/
/
/
1"""Filesystem musicprovider support for MusicAssistant."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import logging
8import os
9import os.path
10import time
11import urllib.parse
12from collections.abc import AsyncGenerator, Sequence
13from datetime import UTC, datetime
14from pathlib import Path
15from typing import TYPE_CHECKING, Any, cast
16
17import aiofiles
18import shortuuid
19import xmltodict
20from aiofiles.os import wrap
21from music_assistant_models.enums import (
22 ContentType,
23 ExternalID,
24 ImageType,
25 MediaType,
26 ProviderFeature,
27 StreamType,
28)
29from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError, SetupFailedError
30from music_assistant_models.media_items import (
31 Album,
32 Artist,
33 Audiobook,
34 AudioFormat,
35 BrowseFolder,
36 ItemMapping,
37 MediaItemChapter,
38 MediaItemImage,
39 MediaItemType,
40 Playlist,
41 Podcast,
42 PodcastEpisode,
43 ProviderMapping,
44 SearchResults,
45 Track,
46 UniqueList,
47 is_track,
48)
49from music_assistant_models.streamdetails import MultiPartPath, StreamDetails
50
51from music_assistant.constants import (
52 CONF_PATH,
53 DB_TABLE_ALBUM_ARTISTS,
54 DB_TABLE_ALBUM_TRACKS,
55 DB_TABLE_ALBUMS,
56 DB_TABLE_ARTISTS,
57 DB_TABLE_PROVIDER_MAPPINGS,
58 DB_TABLE_TRACK_ARTISTS,
59 VARIOUS_ARTISTS_MBID,
60 VARIOUS_ARTISTS_NAME,
61 VERBOSE_LOG_LEVEL,
62)
63from music_assistant.helpers.compare import compare_strings, create_safe_string
64from music_assistant.helpers.json import json_loads
65from music_assistant.helpers.playlists import parse_m3u, parse_pls
66from music_assistant.helpers.tags import AudioTags, async_parse_tags, parse_tags, split_items
67from music_assistant.helpers.util import (
68 TaskManager,
69 detect_charset,
70 parse_title_and_version,
71 try_parse_int,
72)
73from music_assistant.models.music_provider import MusicProvider
74
75from .constants import (
76 AUDIOBOOK_EXTENSIONS,
77 CACHE_CATEGORY_ALBUM_INFO,
78 CACHE_CATEGORY_ARTIST_INFO,
79 CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
80 CACHE_CATEGORY_FOLDER_IMAGES,
81 CACHE_CATEGORY_PODCAST_METADATA,
82 CONF_ENTRY_CONTENT_TYPE,
83 CONF_ENTRY_CONTENT_TYPE_READ_ONLY,
84 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
85 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
86 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
87 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
88 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
89 CONF_ENTRY_MISSING_ALBUM_ARTIST,
90 CONF_ENTRY_PATH,
91 IMAGE_EXTENSIONS,
92 PLAYLIST_EXTENSIONS,
93 PODCAST_EPISODE_EXTENSIONS,
94 SUPPORTED_EXTENSIONS,
95 TRACK_EXTENSIONS,
96 IsChapterFile,
97)
98from .helpers import (
99 FileSystemItem,
100 get_absolute_path,
101 get_album_dir,
102 get_artist_dir,
103 get_relative_path,
104 recursive_iter,
105 sorted_scandir,
106)
107
108if TYPE_CHECKING:
109 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
110 from music_assistant_models.provider import ProviderManifest
111
112 from music_assistant.mass import MusicAssistant
113 from music_assistant.models import ProviderInstanceType
114
115
116isdir = wrap(os.path.isdir)
117isfile = wrap(os.path.isfile)
118exists = wrap(os.path.exists)
119makedirs = wrap(os.makedirs)
120scandir = wrap(os.scandir)
121
122SUPPORTED_FEATURES = {
123 ProviderFeature.BROWSE,
124 ProviderFeature.SEARCH,
125}
126
127
128async def setup(
129 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
130) -> ProviderInstanceType:
131 """Initialize provider(instance) with given configuration."""
132 base_path = cast("str", config.get_value(CONF_PATH))
133 return LocalFileSystemProvider(mass, manifest, config, base_path)
134
135
136async def get_config_entries(
137 mass: MusicAssistant,
138 instance_id: str | None = None,
139 action: str | None = None,
140 values: dict[str, ConfigValueType] | None = None,
141) -> tuple[ConfigEntry, ...]:
142 """
143 Return Config entries to setup this provider.
144
145 instance_id: id of an existing provider instance (None if new instance setup).
146 action: [optional] action key called from config entries UI.
147 values: the (intermediate) raw values for config entries sent with the action.
148 """
149 # ruff: noqa: ARG001
150 base_entries = [
151 CONF_ENTRY_PATH,
152 CONF_ENTRY_MISSING_ALBUM_ARTIST,
153 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
154 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
155 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
156 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
157 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
158 ]
159 if instance_id is None or values is None:
160 return (CONF_ENTRY_CONTENT_TYPE, *base_entries)
161 return (CONF_ENTRY_CONTENT_TYPE_READ_ONLY, *base_entries)
162
163
164class LocalFileSystemProvider(MusicProvider):
165 """
166 Implementation of a musicprovider for (local) files.
167
168 Reads ID3 tags from file and falls back to parsing filename.
169 Optionally reads metadata from nfo files and images in folder structure <artist>/<album>.
170 Supports m3u files for playlists.
171 """
172
173 def __init__(
174 self,
175 mass: MusicAssistant,
176 manifest: ProviderManifest,
177 config: ProviderConfig,
178 base_path: str,
179 ) -> None:
180 """Initialize MusicProvider."""
181 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
182 self.base_path: str = base_path
183 self.write_access: bool = False
184 self.sync_running: bool = False
185 self.media_content_type = cast("str", config.get_value(CONF_ENTRY_CONTENT_TYPE.key))
186
187 @property
188 def supported_features(self) -> set[ProviderFeature]:
189 """Return the features supported by this Provider."""
190 base_features = {*SUPPORTED_FEATURES}
191 if self.media_content_type == "audiobooks":
192 return {ProviderFeature.LIBRARY_AUDIOBOOKS, *base_features}
193 if self.media_content_type == "podcasts":
194 return {ProviderFeature.LIBRARY_PODCASTS, *base_features}
195 music_features = {
196 ProviderFeature.LIBRARY_ALBUMS,
197 ProviderFeature.LIBRARY_ARTISTS,
198 ProviderFeature.LIBRARY_TRACKS,
199 ProviderFeature.LIBRARY_PLAYLISTS,
200 *base_features,
201 }
202 if self.write_access:
203 music_features.add(ProviderFeature.PLAYLIST_TRACKS_EDIT)
204 music_features.add(ProviderFeature.PLAYLIST_CREATE)
205 return music_features
206
207 @property
208 def is_streaming_provider(self) -> bool:
209 """Return True if the provider is a streaming provider."""
210 return False
211
212 @property
213 def instance_name_postfix(self) -> str | None:
214 """Return a (default) instance name postfix for this provider instance."""
215 return self.base_path.split(os.sep)[-1]
216
217 async def handle_async_init(self) -> None:
218 """Handle async initialization of the provider."""
219 if not await isdir(self.base_path):
220 msg = f"Music Directory {self.base_path} does not exist"
221 raise SetupFailedError(msg)
222 await self.check_write_access()
223
224 async def search(
225 self,
226 search_query: str,
227 media_types: list[MediaType] | None,
228 limit: int = 5,
229 ) -> SearchResults:
230 """Perform search on this file based musicprovider."""
231 result = SearchResults()
232 # searching the filesystem is slow and unreliable,
233 # so instead we just query the db...
234 if media_types is None or MediaType.TRACK in media_types:
235 result.tracks = await self.mass.music.tracks.get_library_items_by_query(
236 search=search_query, provider_filter=[self.instance_id], limit=limit
237 )
238
239 if media_types is None or MediaType.ALBUM in media_types:
240 result.albums = await self.mass.music.albums.get_library_items_by_query(
241 search=search_query,
242 provider_filter=[self.instance_id],
243 limit=limit,
244 )
245
246 if media_types is None or MediaType.ARTIST in media_types:
247 result.artists = await self.mass.music.artists.get_library_items_by_query(
248 search=search_query,
249 provider_filter=[self.instance_id],
250 limit=limit,
251 )
252 if media_types is None or MediaType.PLAYLIST in media_types:
253 result.playlists = await self.mass.music.playlists.get_library_items_by_query(
254 search=search_query,
255 provider_filter=[self.instance_id],
256 limit=limit,
257 )
258 if media_types is None or MediaType.AUDIOBOOK in media_types:
259 result.audiobooks = await self.mass.music.audiobooks.get_library_items_by_query(
260 search=search_query,
261 provider_filter=[self.instance_id],
262 limit=limit,
263 )
264 if media_types is None or MediaType.PODCAST in media_types:
265 result.podcasts = await self.mass.music.podcasts.get_library_items_by_query(
266 search=search_query,
267 provider_filter=[self.instance_id],
268 limit=limit,
269 )
270 return result
271
272 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
273 """Browse this provider's items.
274
275 :param path: The path to browse, (e.g. provid://artists).
276 """
277 # for audiobooks and podcasts we just return all library items
278 if self.media_content_type == "podcasts":
279 return await self.mass.music.podcasts.library_items(provider=self.instance_id)
280 if self.media_content_type == "audiobooks":
281 return await self.mass.music.audiobooks.library_items(provider=self.instance_id)
282 items: list[MediaItemType | ItemMapping | BrowseFolder] = []
283 item_path = path.split("://", 1)[1]
284 if not item_path:
285 item_path = ""
286 abs_path = self.get_absolute_path(item_path)
287 for item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True):
288 if not item.is_dir and ("." not in item.filename or not item.ext):
289 # skip system files and files without extension
290 continue
291
292 if item.is_dir:
293 items.append(
294 BrowseFolder(
295 item_id=item.relative_path,
296 provider=self.instance_id,
297 path=f"{self.instance_id}://{item.relative_path}",
298 name=item.filename,
299 # mark folder as playable, assuming it contains tracks underneath
300 is_playable=True,
301 )
302 )
303 elif item.ext in TRACK_EXTENSIONS:
304 items.append(
305 ItemMapping(
306 media_type=MediaType.TRACK,
307 item_id=item.relative_path,
308 provider=self.instance_id,
309 name=item.filename,
310 )
311 )
312 elif item.ext in PLAYLIST_EXTENSIONS:
313 items.append(
314 ItemMapping(
315 media_type=MediaType.PLAYLIST,
316 item_id=item.relative_path,
317 provider=self.instance_id,
318 name=item.filename,
319 )
320 )
321 return items
322
323 async def sync_library(self, media_type: MediaType) -> None:
324 """Run library sync for this provider."""
325 if media_type in (MediaType.ARTIST, MediaType.ALBUM):
326 # artists and albums are synced as part of track sync
327 return
328 assert self.mass.music.database
329 start_time = time.time()
330 if self.sync_running:
331 self.logger.warning("Library sync already running for %s", self.name)
332 return
333 self.logger.info(
334 "Started Library sync for %s",
335 self.name,
336 )
337 file_checksums: dict[str, str] = {}
338 # NOTE: we always run a scan of the entire library, as we need to detect changes
339 # we ignore any given mediatype(s) and just scan all supported files
340 query = (
341 f"SELECT provider_item_id, details FROM {DB_TABLE_PROVIDER_MAPPINGS} "
342 f"WHERE provider_instance = '{self.instance_id}' "
343 f"AND media_type in ('track', 'playlist', 'audiobook', 'podcast_episode')"
344 )
345 for db_row in await self.mass.music.database.get_rows_from_query(query, limit=0):
346 file_checksums[db_row["provider_item_id"]] = str(db_row["details"])
347 # find all supported files in the base directory and all subfolders
348 # we work bottom up, as-in we derive all info from the tracks
349 cur_filenames = set()
350 prev_filenames = set(file_checksums.keys())
351
352 # NOTE: we do the entire traversing of the directory structure, including parsing tags
353 # in a single executor thread to save the overhead of having to spin up tons of tasks
354 def run_sync() -> None:
355 """Run the actual sync (in an executor job)."""
356 self.sync_running = True
357 try:
358 for item in recursive_iter(
359 self.base_path, self.base_path, SUPPORTED_EXTENSIONS, self.logger
360 ):
361 prev_checksum = file_checksums.get(item.relative_path)
362 if self._process_item(item, prev_checksum):
363 cur_filenames.add(item.relative_path)
364 finally:
365 self.sync_running = False
366
367 await asyncio.to_thread(run_sync)
368
369 end_time = time.time()
370 self.logger.info(
371 "Library sync for %s completed in %.2f seconds",
372 self.name,
373 end_time - start_time,
374 )
375 # work out deletions
376 deleted_files = prev_filenames - cur_filenames
377 await self._process_deletions(deleted_files)
378
379 # process orphaned albums and artists
380 await self._process_orphaned_albums_and_artists()
381
382 def _process_item(self, item: FileSystemItem, prev_checksum: str | None) -> bool:
383 """Process a single item. NOT async friendly."""
384 try:
385 self.logger.log(VERBOSE_LOG_LEVEL, "Processing: %s", item.relative_path)
386
387 # ignore playlists that are in album directories
388 # we need to run this check early because the setting may have changed
389 if (
390 item.ext in PLAYLIST_EXTENSIONS
391 and self.media_content_type == "music"
392 and self.config.get_value(CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS.key)
393 ):
394 # we assume this in a bit of a dumb way by just checking if the playlist
395 # is more than 1 level deep in the directory structure
396 if len(item.relative_path.split("/")) > 2:
397 return False
398
399 # return early if the item did not change (checksum still the same)
400 if item.checksum == prev_checksum:
401 return True
402
403 if item.ext in TRACK_EXTENSIONS and self.media_content_type == "music":
404 # handle track item
405 tags = parse_tags(item.absolute_path, item.file_size)
406
407 async def process_track() -> None:
408 track = await self._parse_track(item, tags)
409 # add/update track to db
410 # note that filesystem items are always overwriting existing info
411 # when they are detected as changed
412 track.favorite = False # TODO: implement favorite status based on rating ?
413 await self.mass.music.tracks.add_item_to_library(
414 track, overwrite_existing=prev_checksum is not None
415 )
416
417 asyncio.run_coroutine_threadsafe(process_track(), self.mass.loop).result()
418 return True
419
420 if item.ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
421 # handle audiobook item
422 tags = parse_tags(item.absolute_path, item.file_size)
423
424 async def process_audiobook() -> None:
425 try:
426 audiobook = await self._parse_audiobook(item, tags)
427 except IsChapterFile:
428 return
429 # add/update audiobook to db
430 # note that filesystem items are always overwriting existing info
431 # when they are detected as changed
432 await self.mass.music.audiobooks.add_item_to_library(
433 audiobook, overwrite_existing=prev_checksum is not None
434 )
435
436 asyncio.run_coroutine_threadsafe(process_audiobook(), self.mass.loop).result()
437 return True
438
439 if item.ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
440 # handle podcast(episode) item
441 tags = parse_tags(item.absolute_path, item.file_size)
442
443 async def process_episode() -> None:
444 episode = await self._parse_podcast_episode(item, tags)
445 assert isinstance(episode.podcast, Podcast)
446 # add/update episode to db
447 # note that filesystem items are always overwriting existing info
448 # when they are detected as changed
449 await self.mass.music.podcasts.add_item_to_library(
450 episode.podcast, overwrite_existing=prev_checksum is not None
451 )
452
453 asyncio.run_coroutine_threadsafe(process_episode(), self.mass.loop).result()
454 return True
455
456 if item.ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
457 # handle playlist item
458
459 async def process_playlist() -> None:
460 playlist = await self.get_playlist(item.relative_path)
461 # add/update playlist to db
462 await self.mass.music.playlists.add_item_to_library(
463 playlist,
464 overwrite_existing=prev_checksum is not None,
465 )
466
467 asyncio.run_coroutine_threadsafe(process_playlist(), self.mass.loop).result()
468 return True
469
470 except Exception as err:
471 # we don't want the whole sync to crash on one file so we catch all exceptions here
472 self.logger.error(
473 "Error processing %s - %s",
474 item.relative_path,
475 str(err),
476 exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
477 )
478 return False
479
480 async def _process_orphaned_albums_and_artists(self) -> None:
481 """Process deletion of orphaned albums and artists."""
482 assert self.mass.music.database
483 # Remove albums without any tracks
484 query = (
485 f"SELECT item_id FROM {DB_TABLE_ALBUMS} "
486 f"WHERE item_id not in ( SELECT album_id from {DB_TABLE_ALBUM_TRACKS}) "
487 f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
488 f"WHERE provider_instance = '{self.instance_id}' and media_type = 'album' )"
489 )
490 for db_row in await self.mass.music.database.get_rows_from_query(
491 query,
492 limit=100000,
493 ):
494 await self.mass.music.albums.remove_item_from_library(db_row["item_id"])
495
496 # Remove artists without any tracks or albums
497 query = (
498 f"SELECT item_id FROM {DB_TABLE_ARTISTS} "
499 f"WHERE item_id not in "
500 f"( select artist_id from {DB_TABLE_TRACK_ARTISTS} "
501 f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} ) "
502 f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
503 f"WHERE provider_instance = '{self.instance_id}' and media_type = 'artist' )"
504 )
505 for db_row in await self.mass.music.database.get_rows_from_query(
506 query,
507 limit=100000,
508 ):
509 await self.mass.music.artists.remove_item_from_library(db_row["item_id"])
510
511 async def _process_deletions(self, deleted_files: set[str]) -> None:
512 """Process all deletions."""
513 # process deleted tracks/playlists
514 album_ids = set()
515 artist_ids = set()
516 for file_path in deleted_files:
517 _, ext = file_path.rsplit(".", 1)
518 if ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
519 controller = self.mass.music.get_controller(MediaType.PODCAST_EPISODE)
520 elif ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
521 controller = self.mass.music.get_controller(MediaType.AUDIOBOOK)
522 elif ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
523 controller = self.mass.music.get_controller(MediaType.PLAYLIST)
524 elif ext in TRACK_EXTENSIONS and self.media_content_type == "music":
525 controller = self.mass.music.get_controller(MediaType.TRACK)
526 else:
527 # unsupported file extension?
528 continue
529
530 if library_item := await controller.get_library_item_by_prov_id(
531 file_path, self.instance_id
532 ):
533 if is_track(library_item):
534 if library_item.album:
535 album_ids.add(library_item.album.item_id)
536 # need to fetch the library album to resolve the itemmapping
537 db_album = await self.mass.music.albums.get_library_item(
538 library_item.album.item_id
539 )
540 for artist in db_album.artists:
541 artist_ids.add(artist.item_id)
542 for artist in library_item.artists:
543 artist_ids.add(artist.item_id)
544 await controller.remove_item_from_library(library_item.item_id)
545 # check if any albums need to be cleaned up
546 for album_id in album_ids:
547 if not await self.mass.music.albums.tracks(album_id, "library"):
548 await self.mass.music.albums.remove_item_from_library(album_id)
549 # check if any artists need to be cleaned up
550 for artist_id in artist_ids:
551 artist_albums = await self.mass.music.artists.albums(artist_id, "library")
552 artist_tracks = await self.mass.music.artists.tracks(artist_id, "library")
553 if not (artist_albums or artist_tracks):
554 await self.mass.music.artists.remove_item_from_library(artist_id)
555
556 async def get_artist(self, prov_artist_id: str) -> Artist:
557 """Get full artist details by id."""
558 db_artist = await self.mass.music.artists.get_library_item_by_prov_id(
559 prov_artist_id, self.instance_id
560 )
561 if not db_artist:
562 # this may happen if the artist is not in the db yet
563 # e.g. when browsing the filesystem
564 if await self.exists(prov_artist_id):
565 return await self._parse_artist(prov_artist_id, artist_path=prov_artist_id)
566 return await self._parse_artist(prov_artist_id)
567
568 # prov_artist_id is either an actual (relative) path or a name (as fallback)
569 safe_artist_name = create_safe_string(prov_artist_id, lowercase=False, replace_space=False)
570 if await self.exists(prov_artist_id):
571 artist_path = prov_artist_id
572 elif await self.exists(safe_artist_name):
573 artist_path = safe_artist_name
574 else:
575 for prov_mapping in db_artist.provider_mappings:
576 if prov_mapping.provider_instance != self.instance_id:
577 continue
578 if prov_mapping.url:
579 artist_path = prov_mapping.url
580 break
581 else:
582 # this is an artist without an actual path on disk
583 # return the info we already have in the db
584 return db_artist
585 return await self._parse_artist(
586 db_artist.name,
587 sort_name=db_artist.sort_name,
588 mbid=db_artist.mbid,
589 artist_path=artist_path,
590 )
591
592 async def get_album(self, prov_album_id: str) -> Album:
593 """Get full album details by id."""
594 for track in await self.get_album_tracks(prov_album_id):
595 for prov_mapping in track.provider_mappings:
596 if prov_mapping.provider_instance == self.instance_id:
597 file_item = await self.resolve(prov_mapping.item_id)
598 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
599 full_track = await self._parse_track(file_item, tags)
600 assert isinstance(full_track.album, Album)
601 return full_track.album
602 msg = f"Album not found: {prov_album_id}"
603 raise MediaNotFoundError(msg)
604
605 async def get_track(self, prov_track_id: str) -> Track:
606 """Get full track details by id."""
607 # ruff: noqa: PLR0915
608 if not await self.exists(prov_track_id):
609 msg = f"Track path does not exist: {prov_track_id}"
610 raise MediaNotFoundError(msg)
611
612 file_item = await self.resolve(prov_track_id)
613 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
614 return await self._parse_track(file_item, tags=tags, full_album_metadata=True)
615
616 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
617 """Get full playlist details by id."""
618 if not await self.exists(prov_playlist_id):
619 msg = f"Playlist path does not exist: {prov_playlist_id}"
620 raise MediaNotFoundError(msg)
621
622 file_item = await self.resolve(prov_playlist_id)
623 playlist = Playlist(
624 item_id=file_item.relative_path,
625 provider=self.instance_id,
626 name=file_item.name,
627 provider_mappings={
628 ProviderMapping(
629 item_id=file_item.relative_path,
630 provider_domain=self.domain,
631 provider_instance=self.instance_id,
632 details=file_item.checksum,
633 )
634 },
635 )
636 playlist.is_editable = ProviderFeature.PLAYLIST_TRACKS_EDIT in self.supported_features
637 # only playlists in the root are editable - all other are read only
638 if "/" in prov_playlist_id or "\\" in prov_playlist_id:
639 playlist.is_editable = False
640 # we do not (yet) have support to edit/create pls playlists, only m3u files can be edited
641 if file_item.ext == "pls":
642 playlist.is_editable = False
643 playlist.owner = self.name
644 return playlist
645
646 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
647 """Get full audiobook details by id."""
648 # ruff: noqa: PLR0915
649 if not await self.exists(prov_audiobook_id):
650 msg = f"Audiobook path does not exist: {prov_audiobook_id}"
651 raise MediaNotFoundError(msg)
652
653 file_item = await self.resolve(prov_audiobook_id)
654 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
655 return await self._parse_audiobook(file_item, tags=tags)
656
657 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
658 """Get full podcast details by id."""
659 async for episode in self.get_podcast_episodes(prov_podcast_id):
660 assert isinstance(episode.podcast, Podcast)
661 return episode.podcast
662 msg = f"Podcast not found: {prov_podcast_id}"
663 raise MediaNotFoundError(msg)
664
665 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
666 """Get album tracks for given album id."""
667 # filesystem items are always stored in db so we can query the database
668 db_album = await self.mass.music.albums.get_library_item_by_prov_id(
669 prov_album_id, self.instance_id
670 )
671 if db_album is None:
672 msg = f"Album not found: {prov_album_id}"
673 raise MediaNotFoundError(msg)
674 album_tracks = await self.mass.music.albums.get_library_album_tracks(db_album.item_id)
675 return [
676 track
677 for track in album_tracks
678 if any(x.provider_instance == self.instance_id for x in track.provider_mappings)
679 ]
680
681 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
682 """Get playlist tracks."""
683 result: list[Track] = []
684 if page > 0:
685 # paging not (yet) supported
686 return result
687 if not await self.exists(prov_playlist_id):
688 msg = f"Playlist path does not exist: {prov_playlist_id}"
689 raise MediaNotFoundError(msg)
690
691 file_item = await self.resolve(prov_playlist_id)
692 # We are using the checksum of the playlist file here to invalidate the cache
693 # when a change has been made to the playlist file (ie track addition/deletion)
694 cache_checksum = file_item.checksum
695
696 cache_key = f"get_playlist_tracks.{prov_playlist_id}"
697 cached_data = await self.mass.cache.get(
698 cache_key,
699 provider=self.instance_id,
700 checksum=cache_checksum,
701 category=0,
702 )
703 if cached_data is not None:
704 if cached_data and isinstance(cached_data[0], dict):
705 return [Track.from_dict(track_dict) for track_dict in cached_data]
706 return cast("list[Track]", cached_data)
707
708 _, ext = prov_playlist_id.rsplit(".", 1)
709 try:
710 # get playlist file contents
711 playlist_filename = self.get_absolute_path(prov_playlist_id)
712 async with aiofiles.open(playlist_filename, mode="rb") as _file:
713 playlist_data_raw = await _file.read()
714 encoding = await detect_charset(playlist_data_raw)
715 playlist_data = playlist_data_raw.decode(encoding, errors="replace")
716
717 if ext in ("m3u", "m3u8"):
718 playlist_lines = parse_m3u(playlist_data)
719 else:
720 playlist_lines = parse_pls(playlist_data)
721
722 for idx, playlist_line in enumerate(playlist_lines, 1):
723 if "#EXT" in playlist_line.path:
724 continue
725 if track := await self._parse_playlist_line(
726 playlist_line.path, os.path.dirname(prov_playlist_id)
727 ):
728 track.position = idx
729 result.append(track)
730
731 except Exception as err:
732 self.logger.warning(
733 "Error while parsing playlist %s: %s",
734 prov_playlist_id,
735 str(err),
736 exc_info=err if self.logger.isEnabledFor(10) else None,
737 )
738
739 await self.mass.cache.set(
740 key=cache_key,
741 data=result,
742 expiration=3600 * 24, # Cache for 24 hours
743 provider=self.instance_id,
744 checksum=cache_checksum,
745 category=0,
746 )
747
748 return result
749
750 async def get_podcast_episodes(
751 self, prov_podcast_id: str
752 ) -> AsyncGenerator[PodcastEpisode, None]:
753 """Get podcast episodes for given podcast id."""
754 episodes: list[PodcastEpisode] = []
755
756 async def _process_podcast_episode(item: FileSystemItem) -> None:
757 tags = await async_parse_tags(item.absolute_path, item.file_size)
758 try:
759 episode = await self._parse_podcast_episode(item, tags)
760 except MusicAssistantError as err:
761 self.logger.warning(
762 "Could not parse uri/file %s to podcast episode: %s",
763 item.relative_path,
764 str(err),
765 )
766 else:
767 episodes.append(episode)
768
769 async with TaskManager(self.mass, 25) as tm:
770 for item in await asyncio.to_thread(sorted_scandir, self.base_path, prov_podcast_id):
771 if "." not in item.relative_path or item.is_dir:
772 continue
773 if item.ext not in PODCAST_EPISODE_EXTENSIONS:
774 continue
775 tm.create_task(_process_podcast_episode(item))
776
777 for episode in episodes:
778 yield episode
779
780 async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None:
781 """Try to parse a track from a playlist line."""
782 try:
783 line = line.replace("file://", "").strip()
784 # try to resolve the filename (both normal and url decoded):
785 # - as an absolute path
786 # - relative to the playlist path
787 # - relative to our base path
788 # - relative to the playlist path with a leading slash
789 for _line in (line, urllib.parse.unquote(line)):
790 for filename in (
791 # try to resolve the line by resolving it against the (absolute) playlist path
792 # use the path.resolve step in between to auto-resolve parent item references
793 (Path(self.get_absolute_path(playlist_path)) / _line).resolve().as_posix(),
794 # try to resolve the line as a full absolute (or relative to music dir) path
795 _line,
796 ):
797 with contextlib.suppress(FileNotFoundError):
798 file_item = await self.resolve(filename)
799 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
800 return await self._parse_track(file_item, tags)
801 # all attempts failed
802 raise MediaNotFoundError("Invalid path/uri")
803
804 except MusicAssistantError as err:
805 self.logger.warning("Could not parse %s to track: %s", line, str(err))
806
807 return None
808
809 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
810 """Add track(s) to playlist."""
811 if not await self.exists(prov_playlist_id):
812 msg = f"Playlist path does not exist: {prov_playlist_id}"
813 raise MediaNotFoundError(msg)
814 playlist_filename = self.get_absolute_path(prov_playlist_id)
815 async with aiofiles.open(playlist_filename, encoding="utf-8") as _file:
816 playlist_data = await _file.read()
817 for file_path in prov_track_ids:
818 track = await self.get_track(file_path)
819 playlist_data += f"\n#EXTINF:{track.duration or 0},{track.name}\n{file_path}\n"
820
821 # write playlist file (always in utf-8)
822 async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
823 await _file.write(playlist_data)
824
825 async def remove_playlist_tracks(
826 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
827 ) -> None:
828 """Remove track(s) from playlist."""
829 if not await self.exists(prov_playlist_id):
830 msg = f"Playlist path does not exist: {prov_playlist_id}"
831 raise MediaNotFoundError(msg)
832 _, ext = prov_playlist_id.rsplit(".", 1)
833 # get playlist file contents
834 playlist_filename = self.get_absolute_path(prov_playlist_id)
835 async with aiofiles.open(playlist_filename, encoding="utf-8") as _file:
836 playlist_data = await _file.read()
837 # get current contents first
838 if ext in ("m3u", "m3u8"):
839 playlist_items = parse_m3u(playlist_data)
840 else:
841 playlist_items = parse_pls(playlist_data)
842 # remove items by index
843 for i in sorted(positions_to_remove, reverse=True):
844 # position = index + 1
845 del playlist_items[i - 1]
846 # build new playlist data
847 new_playlist_data = "#EXTM3U\n"
848 for item in playlist_items:
849 new_playlist_data += f"\n#EXTINF:{item.length or 0},{item.title}\n{item.path}\n"
850 async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
851 await _file.write(new_playlist_data)
852
853 async def create_playlist(self, name: str) -> Playlist:
854 """Create a new playlist on provider with given name."""
855 # creating a new playlist on the filesystem is as easy
856 # as creating a new (empty) file with the m3u extension...
857 # filename = await self.resolve(f"{name}.m3u")
858 filename = f"{name}.m3u"
859 playlist_filename = self.get_absolute_path(filename)
860 async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
861 await _file.write("#EXTM3U\n")
862 return await self.get_playlist(filename)
863
864 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
865 """Return the content details for the given track when it will be streamed."""
866 try:
867 if media_type == MediaType.AUDIOBOOK:
868 return await self._get_stream_details_for_audiobook(item_id)
869 if media_type == MediaType.PODCAST_EPISODE:
870 return await self._get_stream_details_for_podcast_episode(item_id)
871 return await self._get_stream_details_for_track(item_id)
872 except FileNotFoundError:
873 self.logger.warning(
874 "File not found for media item %s",
875 item_id,
876 )
877 msg = f"Media file not found: {item_id}"
878 raise MediaNotFoundError(msg)
879
880 async def resolve_image(self, path: str) -> str | bytes:
881 """
882 Resolve an image from an image path.
883
884 This either returns (a generator to get) raw bytes of the image or
885 a string with an http(s) URL or local path that is accessible from the server.
886 """
887 file_item = await self.resolve(path)
888 return file_item.absolute_path
889
890 async def _parse_track(
891 self, file_item: FileSystemItem, tags: AudioTags, full_album_metadata: bool = False
892 ) -> Track:
893 """Parse full track details from file tags."""
894 # ruff: noqa: PLR0915
895 name, version = parse_title_and_version(tags.title, tags.version)
896 track = Track(
897 item_id=file_item.relative_path,
898 provider=self.instance_id,
899 name=name,
900 sort_name=tags.title_sort,
901 version=version,
902 provider_mappings={
903 ProviderMapping(
904 item_id=file_item.relative_path,
905 provider_domain=self.domain,
906 provider_instance=self.instance_id,
907 audio_format=AudioFormat(
908 content_type=ContentType.try_parse(file_item.ext or tags.format),
909 sample_rate=tags.sample_rate,
910 bit_depth=tags.bits_per_sample,
911 channels=tags.channels,
912 bit_rate=tags.bit_rate,
913 ),
914 details=file_item.checksum,
915 in_library=True,
916 )
917 },
918 disc_number=tags.disc or 0,
919 track_number=tags.track or 0,
920 date_added=(
921 datetime.fromtimestamp(file_item.created_at, tz=UTC)
922 if file_item.created_at
923 else None
924 ),
925 )
926
927 if isrc_tags := tags.isrc:
928 for isrsc in isrc_tags:
929 track.external_ids.add((ExternalID.ISRC, isrsc))
930
931 if acoustid := tags.get("acoustid"):
932 track.external_ids.add((ExternalID.ACOUSTID, acoustid))
933
934 # album
935 album = track.album = (
936 await self._parse_album(
937 track_path=file_item.relative_path,
938 track_tags=tags,
939 track_created_at=file_item.created_at,
940 )
941 if tags.album
942 else None
943 )
944
945 # track artist(s)
946 for index, track_artist_str in enumerate(tags.artists):
947 # prefer album artist if match
948 if album and (
949 album_artist_match := next(
950 (x for x in album.artists if x.name == track_artist_str), None
951 )
952 ):
953 track.artists.append(album_artist_match)
954 continue
955 artist = await self._parse_artist(
956 track_artist_str,
957 sort_name=(
958 tags.artist_sort_names[index] if index < len(tags.artist_sort_names) else None
959 ),
960 mbid=(
961 tags.musicbrainz_artistids[index]
962 if index < len(tags.musicbrainz_artistids)
963 else None
964 ),
965 )
966 track.artists.append(artist)
967
968 # handle embedded cover image
969 if tags.has_cover_image:
970 # we do not actually embed the image in the metadata because that would consume too
971 # much space and bandwidth. Instead we set the filename as value so the image can
972 # be retrieved later in realtime.
973 track.metadata.images = UniqueList(
974 [
975 MediaItemImage(
976 type=ImageType.THUMB,
977 path=file_item.relative_path,
978 provider=self.instance_id,
979 remotely_accessible=False,
980 )
981 ]
982 )
983
984 # copy (embedded) album image from track (if the album itself doesn't have an image)
985 if album and not album.image and track.image:
986 album.metadata.images = UniqueList([track.image])
987
988 # parse other info
989 track.duration = int(tags.duration or 0)
990 track.metadata.genres = set(tags.genres)
991 if tags.disc:
992 track.disc_number = tags.disc
993 if tags.track:
994 track.track_number = tags.track
995 track.metadata.copyright = tags.get("copyright")
996 track.metadata.lyrics = tags.lyrics
997 track.metadata.grouping = tags.get("grouping")
998 track.metadata.description = tags.get("comment")
999 explicit_tag = tags.get("itunesadvisory")
1000 if explicit_tag is not None:
1001 track.metadata.explicit = explicit_tag == "1"
1002 if tags.musicbrainz_recordingid:
1003 track.mbid = tags.musicbrainz_recordingid
1004
1005 # handle (optional) loudness measurement tag(s)
1006 if tags.track_loudness is not None:
1007 self.mass.create_task(
1008 self.mass.music.set_loudness(
1009 track.item_id,
1010 self.instance_id,
1011 tags.track_loudness,
1012 tags.track_album_loudness,
1013 )
1014 )
1015
1016 # possible lrclib metadata
1017 # synced lyrics are saved as "filename.lrc" by lrcget alongside
1018 # the actual file location - just change the file extension
1019 assert file_item.ext is not None # for type checking
1020 lrc_path = f"{file_item.absolute_path.removesuffix(file_item.ext)}lrc"
1021 if await self.exists(lrc_path):
1022 try:
1023 async with aiofiles.open(lrc_path, encoding="utf-8") as lrc_file:
1024 track.metadata.lrc_lyrics = await lrc_file.read()
1025 except Exception as err:
1026 self.logger.warning(
1027 "Failed to read lyrics file %s: %s",
1028 lrc_path,
1029 str(err),
1030 )
1031
1032 return track
1033
1034 async def _parse_artist(
1035 self,
1036 name: str,
1037 album_dir: str | None = None,
1038 sort_name: str | None = None,
1039 mbid: str | None = None,
1040 artist_path: str | None = None,
1041 ) -> Artist:
1042 """Parse full (album) Artist."""
1043 if not artist_path:
1044 # we need to hunt for the artist (metadata) path on disk
1045 # this can either be relative to the album path or at root level
1046 # check if we have an artist folder for this artist at root level
1047 safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
1048 if await self.exists(name):
1049 artist_path = name
1050 elif await self.exists(safe_artist_name):
1051 artist_path = safe_artist_name
1052 elif album_dir and (foldermatch := get_artist_dir(name, album_dir=album_dir)):
1053 # try to find (album)artist folder based on album path
1054 artist_path = foldermatch
1055 else:
1056 # check if we have an existing item to retrieve the artist path
1057 async for item in self.mass.music.artists.iter_library_items(
1058 search=name, provider=self.instance_id
1059 ):
1060 if not compare_strings(name, item.name):
1061 continue
1062 for prov_mapping in item.provider_mappings:
1063 if prov_mapping.provider_instance != self.instance_id:
1064 continue
1065 if prov_mapping.url:
1066 artist_path = prov_mapping.url
1067 break
1068 if artist_path:
1069 break
1070
1071 # prefer (short lived) cache for a bit more speed
1072 if artist_path and (
1073 cache := await self.cache.get(
1074 key=artist_path, provider=self.instance_id, category=CACHE_CATEGORY_ARTIST_INFO
1075 )
1076 ):
1077 return cast("Artist", cache)
1078
1079 prov_artist_id = artist_path or name
1080 artist = Artist(
1081 item_id=prov_artist_id,
1082 provider=self.instance_id,
1083 name=name,
1084 sort_name=sort_name,
1085 provider_mappings={
1086 ProviderMapping(
1087 item_id=prov_artist_id,
1088 provider_domain=self.domain,
1089 provider_instance=self.instance_id,
1090 url=artist_path,
1091 in_library=True,
1092 )
1093 },
1094 )
1095 if mbid:
1096 artist.mbid = mbid
1097 if not artist_path:
1098 return artist
1099
1100 # grab additional metadata within the Artist's folder
1101 nfo_file = os.path.join(artist_path, "artist.nfo")
1102 if await self.exists(nfo_file):
1103 # found NFO file with metadata
1104 # https://kodi.wiki/view/NFO_files/Artists
1105 nfo_file = self.get_absolute_path(nfo_file)
1106 async with aiofiles.open(nfo_file) as _file:
1107 data = await _file.read()
1108 info = await asyncio.to_thread(xmltodict.parse, data)
1109 info = info["artist"]
1110 artist.name = info.get("title", info.get("name", name))
1111 if sort_name := info.get("sortname"):
1112 artist.sort_name = sort_name
1113 if mbid := info.get("musicbrainzartistid"):
1114 artist.mbid = mbid
1115 if description := info.get("biography"):
1116 artist.metadata.description = description
1117 if genre := info.get("genre"):
1118 artist.metadata.genres = set(split_items(genre))
1119 # find local images
1120 if images := await self._get_local_images(artist_path, extra_thumb_names=("artist",)):
1121 artist.metadata.images = UniqueList(images)
1122
1123 await self.cache.set(
1124 key=artist_path,
1125 data=artist,
1126 provider=self.instance_id,
1127 category=CACHE_CATEGORY_ARTIST_INFO,
1128 expiration=120,
1129 )
1130
1131 return artist
1132
1133 async def _parse_audiobook(self, file_item: FileSystemItem, tags: AudioTags) -> Audiobook:
1134 """Parse Audiobook details from file tags.
1135
1136 Audiobooks can be single files with embedded chapters or multiple files per folder.
1137 Only the first file (by track number or alphabetically) is processed as the audiobook.
1138 """
1139 # Skip files that aren't the first chapter
1140 track_tag = tags.tags.get("track")
1141 if track_tag:
1142 track_num = try_parse_int(str(track_tag).split("/")[0], None)
1143 if track_num and track_num > 1:
1144 raise IsChapterFile
1145 else:
1146 # No track tag - only process the first file alphabetically
1147 abs_path = self.get_absolute_path(file_item.parent_path)
1148 for item in await asyncio.to_thread(
1149 sorted_scandir, self.base_path, abs_path, sort=True
1150 ):
1151 if item.is_dir or item.ext not in AUDIOBOOK_EXTENSIONS:
1152 continue
1153 if item.absolute_path != file_item.absolute_path:
1154 raise IsChapterFile
1155 break
1156
1157 # For multi-file audiobooks, album tag is the book name, title is the chapter name
1158 if tags.album:
1159 book_name = tags.album
1160 sort_name = tags.album_sort
1161 elif (title := tags.tags.get("title")) and tags.track is None:
1162 book_name = title
1163 sort_name = tags.title_sort
1164 else:
1165 # file(s) without tags, use foldername
1166 book_name = file_item.parent_name
1167 sort_name = None
1168
1169 # collect all chapters
1170 total_duration, chapters = await self._get_chapters_for_audiobook(file_item, tags)
1171
1172 audio_book = Audiobook(
1173 item_id=file_item.relative_path,
1174 provider=self.instance_id,
1175 name=book_name,
1176 sort_name=sort_name,
1177 version=tags.version,
1178 duration=total_duration or int(tags.duration or 0),
1179 provider_mappings={
1180 ProviderMapping(
1181 item_id=file_item.relative_path,
1182 provider_domain=self.domain,
1183 provider_instance=self.instance_id,
1184 audio_format=AudioFormat(
1185 content_type=ContentType.try_parse(file_item.ext or tags.format),
1186 sample_rate=tags.sample_rate,
1187 bit_depth=tags.bits_per_sample,
1188 channels=tags.channels,
1189 bit_rate=tags.bit_rate,
1190 ),
1191 details=file_item.checksum,
1192 in_library=True,
1193 )
1194 },
1195 )
1196 audio_book.metadata.chapters = chapters
1197
1198 # handle embedded cover image
1199 if tags.has_cover_image:
1200 # we do not actually embed the image in the metadata because that would consume too
1201 # much space and bandwidth. Instead we set the filename as value so the image can
1202 # be retrieved later in realtime.
1203 audio_book.metadata.add_image(
1204 MediaItemImage(
1205 type=ImageType.THUMB,
1206 path=file_item.relative_path,
1207 provider=self.instance_id,
1208 remotely_accessible=False,
1209 )
1210 )
1211
1212 # parse other info
1213 audio_book.authors.set(tags.writers or tags.album_artists or tags.artists)
1214 audio_book.metadata.genres = set(tags.genres)
1215 audio_book.metadata.copyright = tags.get("copyright")
1216 audio_book.metadata.lyrics = tags.lyrics
1217 audio_book.metadata.description = tags.get("comment")
1218 explicit_tag = tags.get("itunesadvisory")
1219 if explicit_tag is not None:
1220 audio_book.metadata.explicit = explicit_tag == "1"
1221 if tags.musicbrainz_recordingid:
1222 audio_book.mbid = tags.musicbrainz_recordingid
1223
1224 # try to fetch additional metadata from the folder
1225 if not audio_book.image or not audio_book.metadata.description:
1226 # try to get an image by traversing files in the same folder
1227 abs_path = self.get_absolute_path(file_item.parent_path)
1228 for _item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path):
1229 if "." not in _item.relative_path or _item.is_dir:
1230 continue
1231 if _item.ext in IMAGE_EXTENSIONS and not audio_book.image:
1232 audio_book.metadata.add_image(
1233 MediaItemImage(
1234 type=ImageType.THUMB,
1235 path=_item.relative_path,
1236 provider=self.instance_id,
1237 remotely_accessible=False,
1238 )
1239 )
1240 if _item.ext == "txt" and not audio_book.metadata.description:
1241 # try to parse a description from a text file
1242 try:
1243 async with aiofiles.open(_item.absolute_path, encoding="utf-8") as _file:
1244 description = await _file.read()
1245 audio_book.metadata.description = description
1246 except Exception as err:
1247 self.logger.warning(
1248 "Could not read description from file %s: %s",
1249 _item.relative_path,
1250 str(err),
1251 )
1252
1253 # handle (optional) loudness measurement tag(s)
1254 if tags.track_loudness is not None:
1255 self.mass.create_task(
1256 self.mass.music.set_loudness(
1257 audio_book.item_id,
1258 self.instance_id,
1259 tags.track_loudness,
1260 tags.track_album_loudness,
1261 media_type=MediaType.AUDIOBOOK,
1262 )
1263 )
1264 return audio_book
1265
1266 async def _parse_podcast_episode(
1267 self, file_item: FileSystemItem, tags: AudioTags
1268 ) -> PodcastEpisode:
1269 """Parse full PodcastEpisode details from file tags."""
1270 # ruff: noqa: PLR0915
1271 podcast_name = tags.album or file_item.parent_name
1272 podcast_path = get_relative_path(self.base_path, file_item.parent_path)
1273 episode = PodcastEpisode(
1274 item_id=file_item.relative_path,
1275 provider=self.instance_id,
1276 name=tags.title,
1277 sort_name=tags.title_sort,
1278 provider_mappings={
1279 ProviderMapping(
1280 item_id=file_item.relative_path,
1281 provider_domain=self.domain,
1282 provider_instance=self.instance_id,
1283 audio_format=AudioFormat(
1284 content_type=ContentType.try_parse(file_item.ext or tags.format),
1285 sample_rate=tags.sample_rate,
1286 bit_depth=tags.bits_per_sample,
1287 channels=tags.channels,
1288 bit_rate=tags.bit_rate,
1289 ),
1290 details=file_item.checksum,
1291 in_library=True,
1292 )
1293 },
1294 position=tags.track or 0,
1295 duration=try_parse_int(tags.duration) or 0,
1296 podcast=Podcast(
1297 item_id=podcast_path,
1298 provider=self.instance_id,
1299 name=podcast_name,
1300 sort_name=tags.album_sort,
1301 publisher=tags.tags.get("publisher"),
1302 provider_mappings={
1303 ProviderMapping(
1304 item_id=podcast_path,
1305 provider_domain=self.domain,
1306 provider_instance=self.instance_id,
1307 )
1308 },
1309 ),
1310 )
1311 # handle embedded cover image
1312 if tags.has_cover_image:
1313 # we do not actually embed the image in the metadata because that would consume too
1314 # much space and bandwidth. Instead we set the filename as value so the image can
1315 # be retrieved later in realtime.
1316 episode.metadata.add_image(
1317 MediaItemImage(
1318 type=ImageType.THUMB,
1319 path=file_item.relative_path,
1320 provider=self.instance_id,
1321 remotely_accessible=False,
1322 )
1323 )
1324 # parse other info
1325 episode.metadata.genres = set(tags.genres)
1326 episode.metadata.copyright = tags.get("copyright")
1327 episode.metadata.lyrics = tags.lyrics
1328 episode.metadata.description = tags.get("comment")
1329 explicit_tag = tags.get("itunesadvisory")
1330 if explicit_tag is not None:
1331 episode.metadata.explicit = explicit_tag == "1"
1332
1333 # handle (optional) chapters
1334 if tags.chapters:
1335 episode.metadata.chapters = [
1336 MediaItemChapter(
1337 position=chapter.chapter_id,
1338 name=chapter.title or f"Chapter {chapter.chapter_id}",
1339 start=chapter.position_start,
1340 end=chapter.position_end,
1341 )
1342 for chapter in tags.chapters
1343 ]
1344
1345 # try to fetch additional Podcast metadata from the folder
1346 assert isinstance(episode.podcast, Podcast)
1347 if images := await self._get_local_images(file_item.parent_path):
1348 episode.podcast.metadata.images = images
1349 if metadata := await self._get_podcast_metadata(file_item.parent_path):
1350 if title := metadata.get("title"):
1351 episode.podcast.name = title
1352 if sort_name := metadata.get("sorttitle"):
1353 episode.podcast.sort_name = sort_name
1354 if description := metadata.get("description"):
1355 episode.podcast.metadata.description = description
1356 if genres := metadata.get("genres"):
1357 episode.podcast.metadata.genres = set(genres)
1358 if publisher := metadata.get("publisher"):
1359 episode.podcast.publisher = publisher
1360 if image := metadata.get("imageURL"):
1361 episode.podcast.metadata.add_image(
1362 MediaItemImage(
1363 type=ImageType.THUMB,
1364 path=image,
1365 provider=self.instance_id,
1366 remotely_accessible=True,
1367 )
1368 )
1369 # copy (embedded) image from episode (or vice versa)
1370 if not episode.podcast.image and episode.image:
1371 episode.podcast.metadata.add_image(episode.image)
1372 elif not episode.image and episode.podcast.image:
1373 episode.metadata.add_image(episode.podcast.image)
1374
1375 # handle (optional) loudness measurement tag(s)
1376 if tags.track_loudness is not None:
1377 self.mass.create_task(
1378 self.mass.music.set_loudness(
1379 episode.item_id,
1380 self.instance_id,
1381 tags.track_loudness,
1382 tags.track_album_loudness,
1383 media_type=MediaType.PODCAST_EPISODE,
1384 )
1385 )
1386 return episode
1387
1388 async def _parse_album(
1389 self, track_path: str, track_tags: AudioTags, track_created_at: int | None = None
1390 ) -> Album:
1391 """Parse Album metadata from Track tags.
1392
1393 :param track_path: Path to the track file.
1394 :param track_tags: Audio tags from the track.
1395 :param track_created_at: Creation timestamp of the track file (Unix epoch).
1396 """
1397 assert track_tags.album
1398 # work out if we have an album and/or disc folder
1399 # track_dir is the folder level where the tracks are located
1400 # this may be a separate disc folder (Disc 1, Disc 2 etc) underneath the album folder
1401 # or this is an album folder with the disc attached
1402 track_dir = os.path.dirname(track_path)
1403 album_dir = get_album_dir(track_dir, track_tags.album)
1404
1405 if album_dir and (
1406 cache := await self.cache.get(
1407 key=album_dir,
1408 provider=self.instance_id,
1409 category=CACHE_CATEGORY_ALBUM_INFO,
1410 )
1411 ):
1412 return cast("Album", cache)
1413
1414 # album artist(s)
1415 album_artists: UniqueList[Artist | ItemMapping] = UniqueList()
1416 if track_tags.album_artists:
1417 for index, album_artist_str in enumerate(track_tags.album_artists):
1418 artist = await self._parse_artist(
1419 album_artist_str,
1420 album_dir=album_dir,
1421 sort_name=(
1422 track_tags.album_artist_sort_names[index]
1423 if index < len(track_tags.album_artist_sort_names)
1424 else None
1425 ),
1426 mbid=(
1427 track_tags.musicbrainz_albumartistids[index]
1428 if index < len(track_tags.musicbrainz_albumartistids)
1429 else None
1430 ),
1431 )
1432 album_artists.append(artist)
1433 else:
1434 # album artist tag is missing, determine fallback
1435 fallback_action = self.config.get_value(CONF_ENTRY_MISSING_ALBUM_ARTIST.key)
1436 if fallback_action == "folder_name" and album_dir:
1437 possible_artist_folder = os.path.dirname(album_dir)
1438 self.logger.warning(
1439 "%s is missing ID3 tag [albumartist], using foldername %s as fallback",
1440 track_path,
1441 possible_artist_folder,
1442 )
1443 album_artist_str = possible_artist_folder.rsplit(os.sep)[-1]
1444 album_artists = UniqueList(
1445 [await self._parse_artist(name=album_artist_str, album_dir=album_dir)]
1446 )
1447 # fallback to track artists (if defined by user)
1448 elif fallback_action == "track_artist":
1449 self.logger.warning(
1450 "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
1451 track_path,
1452 )
1453 album_artists = UniqueList(
1454 [
1455 await self._parse_artist(name=track_artist_str, album_dir=album_dir)
1456 for track_artist_str in track_tags.artists
1457 ]
1458 )
1459 # all other: fallback to various artists
1460 else:
1461 self.logger.warning(
1462 "%s is missing ID3 tag [albumartist], using %s as fallback",
1463 track_path,
1464 VARIOUS_ARTISTS_NAME,
1465 )
1466 album_artists = UniqueList(
1467 [await self._parse_artist(name=VARIOUS_ARTISTS_NAME, mbid=VARIOUS_ARTISTS_MBID)]
1468 )
1469
1470 if album_dir: # noqa: SIM108
1471 # prefer the path as id
1472 item_id = album_dir
1473 else:
1474 # create fake item_id based on artist + album
1475 item_id = album_artists[0].name + os.sep + track_tags.album
1476
1477 name, version = parse_title_and_version(track_tags.album)
1478 album = Album(
1479 item_id=item_id,
1480 provider=self.instance_id,
1481 name=name,
1482 version=version,
1483 sort_name=track_tags.album_sort,
1484 artists=album_artists,
1485 provider_mappings={
1486 ProviderMapping(
1487 item_id=item_id,
1488 provider_domain=self.domain,
1489 provider_instance=self.instance_id,
1490 url=album_dir,
1491 in_library=True,
1492 )
1493 },
1494 date_added=(
1495 datetime.fromtimestamp(track_created_at, tz=UTC) if track_created_at else None
1496 ),
1497 )
1498 if track_tags.barcode:
1499 album.external_ids.add((ExternalID.BARCODE, track_tags.barcode))
1500
1501 if track_tags.musicbrainz_albumid:
1502 album.mbid = track_tags.musicbrainz_albumid
1503 if track_tags.musicbrainz_releasegroupid:
1504 album.add_external_id(ExternalID.MB_RELEASEGROUP, track_tags.musicbrainz_releasegroupid)
1505 if track_tags.year:
1506 album.year = track_tags.year
1507 album.album_type = track_tags.album_type
1508
1509 # hunt for additional metadata and images in the folder structure
1510 if not album_dir:
1511 return album
1512
1513 for folder_path in (track_dir, album_dir):
1514 if not folder_path or not await self.exists(folder_path):
1515 continue
1516 nfo_file = os.path.join(folder_path, "album.nfo")
1517 if await self.exists(nfo_file):
1518 # found NFO file with metadata
1519 # https://kodi.wiki/view/NFO_files/Artists
1520 nfo_file = self.get_absolute_path(nfo_file)
1521 async with aiofiles.open(nfo_file) as _file:
1522 data = await _file.read()
1523 info = await asyncio.to_thread(xmltodict.parse, data)
1524 info = info["album"]
1525 album.name = info.get("title", info.get("name", name))
1526 if sort_name := info.get("sortname"):
1527 album.sort_name = sort_name
1528 if releasegroup_id := info.get("musicbrainzreleasegroupid"):
1529 album.add_external_id(ExternalID.MB_RELEASEGROUP, releasegroup_id)
1530 if album_id := info.get("musicbrainzalbumid"):
1531 album.add_external_id(ExternalID.MB_ALBUM, album_id)
1532 if mb_artist_id := info.get("musicbrainzalbumartistid"):
1533 if album.artists and not album.artists[0].mbid:
1534 album.artists[0].mbid = mb_artist_id
1535 if description := info.get("review"):
1536 album.metadata.description = description
1537 if year := info.get("year"):
1538 album.year = int(year)
1539 if genre := info.get("genre"):
1540 album.metadata.genres = set(split_items(genre))
1541 # parse name/version
1542 album.name, album.version = parse_title_and_version(album.name)
1543 # find local images
1544 if images := await self._get_local_images(folder_path, extra_thumb_names=("album",)):
1545 if album.metadata.images is None:
1546 album.metadata.images = UniqueList(images)
1547 else:
1548 album.metadata.images += images
1549 await self.cache.set(
1550 key=album_dir,
1551 data=album,
1552 provider=self.instance_id,
1553 category=CACHE_CATEGORY_ALBUM_INFO,
1554 expiration=120,
1555 )
1556 return album
1557
1558 async def _get_local_images(
1559 self, folder: str, extra_thumb_names: tuple[str, ...] | None = None
1560 ) -> UniqueList[MediaItemImage]:
1561 """Return local images found in a given folderpath."""
1562 if (
1563 cache := await self.cache.get(
1564 key=folder, provider=self.instance_id, category=CACHE_CATEGORY_FOLDER_IMAGES
1565 )
1566 ) is not None:
1567 return cast("UniqueList[MediaItemImage]", cache)
1568 if extra_thumb_names is None:
1569 extra_thumb_names = ()
1570 images: UniqueList[MediaItemImage] = UniqueList()
1571 abs_path = self.get_absolute_path(folder)
1572 folder_files = await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=False)
1573 for item in folder_files:
1574 if "." not in item.relative_path or item.is_dir or not item.ext:
1575 continue
1576 if item.ext.lower() not in IMAGE_EXTENSIONS:
1577 continue
1578 # try match on filename = one of our imagetypes
1579 if item.name.lower() in ImageType:
1580 images.append(
1581 MediaItemImage(
1582 type=ImageType(item.name),
1583 path=item.relative_path,
1584 provider=self.instance_id,
1585 remotely_accessible=False,
1586 )
1587 )
1588
1589 # try alternative names for thumbs
1590 extra_thumb_names = ("folder", "cover", *extra_thumb_names)
1591 for item in folder_files:
1592 if "." not in item.relative_path or item.is_dir or not item.ext:
1593 continue
1594 if item.ext.lower() not in IMAGE_EXTENSIONS:
1595 continue
1596 if item.name.lower() not in extra_thumb_names:
1597 continue
1598 images.append(
1599 MediaItemImage(
1600 type=ImageType.THUMB,
1601 path=item.relative_path,
1602 provider=self.instance_id,
1603 remotely_accessible=False,
1604 )
1605 )
1606
1607 await self.cache.set(
1608 key=folder,
1609 data=images,
1610 provider=self.instance_id,
1611 category=CACHE_CATEGORY_FOLDER_IMAGES,
1612 expiration=120,
1613 )
1614 return images
1615
1616 async def check_write_access(self) -> None:
1617 """Perform check if we have write access."""
1618 # verify write access to determine we have playlist create/edit support
1619 # overwrite with provider specific implementation if needed
1620 temp_file_name = self.get_absolute_path(f"{shortuuid.random(8)}.txt")
1621 try:
1622 async with aiofiles.open(temp_file_name, "w") as _file:
1623 await _file.write("test")
1624 await asyncio.to_thread(os.remove, temp_file_name)
1625 self.write_access = True
1626 except Exception as err:
1627 self.logger.debug("Write access disabled: %s", str(err))
1628
1629 async def resolve(
1630 self,
1631 file_path: str,
1632 ) -> FileSystemItem:
1633 """Resolve (absolute or relative) path to FileSystemItem."""
1634 absolute_path = self.get_absolute_path(file_path)
1635
1636 def _create_item() -> FileSystemItem:
1637 if os.path.isdir(absolute_path):
1638 return FileSystemItem(
1639 filename=os.path.basename(file_path),
1640 relative_path=get_relative_path(self.base_path, file_path),
1641 absolute_path=absolute_path,
1642 is_dir=True,
1643 )
1644 stat = os.stat(absolute_path, follow_symlinks=False)
1645 return FileSystemItem(
1646 filename=os.path.basename(file_path),
1647 relative_path=get_relative_path(self.base_path, file_path),
1648 absolute_path=absolute_path,
1649 is_dir=False,
1650 checksum=str(int(stat.st_mtime)),
1651 file_size=stat.st_size,
1652 )
1653
1654 # run in thread because strictly taken this may be blocking IO
1655 return await asyncio.to_thread(_create_item)
1656
1657 async def exists(self, file_path: str) -> bool:
1658 """Return bool is this FileSystem musicprovider has given file/dir."""
1659 if not file_path:
1660 return False # guard
1661 abs_path = self.get_absolute_path(file_path)
1662 return bool(await exists(abs_path))
1663
1664 def get_absolute_path(self, file_path: str) -> str:
1665 """Return absolute path for given file path."""
1666 return get_absolute_path(self.base_path, file_path)
1667
1668 async def _get_stream_details_for_track(self, item_id: str) -> StreamDetails:
1669 """Return the streamdetails for a track/song."""
1670 library_item = await self.mass.music.tracks.get_library_item_by_prov_id(
1671 item_id, self.instance_id
1672 )
1673 if library_item is None:
1674 # this could be a file that has just been added, try parsing it
1675 file_item = await self.resolve(item_id)
1676 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1677 if not (library_item := await self._parse_track(file_item, tags)):
1678 msg = f"Item not found: {item_id}"
1679 raise MediaNotFoundError(msg)
1680
1681 prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
1682 file_item = await self.resolve(item_id)
1683
1684 return StreamDetails(
1685 provider=self.instance_id,
1686 item_id=item_id,
1687 audio_format=prov_mapping.audio_format,
1688 media_type=MediaType.TRACK,
1689 stream_type=StreamType.LOCAL_FILE,
1690 duration=library_item.duration,
1691 size=file_item.file_size,
1692 data=file_item,
1693 path=file_item.absolute_path,
1694 can_seek=True,
1695 allow_seek=True,
1696 )
1697
1698 async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamDetails:
1699 """Return the streamdetails for a podcast episode."""
1700 # podcasts episodes are never stored in the library so we need to parse the file
1701 file_item = await self.resolve(item_id)
1702 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1703 return StreamDetails(
1704 provider=self.instance_id,
1705 item_id=item_id,
1706 audio_format=AudioFormat(
1707 content_type=ContentType.try_parse(file_item.ext or tags.format),
1708 sample_rate=tags.sample_rate,
1709 bit_depth=tags.bits_per_sample,
1710 channels=tags.channels,
1711 bit_rate=tags.bit_rate,
1712 ),
1713 media_type=MediaType.PODCAST_EPISODE,
1714 stream_type=StreamType.LOCAL_FILE,
1715 duration=try_parse_int(tags.duration or 0),
1716 size=file_item.file_size,
1717 data=file_item,
1718 path=file_item.absolute_path,
1719 allow_seek=True,
1720 can_seek=True,
1721 )
1722
1723 async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails:
1724 """Return the streamdetails for an audiobook."""
1725 library_item = await self.mass.music.audiobooks.get_library_item_by_prov_id(
1726 item_id, self.instance_id
1727 )
1728 if library_item is None:
1729 # this could be a file that has just been added, try parsing it
1730 file_item = await self.resolve(item_id)
1731 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1732 if not (library_item := await self._parse_audiobook(file_item, tags)):
1733 msg = f"Item not found: {item_id}"
1734 raise MediaNotFoundError(msg)
1735
1736 prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
1737 file_item = await self.resolve(item_id)
1738 duration = library_item.duration
1739 file_based_chapters: list[tuple[str, float]] | None = await self.cache.get(
1740 key=file_item.relative_path,
1741 provider=self.instance_id,
1742 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
1743 )
1744 if file_based_chapters is None:
1745 # no cache available for this audiobook, we need to parse the chapters
1746 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1747 await self._parse_audiobook(file_item, tags)
1748 file_based_chapters = await self.cache.get(
1749 key=file_item.relative_path,
1750 provider=self.instance_id,
1751 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
1752 )
1753
1754 if file_based_chapters:
1755 # this is a multi-file audiobook
1756 return StreamDetails(
1757 provider=self.instance_id,
1758 item_id=item_id,
1759 audio_format=prov_mapping.audio_format,
1760 media_type=MediaType.AUDIOBOOK,
1761 stream_type=StreamType.LOCAL_FILE,
1762 duration=duration,
1763 path=[
1764 MultiPartPath(path=self.get_absolute_path(path), duration=duration)
1765 for path, duration in file_based_chapters
1766 ],
1767 allow_seek=True,
1768 )
1769
1770 # regular single-file streaming, simply let ffmpeg deal with the file directly
1771 return StreamDetails(
1772 provider=self.instance_id,
1773 item_id=item_id,
1774 audio_format=prov_mapping.audio_format,
1775 media_type=MediaType.AUDIOBOOK,
1776 stream_type=StreamType.LOCAL_FILE,
1777 duration=library_item.duration,
1778 size=file_item.file_size,
1779 data=file_item,
1780 path=file_item.absolute_path,
1781 allow_seek=True,
1782 can_seek=True,
1783 )
1784
1785 async def _get_chapters_for_audiobook(
1786 self, audiobook_file_item: FileSystemItem, tags: AudioTags
1787 ) -> tuple[int, list[MediaItemChapter]]:
1788 """Return chapters for an audiobook.
1789
1790 Chapter sources in order of preference:
1791 1. Multiple files with track tags - sorted by track number
1792 2. Single file with embedded chapters - use embedded chapter markers
1793 3. Multiple files without track tags - sorted alphabetically (fallback)
1794 """
1795 chapters: list[MediaItemChapter] = []
1796 all_chapter_files: list[tuple[str, float]] = []
1797 total_duration = 0.0
1798
1799 # Scan folder for chapter files, separating tagged from untagged
1800 chapter_file_tags: list[AudioTags] = []
1801 untagged_file_tags: list[AudioTags] = []
1802 abs_path = self.get_absolute_path(audiobook_file_item.parent_path)
1803 for item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True):
1804 if "." not in item.relative_path or item.is_dir:
1805 continue
1806 if item.ext not in AUDIOBOOK_EXTENSIONS:
1807 continue
1808 item_tags = await async_parse_tags(item.absolute_path, item.file_size)
1809 if not (tags.album == item_tags.album or (item_tags.tags.get("title") is None)):
1810 continue
1811 if item_tags.tags.get("track") is None:
1812 untagged_file_tags.append(item_tags)
1813 else:
1814 chapter_file_tags.append(item_tags)
1815
1816 # Determine chapter source
1817 use_embedded = False
1818 use_alphabetical = False
1819
1820 if len(chapter_file_tags) > 1:
1821 chapter_file_tags.sort(key=lambda x: (x.disc or 0, x.track or 0))
1822 elif len(chapter_file_tags) <= 1 and tags.chapters:
1823 use_embedded = True
1824 elif len(untagged_file_tags) > 1:
1825 use_alphabetical = True
1826 chapter_file_tags = untagged_file_tags
1827 self.logger.info(
1828 "Audiobook files have no track tags, using alphabetical order: %s",
1829 tags.album,
1830 )
1831
1832 if use_embedded:
1833 chapters = [
1834 MediaItemChapter(
1835 position=chapter.chapter_id,
1836 name=chapter.title or f"Chapter {chapter.chapter_id}",
1837 start=chapter.position_start,
1838 end=chapter.position_end,
1839 )
1840 for chapter in tags.chapters
1841 ]
1842 total_duration = try_parse_int(tags.duration) or 0
1843 self.logger.log(
1844 VERBOSE_LOG_LEVEL,
1845 "Audiobook '%s': %d embedded chapters, duration=%d",
1846 tags.album,
1847 len(chapters),
1848 int(total_duration),
1849 )
1850 else:
1851 for position, chapter_tags in enumerate(chapter_file_tags, start=1):
1852 if chapter_tags.duration is None:
1853 self.logger.warning(
1854 "Chapter file has no duration, skipping: %s",
1855 chapter_tags.filename,
1856 )
1857 continue
1858 chapters.append(
1859 MediaItemChapter(
1860 position=position,
1861 name=chapter_tags.title,
1862 start=total_duration,
1863 end=total_duration + chapter_tags.duration,
1864 )
1865 )
1866 all_chapter_files.append(
1867 (
1868 get_relative_path(self.base_path, chapter_tags.filename),
1869 chapter_tags.duration,
1870 )
1871 )
1872 total_duration += chapter_tags.duration
1873 sort_method = "alphabetical" if use_alphabetical else "track"
1874 self.logger.log(
1875 VERBOSE_LOG_LEVEL,
1876 "Audiobook '%s': %d files (%s order), duration=%d",
1877 tags.album,
1878 len(chapters),
1879 sort_method,
1880 int(total_duration),
1881 )
1882
1883 # Cache chapter files for streaming
1884 await self.cache.set(
1885 key=audiobook_file_item.relative_path,
1886 data=all_chapter_files,
1887 provider=self.instance_id,
1888 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
1889 )
1890 return (int(total_duration), chapters)
1891
1892 async def _get_podcast_metadata(self, podcast_folder: str) -> dict[str, Any]:
1893 """Return metadata for a podcast."""
1894 if (
1895 cache := await self.cache.get(
1896 key=podcast_folder,
1897 provider=self.instance_id,
1898 category=CACHE_CATEGORY_PODCAST_METADATA,
1899 )
1900 ) is not None:
1901 return cast("dict[str, Any]", cache)
1902 data: dict[str, Any] = {}
1903 metadata_file = os.path.join(podcast_folder, "metadata.json")
1904 if await self.exists(metadata_file):
1905 # found json file with metadata
1906 metadata_file = self.get_absolute_path(metadata_file)
1907 async with aiofiles.open(metadata_file) as _file:
1908 data.update(json_loads(await _file.read()))
1909 await self.cache.set(
1910 key=podcast_folder,
1911 data=data,
1912 provider=self.instance_id,
1913 category=CACHE_CATEGORY_PODCAST_METADATA,
1914 )
1915 return data
1916