/
/
1"""Filesystem musicprovider support for MusicAssistant."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import logging
8import os
9import os.path
10import posixpath
11import urllib.parse
12from collections.abc import AsyncGenerator, Sequence
13from datetime import UTC, datetime
14from pathlib import Path
15from typing import TYPE_CHECKING, Any, ClassVar, cast
16from xml.parsers.expat import ExpatError
17
18import aiofiles
19import shortuuid
20import xmltodict
21from aiofiles.os import wrap
22from music_assistant_models.enums import (
23 ContentType,
24 EventType,
25 ExternalID,
26 ImageType,
27 MediaType,
28 ProviderFeature,
29 StreamType,
30)
31from music_assistant_models.errors import (
32 InvalidDataError,
33 MediaNotFoundError,
34 MusicAssistantError,
35 SetupFailedError,
36)
37from music_assistant_models.helpers import create_safe_string
38from music_assistant_models.media_items import (
39 Album,
40 Artist,
41 Audiobook,
42 AudioFormat,
43 BrowseFolder,
44 ItemMapping,
45 MediaItemChapter,
46 MediaItemImage,
47 MediaItemType,
48 Playlist,
49 Podcast,
50 PodcastEpisode,
51 ProviderMapping,
52 SearchResults,
53 SoundEffect,
54 Track,
55 UniqueList,
56 is_track,
57)
58from music_assistant_models.streamdetails import MultiPartPath, StreamDetails
59
60from music_assistant.constants import (
61 CONF_PATH,
62 DB_TABLE_ALBUM_ARTISTS,
63 DB_TABLE_ALBUM_TRACKS,
64 DB_TABLE_ALBUMS,
65 DB_TABLE_ARTISTS,
66 DB_TABLE_PROVIDER_MAPPINGS,
67 DB_TABLE_TRACK_ARTISTS,
68 VARIOUS_ARTISTS_MBID,
69 VARIOUS_ARTISTS_NAME,
70 VERBOSE_LOG_LEVEL,
71)
72from music_assistant.controllers.tasks.context import (
73 report_current_task_failure,
74 update_current_task_progress_from_index,
75 update_current_task_progress_text,
76)
77from music_assistant.helpers import lyrics
78from music_assistant.helpers.compare import compare_strings
79from music_assistant.helpers.json import SerializableType, json_loads
80from music_assistant.helpers.playlists import parse_m3u, parse_pls
81from music_assistant.helpers.tags import AudioTags, async_parse_tags, clean_mbid, split_items
82from music_assistant.helpers.uri import create_uri
83from music_assistant.helpers.util import (
84 TaskManager,
85 detect_charset,
86 parse_title_and_version,
87 try_parse_int,
88)
89from music_assistant.models.music_provider import MusicProvider
90
91from .constants import (
92 AUDIOBOOK_EXTENSIONS,
93 AVAILABILITY_PROBE_INTERVAL,
94 CACHE_CATEGORY_ALBUM_INFO,
95 CACHE_CATEGORY_ARTIST_INFO,
96 CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
97 CACHE_CATEGORY_FOLDER_IMAGES,
98 CACHE_CATEGORY_METADATA_FILE,
99 CACHE_CATEGORY_PODCAST_EPISODES,
100 CACHE_CATEGORY_PODCAST_METADATA,
101 CACHE_CATEGORY_SOUND_EFFECTS,
102 CONF_CONTENT_TYPE,
103 CONF_ENTRY_CONTENT_TYPE,
104 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
105 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
106 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
107 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
108 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
109 CONF_ENTRY_MISSING_ALBUM_ARTIST,
110 CONF_ENTRY_PROPAGATE_GENRES,
111 CUE_EXTENSIONS,
112 DEFAULT_AUDIOBOOK_PODCAST_GENRE,
113 IMAGE_EXTENSIONS,
114 METADATA_FILE_CACHE_EXPIRATION,
115 METADATA_FILE_EXTENSIONS,
116 PARTIAL_LISTING_CACHE_EXPIRATION,
117 PLAYLIST_EXTENSIONS,
118 PODCAST_EPISODE_EXTENSIONS,
119 SOUND_EFFECT_EXTENSIONS,
120 TRACK_EXTENSIONS,
121 WALK_EXTENSIONS,
122 IsChapterFile,
123 content_type_config_entry,
124)
125from .cue import (
126 CueSheetHandler,
127 cue_metadata_checksum,
128 cue_referenced_audio_stem,
129 make_cue_track_id,
130 parse_cue_track_id,
131)
132from .helpers import (
133 FileSystemItem,
134 ScanErrors,
135 get_absolute_path,
136 get_album_dir,
137 get_artist_dir,
138 get_folder_signature,
139 get_relative_path,
140 is_image_file,
141 is_metadata_file,
142 recursive_iter,
143 sorted_scandir,
144)
145from .parsers import parse_album_nfo
146
147if TYPE_CHECKING:
148 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
149 from music_assistant_models.provider import ProviderManifest
150
151 from music_assistant.mass import MusicAssistant
152 from music_assistant.models import ProviderInstanceType
153 from music_assistant.providers.musicbrainz import MusicbrainzProvider
154
155
156isdir = wrap(os.path.isdir)
157isfile = wrap(os.path.isfile)
158ismount = wrap(os.path.ismount)
159exists = wrap(os.path.exists)
160makedirs = wrap(os.makedirs)
161
162SUPPORTED_FEATURES = {
163 ProviderFeature.BROWSE,
164 ProviderFeature.SEARCH,
165}
166
167
168async def setup(
169 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
170) -> ProviderInstanceType:
171 """Initialize provider(instance) with given configuration."""
172 return LocalFileSystemProvider(mass, manifest, config)
173
174
175class LocalFileSystemProvider(MusicProvider):
176 """
177 Implementation of a musicprovider for (local) files.
178
179 Reads ID3 tags from file and falls back to parsing filename.
180 Optionally reads metadata from nfo files and images in folder structure <artist>/<album>.
181 Supports m3u files for playlists.
182 """
183
184 # parallel workers per sync; subclasses lower this for slower transports
185 _SYNC_CONCURRENCY: ClassVar[int] = 16
186 _sync_tracks: bool = True
187 _sync_playlists: bool = True
188
189 def __init__(
190 self,
191 mass: MusicAssistant,
192 manifest: ProviderManifest,
193 config: ProviderConfig,
194 base_path: str | None = None,
195 ) -> None:
196 """Initialize MusicProvider."""
197 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
198 # subclasses (NFS/SMB/...) mount elsewhere and pass their own base_path;
199 # the plain local provider reads its scan directory from the setup data
200 self.base_path: str = (
201 base_path if base_path is not None else cast("str", self.get_setup_value(CONF_PATH))
202 )
203 self.write_access: bool = False
204 self.sync_running: bool = False
205 self.media_content_type = cast(
206 "str", self.get_setup_value(CONF_CONTENT_TYPE, CONF_ENTRY_CONTENT_TYPE.default_value)
207 )
208 self._cue = CueSheetHandler(self)
209
210 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
211 """Return Config entries to configure this provider."""
212 # content type and path are collected by the setup flow; surface the (immutable)
213 # content type read-only so the sync options' depends_on chains still resolve
214 content_type = str(
215 self.get_setup_value(CONF_CONTENT_TYPE, CONF_ENTRY_CONTENT_TYPE.default_value)
216 )
217 return (
218 content_type_config_entry(content_type),
219 CONF_ENTRY_MISSING_ALBUM_ARTIST,
220 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
221 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
222 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
223 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
224 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
225 CONF_ENTRY_PROPAGATE_GENRES,
226 )
227
228 @property
229 def supported_features(self) -> set[ProviderFeature]:
230 """Return the features supported by this Provider."""
231 base_features = {*SUPPORTED_FEATURES}
232 if self.media_content_type == "audiobooks":
233 return {ProviderFeature.LIBRARY_AUDIOBOOKS, *base_features}
234 if self.media_content_type == "podcasts":
235 return {ProviderFeature.LIBRARY_PODCASTS, *base_features}
236 if self.media_content_type == "sound_effects":
237 # sound effects are live-fetched content, never synced into the library
238 return {ProviderFeature.SOUND_EFFECTS, *base_features}
239 music_features = {
240 ProviderFeature.LIBRARY_ALBUMS,
241 ProviderFeature.LIBRARY_ARTISTS,
242 ProviderFeature.LIBRARY_TRACKS,
243 ProviderFeature.LIBRARY_PLAYLISTS,
244 *base_features,
245 }
246 if self.write_access:
247 music_features.add(ProviderFeature.PLAYLIST_TRACKS_EDIT)
248 music_features.add(ProviderFeature.PLAYLIST_CREATE)
249 return music_features
250
251 @property
252 def is_streaming_provider(self) -> bool:
253 """Return True if the provider is a streaming provider."""
254 return False
255
256 @property
257 def instance_name_postfix(self) -> str | None:
258 """Return a (default) instance name postfix for this provider instance."""
259 return Path(self.base_path).name
260
261 async def handle_async_init(self) -> None:
262 """Handle async initialization of the provider."""
263 if not await isdir(self.base_path):
264 msg = f"Music Directory {self.base_path} does not exist"
265 raise SetupFailedError(
266 msg,
267 translation_key="music_directory_not_found",
268 translation_owner=self.translation_owner,
269 translation_args=[self.base_path],
270 )
271 await self.check_write_access()
272
273 async def unload(self, is_removed: bool = False) -> None:
274 """Handle unload/close of the provider."""
275 self._cancel_availability_probe()
276 # a check that already started runs as a task under the same id, and it would
277 # otherwise keep talking to storage this unload is in the middle of tearing down
278 self.mass.cancel_task(self._availability_probe_id)
279
280 async def get_diagnostics(self) -> dict[str, SerializableType]:
281 """Return diagnostics info for this provider to include in diagnostics reports."""
282 return {
283 "sync_running": self.sync_running,
284 "write_access": self.write_access,
285 "content_type": self.media_content_type,
286 }
287
288 async def search(
289 self,
290 search_query: str,
291 media_types: list[MediaType] | None,
292 limit: int = 5,
293 ) -> SearchResults:
294 """Perform search on this file based musicprovider."""
295 result = SearchResults()
296 # searching the filesystem is slow and unreliable,
297 # so instead we just query the db...
298 if media_types is None or MediaType.TRACK in media_types:
299 result.tracks = await self.mass.music.tracks.get_library_items_by_query(
300 search=search_query, provider_filter=[self.instance_id], limit=limit
301 )
302
303 if media_types is None or MediaType.ALBUM in media_types:
304 result.albums = await self.mass.music.albums.get_library_items_by_query(
305 search=search_query,
306 provider_filter=[self.instance_id],
307 limit=limit,
308 )
309
310 if media_types is None or MediaType.ARTIST in media_types:
311 result.artists = await self.mass.music.artists.get_library_items_by_query(
312 search=search_query,
313 provider_filter=[self.instance_id],
314 limit=limit,
315 )
316 if media_types is None or MediaType.PLAYLIST in media_types:
317 result.playlists = await self.mass.music.playlists.get_library_items_by_query(
318 search=search_query,
319 provider_filter=[self.instance_id],
320 limit=limit,
321 )
322 if media_types is None or MediaType.AUDIOBOOK in media_types:
323 result.audiobooks = await self.mass.music.audiobooks.get_library_items_by_query(
324 search=search_query,
325 provider_filter=[self.instance_id],
326 limit=limit,
327 )
328 if media_types is None or MediaType.PODCAST in media_types:
329 result.podcasts = await self.mass.music.podcasts.get_library_items_by_query(
330 search=search_query,
331 provider_filter=[self.instance_id],
332 limit=limit,
333 )
334 return result
335
336 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
337 """
338 Browse this provider's items.
339
340 :param path: The path to browse, (e.g. provid://artists).
341 """
342 # for audiobooks and podcasts we just return all library items
343 if self.media_content_type == "podcasts":
344 return await self.mass.music.podcasts.library_items(
345 provider=self.instance_id, summary=False
346 )
347 if self.media_content_type == "audiobooks":
348 return await self.mass.music.audiobooks.library_items(
349 provider=self.instance_id, summary=False
350 )
351 items: list[MediaItemType | ItemMapping | BrowseFolder] = []
352 item_path = path.split("://", 1)[1]
353 if not item_path:
354 item_path = ""
355 scanned = await self._scandir(item_path)
356 # expand CUE sheets into per-track entries and hide the companion audio;
357 # synthetic ids match those minted during sync so get_track resolves them
358 cue_stems: set[str] = set()
359 if self.media_content_type == "music":
360 for item in scanned:
361 if item.ext not in CUE_EXTENSIONS:
362 continue
363 cue_stems.add(item.absolute_path.rsplit(".", 1)[0])
364 try:
365 cue_sheet = await self._cue.load_cue_sheet(item)
366 except InvalidDataError as err:
367 self.logger.warning("Unable to parse CUE sheet %s: %s", item.relative_path, err)
368 continue
369 # also hide the audio file named in the CUE (may differ from its stem)
370 if companion_stem := cue_referenced_audio_stem(item, cue_sheet):
371 cue_stems.add(companion_stem)
372 for cue_track in cue_sheet.tracks:
373 items.append(
374 ItemMapping(
375 media_type=MediaType.TRACK,
376 item_id=make_cue_track_id(item.relative_path, cue_track.number),
377 provider=self.instance_id,
378 name=cue_track.title or f"Track {cue_track.number}",
379 )
380 )
381 for item in scanned:
382 if not item.is_dir and ("." not in item.filename or not item.ext):
383 # skip system files and files without extension
384 continue
385
386 if item.is_dir:
387 items.append(
388 BrowseFolder(
389 item_id=item.relative_path,
390 provider=self.instance_id,
391 path=f"{self.instance_id}://{item.relative_path}",
392 name=item.filename,
393 # mark folder as playable, assuming it contains tracks underneath
394 is_playable=True,
395 )
396 )
397 elif item.ext in TRACK_EXTENSIONS:
398 if item.absolute_path.rsplit(".", 1)[0] in cue_stems:
399 continue
400 items.append(
401 ItemMapping(
402 media_type=(
403 MediaType.SOUND_EFFECT
404 if self.media_content_type == "sound_effects"
405 else MediaType.TRACK
406 ),
407 item_id=item.relative_path,
408 provider=self.instance_id,
409 name=item.filename,
410 )
411 )
412 elif item.ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
413 items.append(
414 ItemMapping(
415 media_type=MediaType.PLAYLIST,
416 item_id=item.relative_path,
417 provider=self.instance_id,
418 name=item.filename,
419 )
420 )
421 if self.media_content_type == "music":
422 track_indexes = [
423 index
424 for index, item in enumerate(items)
425 if isinstance(item, ItemMapping) and item.media_type == MediaType.TRACK
426 ]
427 library_tracks = await asyncio.gather(
428 *(
429 self.mass.music.tracks.get_library_item_by_prov_id(
430 items[index].item_id, self.instance_id
431 )
432 for index in track_indexes
433 )
434 )
435 for index, library_track in zip(track_indexes, library_tracks, strict=True):
436 if library_track:
437 items[index] = library_track
438 return items
439
440 async def sync_library(self, media_type: MediaType) -> None:
441 """Run library sync for this provider."""
442 if media_type in (MediaType.ARTIST, MediaType.ALBUM):
443 # artists and albums are synced as part of track sync
444 return
445 if self.media_content_type == "sound_effects":
446 # sound effects are live-fetched content, never synced into the library
447 return
448 # check if any sync options are enabled for this content type
449 # the filesystem provider processes all file types in one scan,
450 # so we can return early if nothing needs syncing
451 if self.media_content_type == "music":
452 self._sync_tracks = bool(self.config.get_value(CONF_ENTRY_LIBRARY_SYNC_TRACKS.key))
453 self._sync_playlists = bool(
454 self.config.get_value(CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS.key)
455 )
456 if not self._sync_tracks and not self._sync_playlists:
457 return
458 elif self.media_content_type == "audiobooks":
459 if not self.config.get_value(CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS.key):
460 return
461 elif self.media_content_type == "podcasts":
462 if not self.config.get_value(CONF_ENTRY_LIBRARY_SYNC_PODCASTS.key):
463 return
464 assert self.mass.music.database
465 if self.sync_running:
466 self.logger.warning("Library sync already running for %s", self.name)
467 return
468 file_checksums: dict[str, str] = {}
469 # NOTE: we always run a scan of the entire library, as we need to detect changes
470 # we ignore any given mediatype(s) and just scan all supported files
471 query = (
472 f"SELECT provider_item_id, details FROM {DB_TABLE_PROVIDER_MAPPINGS} "
473 f"WHERE provider_instance = '{self.instance_id}' "
474 f"AND media_type in ('track', 'playlist', 'audiobook', 'podcast_episode')"
475 )
476 for db_row in await self.mass.music.database.get_rows_from_query(query, limit=0):
477 file_checksums[db_row["provider_item_id"]] = str(db_row["details"])
478 # provider_mappings stores synthetic per-track ids for CUE sheets, not the
479 # CUE path, so collect every track checksum per path for the scan classifier
480 cue_file_checksums: dict[str, set[str]] = {}
481 for prov_item_id, checksum in file_checksums.items():
482 parsed = parse_cue_track_id(prov_item_id)
483 if parsed is not None:
484 cue_file_checksums.setdefault(parsed[0], set()).add(checksum)
485 # find all supported files in the base directory and all subfolders
486 # we work bottom up, as-in we derive all info from the tracks
487 cur_filenames: set[str] = set()
488 prev_filenames = set(file_checksums.keys())
489
490 items_to_process: list[tuple[FileSystemItem, str | None]] = []
491 unchanged_cue_items: list[FileSystemItem] = []
492 # absolute paths of every CUE sheet in this scan with the ".cue" stripped,
493 # used for O(1) companion-CUE lookups per audio file
494 cue_stems: set[str] = set()
495 # local metadata files (NFO/images) collected by the walk alongside normal media;
496 # never imported themselves, only used to detect a change worth reparsing their
497 # registered representative track
498 metadata_files: list[FileSystemItem] = []
499 # relative_path of every representative queued because of a metadata-file change
500 force_refresh_tracks: set[str] = set()
501 # collects the errors raised while walking the tree; any error means the
502 # scan is incomplete, a fatal one means the provider is unreachable
503 scan_errors = ScanErrors()
504
505 self.sync_running = True
506 try:
507 await self._enumerate_files_for_sync(
508 file_checksums=file_checksums,
509 cue_file_checksums=cue_file_checksums,
510 cur_filenames=cur_filenames,
511 items_to_process=items_to_process,
512 unchanged_cue_items=unchanged_cue_items,
513 cue_stems=cue_stems,
514 scan_errors=scan_errors,
515 metadata_files=metadata_files,
516 )
517 if scan_errors.fatal:
518 # the storage is gone, so reading the files collected before it went
519 # away would only add a timeout each
520 self.logger.error("Aborting sync for %s: %s", self.name, scan_errors.fatal)
521 report_current_task_failure("Sync aborted: filesystem unavailable during scan")
522 self._set_available(False)
523 return
524 if metadata_files:
525 await self._queue_changed_metadata_files(
526 metadata_files,
527 file_checksums,
528 cue_file_checksums,
529 items_to_process,
530 force_refresh_tracks,
531 )
532 if force_refresh_tracks:
533 await self._drop_stale_album_artist_caches()
534 # a CUE may name an audio file other than its own; hide that companion too
535 if self.media_content_type == "music":
536 for cue_item in (
537 *unchanged_cue_items,
538 *(item for item, _ in items_to_process if item.ext in CUE_EXTENSIONS),
539 ):
540 try:
541 cue_sheet = await self._cue.load_cue_sheet(cue_item)
542 except InvalidDataError:
543 continue
544 if companion_stem := cue_referenced_audio_stem(cue_item, cue_sheet):
545 cue_stems.add(companion_stem)
546 # drop CUE companion audio: absorbed into CUE tracks and not tracked in
547 # provider_mappings, so they would otherwise flag as changed every sync
548 items_to_process = [
549 (item, prev)
550 for item, prev in items_to_process
551 if not (
552 item.ext in TRACK_EXTENSIONS
553 and item.absolute_path.rsplit(".", 1)[0] in cue_stems
554 )
555 ]
556 # register synthetic track IDs for unchanged CUE files so the
557 # deletion pass does not treat them as removed
558 for cue_item in unchanged_cue_items:
559 try:
560 cue_sheet = await self._cue.load_cue_sheet(cue_item)
561 except InvalidDataError as err:
562 self.logger.warning(
563 "Unable to parse CUE sheet %s: %s", cue_item.relative_path, err
564 )
565 continue
566 for cue_track in cue_sheet.tracks:
567 cur_filenames.add(make_cue_track_id(cue_item.relative_path, cue_track.number))
568 total_items = len(items_to_process)
569 self.logger.info(
570 "Found %d changed/new items to process for %s",
571 total_items,
572 self.name,
573 )
574
575 # _SYNC_CONCURRENCY caps parallelism per provider (NFS/SMB/WebDAV friendly)
576 processed_count = 0
577
578 async def _process(item: FileSystemItem, prev_checksum: str | None) -> None:
579 nonlocal processed_count
580 if await self._process_item_async(
581 item, prev_checksum, cur_filenames, cue_stems, prev_filenames
582 ):
583 cur_filenames.add(item.relative_path)
584 processed_count += 1
585 if processed_count % 50 == 0 or processed_count == total_items:
586 update_current_task_progress_from_index(
587 processed_count,
588 total_items,
589 f"Processed {processed_count}/{total_items} files",
590 )
591
592 async with TaskManager(self.mass, self._SYNC_CONCURRENCY) as tm:
593 for item, prev_checksum in items_to_process:
594 await tm.create_task_with_limit(_process(item, prev_checksum))
595 finally:
596 self.sync_running = False
597
598 # do not run deletions on a clean but empty scan of a previously non-empty library
599 # (wrong share mounted, empty backup mount, ...)
600 if prev_filenames and not cur_filenames:
601 self.logger.error(
602 "Aborting sync for %s: scan found no files but %d were previously indexed",
603 self.name,
604 len(prev_filenames),
605 )
606 report_current_task_failure(
607 f"Sync aborted: scan found no files but {len(prev_filenames)} "
608 "were previously indexed"
609 )
610 return
611
612 # a scan that skipped folders or files is incomplete: what it missed is still
613 # there, so deleting it from the library would throw away valid content
614 if scan_errors.incomplete:
615 summary = scan_errors.describe()
616 self.logger.warning("Skipping deletions for %s: %s", self.name, summary)
617 report_current_task_failure(f"Deletions skipped: {summary}")
618 else:
619 deleted_files = prev_filenames - cur_filenames
620 await self._process_deletions(deleted_files)
621 await self._process_orphaned_albums_and_artists()
622
623 # flag provider as available again if an earlier sync had marked it down
624 self._set_available(True)
625
626 async def get_artist(self, prov_artist_id: str) -> Artist:
627 """Get full artist details by id."""
628 db_artist = await self.mass.music.artists.get_library_item_by_prov_id(
629 prov_artist_id, self.instance_id
630 )
631 if not db_artist:
632 # this may happen if the artist is not in the db yet
633 # e.g. when browsing the filesystem
634 if await self.exists(prov_artist_id):
635 return await self._parse_artist(prov_artist_id, artist_path=prov_artist_id)
636 return await self._parse_artist(prov_artist_id)
637
638 # prov_artist_id is either an actual (relative) path or a name (as fallback)
639 safe_artist_name = create_safe_string(prov_artist_id, lowercase=False, replace_space=False)
640 if await self.exists(prov_artist_id):
641 artist_path = prov_artist_id
642 elif await self.exists(safe_artist_name):
643 artist_path = safe_artist_name
644 else:
645 for prov_mapping in db_artist.provider_mappings:
646 if prov_mapping.provider_instance != self.instance_id:
647 continue
648 if prov_mapping.url:
649 artist_path = prov_mapping.url
650 break
651 else:
652 # this is an artist without an actual path on disk
653 # return the info we already have in the db
654 return db_artist
655 return await self._parse_artist(
656 db_artist.name,
657 sort_name=db_artist.sort_name,
658 mbid=db_artist.mbid,
659 artist_path=artist_path,
660 representative_track=await self._resolve_artist_representative_track(db_artist),
661 )
662
663 async def get_album(self, prov_album_id: str) -> Album:
664 """Get full album details by id."""
665 parsed_cue_paths: set[str] = set()
666 for track in await self.get_album_tracks(prov_album_id):
667 for prov_mapping in track.provider_mappings:
668 if prov_mapping.provider_instance != self.instance_id:
669 continue
670 if parsed := parse_cue_track_id(prov_mapping.item_id):
671 # every track from the same CUE shares the same album; only parse once
672 if parsed[0] in parsed_cue_paths:
673 continue
674 parsed_cue_paths.add(parsed[0])
675 cue_item = await self.resolve(parsed[0])
676 for cue_track in await self._cue.parse_tracks(cue_item):
677 if isinstance(cue_track.album, Album):
678 return cue_track.album
679 continue
680 file_item = await self.resolve(prov_mapping.item_id)
681 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
682 full_track = await self._parse_track(file_item, tags)
683 assert isinstance(full_track.album, Album)
684 return full_track.album
685 msg = f"Album not found: {prov_album_id}"
686 raise MediaNotFoundError(msg)
687
688 async def get_track(self, prov_track_id: str) -> Track:
689 """Get full track details by id."""
690 # ruff: noqa: PLR0915
691 if parsed := parse_cue_track_id(prov_track_id):
692 cue_item = await self.resolve(parsed[0])
693 for cue_track in await self._cue.parse_tracks(cue_item):
694 if cue_track.item_id == prov_track_id:
695 return cue_track
696 msg = f"CUE track not found: {prov_track_id}"
697 raise MediaNotFoundError(msg)
698
699 if not await self.exists(prov_track_id):
700 msg = f"Track path does not exist: {prov_track_id}"
701 raise MediaNotFoundError(msg)
702
703 file_item = await self.resolve(prov_track_id)
704 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
705 return await self._parse_track(file_item, tags=tags, full_album_metadata=True)
706
707 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
708 """Get (full) podcast episode details by id."""
709 if not await self.exists(prov_episode_id):
710 msg = f"Episode path does not exist: {prov_episode_id}"
711 raise MediaNotFoundError(msg)
712 file_item = await self.resolve(prov_episode_id)
713 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
714 return await self._parse_podcast_episode(file_item, tags=tags)
715
716 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
717 """Get full playlist details by id."""
718 if not await self.exists(prov_playlist_id):
719 msg = f"Playlist path does not exist: {prov_playlist_id}"
720 raise MediaNotFoundError(msg)
721
722 file_item = await self.resolve(prov_playlist_id)
723 playlist = Playlist(
724 item_id=file_item.relative_path,
725 provider=self.instance_id,
726 name=file_item.name,
727 provider_mappings={
728 ProviderMapping(
729 item_id=file_item.relative_path,
730 provider_domain=self.domain,
731 provider_instance=self.instance_id,
732 details=file_item.checksum,
733 in_library=True,
734 )
735 },
736 )
737 playlist.is_editable = ProviderFeature.PLAYLIST_TRACKS_EDIT in self.supported_features
738 # only playlists in the root are editable - all other are read only
739 if "/" in prov_playlist_id or "\\" in prov_playlist_id:
740 playlist.is_editable = False
741 # we do not (yet) have support to edit/create pls playlists, only m3u files can be edited
742 if file_item.ext == "pls":
743 playlist.is_editable = False
744 playlist.owner = self.name
745 # Check for local image with the same basename
746 if local_image := await self._get_playlist_local_image(file_item):
747 playlist.metadata.images = UniqueList([local_image])
748 return playlist
749
750 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
751 """Get full audiobook details by id."""
752 # ruff: noqa: PLR0915
753 if not await self.exists(prov_audiobook_id):
754 msg = f"Audiobook path does not exist: {prov_audiobook_id}"
755 raise MediaNotFoundError(msg)
756
757 file_item = await self.resolve(prov_audiobook_id)
758 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
759 return await self._parse_audiobook(file_item, tags=tags)
760
761 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
762 """Get full podcast details by id."""
763 async for episode in self.get_podcast_episodes(prov_podcast_id):
764 assert isinstance(episode.podcast, Podcast)
765 return episode.podcast
766 msg = f"Podcast not found: {prov_podcast_id}"
767 raise MediaNotFoundError(msg)
768
769 async def get_sound_effect(self, prov_sound_effect_id: str) -> SoundEffect:
770 """Get full sound effect details by id."""
771 if not await self.exists(prov_sound_effect_id):
772 msg = f"Sound effect path does not exist: {prov_sound_effect_id}"
773 raise MediaNotFoundError(msg)
774 file_item = await self.resolve(prov_sound_effect_id)
775 return await self._get_or_parse_sound_effect(file_item)
776
777 async def get_sound_effects(self) -> AsyncGenerator[SoundEffect]:
778 """Get all sound effect items this provider offers."""
779
780 def _walk() -> list[FileSystemItem]:
781 return sorted(
782 recursive_iter(
783 self.base_path, self.base_path, SOUND_EFFECT_EXTENSIONS, self.logger
784 ),
785 key=lambda x: x.relative_path,
786 )
787
788 for file_item in await asyncio.to_thread(_walk):
789 yield await self._get_or_parse_sound_effect(file_item)
790
791 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
792 """Get album tracks for given album id."""
793 # filesystem items are always stored in db so we can query the database
794 db_album = await self.mass.music.albums.get_library_item_by_prov_id(
795 prov_album_id, self.instance_id
796 )
797 if db_album is None:
798 msg = f"Album not found: {prov_album_id}"
799 raise MediaNotFoundError(msg)
800 album_tracks = await self.mass.music.albums.get_library_album_tracks(db_album.item_id)
801 return [
802 track
803 for track in album_tracks
804 if any(x.provider_instance == self.instance_id for x in track.provider_mappings)
805 ]
806
807 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
808 """Get playlist tracks."""
809 result: list[Track] = []
810 if page > 0:
811 # paging not (yet) supported
812 return result
813 if not await self.exists(prov_playlist_id):
814 msg = f"Playlist path does not exist: {prov_playlist_id}"
815 raise MediaNotFoundError(msg)
816
817 file_item = await self.resolve(prov_playlist_id)
818 # We are using the checksum of the playlist file here to invalidate the cache
819 # when a change has been made to the playlist file (ie track addition/deletion)
820 cache_checksum = file_item.checksum
821
822 cache_key = f"get_playlist_tracks.{prov_playlist_id}"
823 cached_data = await self.mass.cache.get(
824 cache_key,
825 provider=self.instance_id,
826 checksum=cache_checksum,
827 category=0,
828 base_class=Track,
829 )
830 if cached_data is not None:
831 return cached_data # type: ignore[no-any-return]
832
833 _, ext = prov_playlist_id.rsplit(".", 1)
834 try:
835 # get playlist file contents
836 playlist_data_raw = await self._read_file(prov_playlist_id)
837 encoding = await detect_charset(playlist_data_raw)
838 playlist_data = playlist_data_raw.decode(encoding, errors="replace")
839
840 if ext in ("m3u", "m3u8"):
841 playlist_lines = parse_m3u(playlist_data)
842 else:
843 playlist_lines = parse_pls(playlist_data)
844
845 for idx, playlist_line in enumerate(playlist_lines, 1):
846 if "#EXT" in playlist_line.path:
847 continue
848 if track := await self._parse_playlist_line(
849 playlist_line.path, os.path.dirname(prov_playlist_id)
850 ):
851 track.position = idx
852 result.append(track)
853
854 except Exception as err:
855 self.logger.warning(
856 "Error while parsing playlist %s: %s",
857 prov_playlist_id,
858 str(err),
859 exc_info=err if self.logger.isEnabledFor(10) else None,
860 )
861
862 await self.mass.cache.set(
863 key=cache_key,
864 data=[track.to_dict() for track in result],
865 expiration=3600 * 24 * 365, # File timestamp checksum handles invalidation
866 provider=self.instance_id,
867 checksum=cache_checksum,
868 category=0,
869 )
870
871 return result
872
873 async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
874 """Get podcast episodes for given podcast id."""
875 folder_items = [item for item in await self._scandir(prov_podcast_id) if not item.is_dir]
876 episode_files = [x for x in folder_items if x.ext in PODCAST_EPISODE_EXTENSIONS]
877 # artwork and metadata.json count towards the signature too, because the parse embeds
878 # them into every episode. Case-insensitive, matching _get_podcast_metadata's exists()
879 signature_files = [
880 x
881 for x in folder_items
882 if x.ext in PODCAST_EPISODE_EXTENSIONS
883 or x.ext in IMAGE_EXTENSIONS
884 or x.filename.lower() == "metadata.json"
885 ]
886 cache_key = f"podcast_episodes.{prov_podcast_id}"
887 cache_checksum = get_folder_signature(signature_files)
888 if (
889 cached_episodes := await self.mass.cache.get(
890 cache_key,
891 provider=self.instance_id,
892 category=CACHE_CATEGORY_PODCAST_EPISODES,
893 checksum=cache_checksum,
894 base_class=PodcastEpisode,
895 )
896 ) is not None:
897 for episode in cached_episodes:
898 yield episode
899 return
900
901 # these caches have no checksum of their own, so drop them before parsing or the new
902 # entry gets the values the signature just invalidated. Refill once, or every parse
903 # task below misses at the same time and repeats the same scandir and file read
904 for stale_category in (CACHE_CATEGORY_FOLDER_IMAGES, CACHE_CATEGORY_PODCAST_METADATA):
905 await self.mass.cache.delete(
906 prov_podcast_id, category=stale_category, provider=self.instance_id
907 )
908 await self._get_local_images(prov_podcast_id)
909 await self._get_podcast_metadata(prov_podcast_id)
910
911 # collected by index so the listing keeps scandir order, not parse completion order
912 parsed: list[PodcastEpisode | None] = [None] * len(episode_files)
913
914 async def _process_podcast_episode(index: int, item: FileSystemItem) -> None:
915 try:
916 tags = await async_parse_tags(item.absolute_path, item.file_size)
917 parsed[index] = await self._parse_podcast_episode(item, tags)
918 except MusicAssistantError as err:
919 self.logger.warning(
920 "Could not parse uri/file %s to podcast episode: %s",
921 item.relative_path,
922 str(err),
923 )
924
925 # reuse the per-sync worker limit: the slowest filesystems to parse are exactly the
926 # ones that lower it
927 async with TaskManager(self.mass, self._SYNC_CONCURRENCY) as tm:
928 for index, item in enumerate(episode_files):
929 await tm.create_task_with_limit(_process_podcast_episode(index, item))
930
931 episodes = [episode for episode in parsed if episode is not None]
932 # cache an incomplete listing briefly rather than not at all, so one unreadable file
933 # cannot make every request re-parse the whole folder
934 complete = len(episodes) == len(episode_files)
935 await self.mass.cache.set(
936 key=cache_key,
937 data=[episode.to_dict() for episode in episodes],
938 # a complete listing is invalidated by the folder signature instead
939 expiration=3600 * 24 * 365 if complete else PARTIAL_LISTING_CACHE_EXPIRATION,
940 provider=self.instance_id,
941 category=CACHE_CATEGORY_PODCAST_EPISODES,
942 checksum=cache_checksum,
943 )
944
945 for episode in episodes:
946 yield episode
947
948 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
949 """Add track(s) to playlist."""
950 if not await self.exists(prov_playlist_id):
951 msg = f"Playlist path does not exist: {prov_playlist_id}"
952 raise MediaNotFoundError(msg)
953 playlist_filename = self.get_absolute_path(prov_playlist_id)
954 async with aiofiles.open(playlist_filename, encoding="utf-8") as _file:
955 playlist_data = await _file.read()
956 for file_path in prov_track_ids:
957 track = await self.get_track(file_path)
958 playlist_data += f"\n#EXTINF:{track.duration or 0},{track.name}\n{file_path}\n"
959
960 # write playlist file (always in utf-8)
961 async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
962 await _file.write(playlist_data)
963
964 async def remove_playlist_tracks(
965 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
966 ) -> None:
967 """Remove track(s) from playlist."""
968 if not await self.exists(prov_playlist_id):
969 msg = f"Playlist path does not exist: {prov_playlist_id}"
970 raise MediaNotFoundError(msg)
971 _, ext = prov_playlist_id.rsplit(".", 1)
972 # get playlist file contents
973 playlist_filename = self.get_absolute_path(prov_playlist_id)
974 async with aiofiles.open(playlist_filename, encoding="utf-8") as _file:
975 playlist_data = await _file.read()
976 # get current contents first
977 if ext in ("m3u", "m3u8"):
978 playlist_items = parse_m3u(playlist_data)
979 else:
980 playlist_items = parse_pls(playlist_data)
981 # remove items by index
982 for i in sorted(positions_to_remove, reverse=True):
983 # position = index + 1
984 del playlist_items[i - 1]
985 # build new playlist data
986 new_playlist_data = "#EXTM3U\n"
987 for item in playlist_items:
988 new_playlist_data += f"\n#EXTINF:{item.length or 0},{item.title}\n{item.path}\n"
989 async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
990 await _file.write(new_playlist_data)
991
992 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
993 """Create a new playlist on provider with given name."""
994 # creating a new playlist on the filesystem is as easy
995 # as creating a new (empty) file with the m3u extension...
996 # filename = await self.resolve(f"{name}.m3u")
997 filename = f"{name}.m3u"
998 playlist_filename = self.get_absolute_path(filename)
999 async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
1000 await _file.write("#EXTM3U\n")
1001 return await self.get_playlist(filename)
1002
1003 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
1004 """Return the content details for the given track when it will be streamed."""
1005 try:
1006 if media_type == MediaType.AUDIOBOOK:
1007 return await self._get_stream_details_for_audiobook(item_id)
1008 if media_type == MediaType.PODCAST_EPISODE:
1009 return await self._get_stream_details_for_podcast_episode(item_id)
1010 if media_type == MediaType.SOUND_EFFECT:
1011 return await self._get_stream_details_for_sound_effect(item_id)
1012 return await self._get_stream_details_for_track(item_id)
1013 except FileNotFoundError:
1014 self.logger.warning(
1015 "File not found for media item %s",
1016 item_id,
1017 )
1018 msg = f"Media file not found: {item_id}"
1019 raise MediaNotFoundError(msg)
1020
1021 async def get_audio_stream(
1022 self, streamdetails: StreamDetails, seek_position: int = 0
1023 ) -> AsyncGenerator[bytes]:
1024 """Return the custom audio stream for the provider item."""
1025 # only CUE-derived tracks use StreamType.CUSTOM in this provider
1026 async for chunk in self._cue.get_audio_stream(streamdetails, seek_position):
1027 yield chunk
1028
1029 async def resolve_image(self, path: str) -> str | bytes:
1030 """
1031 Resolve an image from an image path.
1032
1033 This either returns (a generator to get) raw bytes of the image or
1034 a string with an http(s) URL or local path that is accessible from the server.
1035 """
1036 # drop the cache-busting suffix appended by _versioned_image_path
1037 try:
1038 file_item = await self.resolve(path.split("?cs=", 1)[0])
1039 except FileNotFoundError as err:
1040 # the referenced image file was removed from disk; surface a typed
1041 # not-found so the image layer treats it as a missing image
1042 raise MediaNotFoundError(f"Image not found: {path}") from err
1043 if file_item.is_dir:
1044 # handing the path back would have the image layer run an ffmpeg
1045 # embedded-artwork extraction on the directory before giving up
1046 raise MediaNotFoundError(f"Image path is a directory: {path}")
1047 return file_item.absolute_path
1048
1049 async def check_write_access(self) -> None:
1050 """Perform check if we have write access."""
1051 # verify write access to determine we have playlist create/edit support
1052 # overwrite with provider specific implementation if needed
1053 temp_file_name = self.get_absolute_path(f"{shortuuid.random(8)}.txt")
1054 try:
1055 async with aiofiles.open(temp_file_name, "w") as _file:
1056 await _file.write("test")
1057 await asyncio.to_thread(os.remove, temp_file_name)
1058 self.write_access = True
1059 except Exception as err:
1060 self.logger.debug("Write access disabled: %s", str(err))
1061
1062 async def resolve(self, file_path: str) -> FileSystemItem:
1063 """Resolve (absolute or relative) path to FileSystemItem."""
1064 absolute_path = self.get_absolute_path(file_path)
1065
1066 def _create_item() -> FileSystemItem:
1067 if Path(absolute_path).is_dir():
1068 return FileSystemItem(
1069 filename=Path(file_path).name,
1070 relative_path=get_relative_path(self.base_path, file_path),
1071 absolute_path=absolute_path,
1072 is_dir=True,
1073 )
1074 stat_info = Path(absolute_path).stat(follow_symlinks=False)
1075 return FileSystemItem(
1076 filename=Path(file_path).name,
1077 relative_path=get_relative_path(self.base_path, file_path),
1078 absolute_path=absolute_path,
1079 is_dir=False,
1080 checksum=str(int(stat_info.st_mtime)),
1081 file_size=stat_info.st_size,
1082 metadata_token=str(stat_info.st_mtime_ns),
1083 )
1084
1085 return await asyncio.to_thread(_create_item)
1086
1087 async def exists(self, file_path: str) -> bool:
1088 """Return bool is this FileSystem musicprovider has given file/dir."""
1089 if not file_path:
1090 return False
1091 try:
1092 abs_path = self.get_absolute_path(file_path)
1093 except MediaNotFoundError:
1094 # a path that escapes the base directory simply does not exist here
1095 return False
1096 return bool(await exists(abs_path))
1097
1098 def get_absolute_path(self, file_path: str) -> str:
1099 """Return absolute path for given file path."""
1100 return get_absolute_path(self.base_path, file_path)
1101
1102 async def _enumerate_files_for_sync(
1103 self,
1104 *,
1105 file_checksums: dict[str, str],
1106 cue_file_checksums: dict[str, set[str]],
1107 cur_filenames: set[str],
1108 items_to_process: list[tuple[FileSystemItem, str | None]],
1109 unchanged_cue_items: list[FileSystemItem],
1110 cue_stems: set[str],
1111 scan_errors: ScanErrors,
1112 metadata_files: list[FileSystemItem],
1113 ) -> None:
1114 """
1115 Walk every supported file under the provider root and populate the sync buckets.
1116
1117 Override in subclasses that cannot use a local ``os.scandir`` walk.
1118 Implementations must route each discovered file through
1119 :meth:`_classify_scan_item`, report every unreadable directory to
1120 ``scan_errors`` and stop the walk once it reports ``aborted``.
1121
1122 :param file_checksums: Previously stored checksum per provider item id.
1123 :param cue_file_checksums: Previously stored track checksums keyed by CUE relative_path.
1124 :param cur_filenames: Receives the ids/paths present in this scan.
1125 :param items_to_process: Receives changed or new items to process.
1126 :param unchanged_cue_items: Receives CUE sheets whose checksum matches.
1127 :param cue_stems: Receives absolute paths (minus extension) of CUE sheets.
1128 :param scan_errors: Receives the errors raised while walking the tree.
1129 :param metadata_files: Receives local metadata files (NFO/images) found in the walk.
1130 """
1131 ignore_album_playlists = self.media_content_type == "music" and bool(
1132 self.config.get_value(CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS.key)
1133 )
1134
1135 def _walk() -> None:
1136 for scanned, item in enumerate(
1137 recursive_iter(
1138 self.base_path,
1139 self.base_path,
1140 WALK_EXTENSIONS,
1141 self.logger,
1142 scan_errors=scan_errors,
1143 ),
1144 start=1,
1145 ):
1146 if scanned % 500 == 0:
1147 update_current_task_progress_text(f"Scanning files: {scanned} found")
1148 self._classify_scan_item(
1149 item,
1150 file_checksums=file_checksums,
1151 cue_file_checksums=cue_file_checksums,
1152 cur_filenames=cur_filenames,
1153 items_to_process=items_to_process,
1154 unchanged_cue_items=unchanged_cue_items,
1155 cue_stems=cue_stems,
1156 ignore_album_playlists=ignore_album_playlists,
1157 metadata_files=metadata_files,
1158 )
1159
1160 await asyncio.to_thread(_walk)
1161
1162 def _classify_scan_item(
1163 self,
1164 item: FileSystemItem,
1165 *,
1166 file_checksums: dict[str, str],
1167 cue_file_checksums: dict[str, set[str]],
1168 cur_filenames: set[str],
1169 items_to_process: list[tuple[FileSystemItem, str | None]],
1170 unchanged_cue_items: list[FileSystemItem],
1171 cue_stems: set[str],
1172 ignore_album_playlists: bool,
1173 metadata_files: list[FileSystemItem],
1174 ) -> None:
1175 """
1176 Route a single scanned file into the correct sync bucket.
1177
1178 :param item: The file to classify.
1179 :param file_checksums: Previously stored checksum per provider item id.
1180 :param cue_file_checksums: Previously stored track checksums keyed by CUE relative_path.
1181 :param cur_filenames: Receives the ids/paths present in this scan.
1182 :param items_to_process: Receives changed or new items to process.
1183 :param unchanged_cue_items: Receives CUE sheets whose checksum matches.
1184 :param cue_stems: Receives absolute paths (minus extension) of CUE sheets.
1185 :param ignore_album_playlists: When True, skip playlists nested inside
1186 album directories.
1187 :param metadata_files: Receives local metadata files (NFO/images) found in the walk.
1188 """
1189 if is_metadata_file(item):
1190 # a local metadata file is never imported media: it carries no provider mapping
1191 # and is never deleted, only checked (after the walk) for a change worth
1192 # reparsing its registered representative track
1193 metadata_files.append(item)
1194 return
1195 if not item.is_dir and item.ext and item.ext.lower() in METADATA_FILE_EXTENSIONS:
1196 # an nfo/image extension is walked only to catch a recognized metadata file above;
1197 # an unrecognized one (wrong filename) must stay as invisible to the scan as before
1198 # this feature widened the walk beyond SUPPORTED_EXTENSIONS, or a stray image/nfo
1199 # on a wrong/empty mount could satisfy the "not empty" check below and silently
1200 # bypass the safeguard against deleting an entire library
1201 return
1202 # a file this provider never imports gets no mapping, so it would flag as
1203 # changed on every sync; it is still on disk, so record it as present
1204 if not self._is_imported_file(item):
1205 cur_filenames.add(item.relative_path)
1206 return
1207 # skip playlists in album directories if configured
1208 if (
1209 item.ext in PLAYLIST_EXTENSIONS
1210 and ignore_album_playlists
1211 and len(item.relative_path.split("/")) > 2
1212 ):
1213 return
1214 is_cue = item.ext in CUE_EXTENSIONS and self.media_content_type == "music"
1215 item_checksum = item.checksum
1216 if is_cue:
1217 cue_stems.add(item.absolute_path.rsplit(".", 1)[0])
1218 item_checksum = cue_metadata_checksum(item.checksum)
1219 prev_checksums = cue_file_checksums.get(item.relative_path, set())
1220 prev_checksum = min(prev_checksums, default=None)
1221 checksum_matches = prev_checksums == {item_checksum}
1222 else:
1223 prev_checksum = file_checksums.get(item.relative_path)
1224 checksum_matches = item_checksum == prev_checksum
1225 if checksum_matches:
1226 # unchanged, just record it as still present
1227 cur_filenames.add(item.relative_path)
1228 if is_cue:
1229 unchanged_cue_items.append(item)
1230 else:
1231 items_to_process.append((item, prev_checksum))
1232
1233 def _is_imported_file(self, item: FileSystemItem) -> bool:
1234 """Return True when this provider imports the given file into the library."""
1235 if self.media_content_type == "music":
1236 if item.ext in CUE_EXTENSIONS:
1237 return True
1238 if item.ext in TRACK_EXTENSIONS:
1239 return self._sync_tracks
1240 if item.ext in PLAYLIST_EXTENSIONS:
1241 return self._sync_playlists
1242 return False
1243 if self.media_content_type == "audiobooks":
1244 return item.ext in AUDIOBOOK_EXTENSIONS
1245 if self.media_content_type == "podcasts":
1246 return item.ext in PODCAST_EPISODE_EXTENSIONS
1247 return False
1248
1249 async def _resolve_artist_representative_track(self, artist: Artist) -> str | None:
1250 """
1251 Return one of this artist's own track paths, to register a metadata-file baseline.
1252
1253 Used when explicitly (re)fetching an artist (e.g. a manual "Refresh item"), which does
1254 not otherwise go through `_parse_track`/`_parse_album` and so would leave a freshly
1255 read artist.nfo/image unregistered - permanently invisible to the sync's automatic
1256 change detection until an unrelated track/album parse happens to register it instead.
1257
1258 :param artist: The library artist whose own tracks (if any) are searched.
1259 """
1260 tracks = await self.mass.music.artists.get_library_artist_tracks(
1261 artist.item_id, provider_filter=self.instance_id
1262 )
1263 for track in tracks:
1264 for prov_mapping in track.provider_mappings:
1265 if prov_mapping.provider_instance != self.instance_id:
1266 continue
1267 # a CUE-derived track's mapping is a synthetic "<cue path>::<track>" id, not
1268 # itself a resolvable path; its CUE sheet is, and reprocessing that sheet
1269 # already refreshes every track (and this artist) it describes
1270 if parsed := parse_cue_track_id(prov_mapping.item_id):
1271 return parsed[0]
1272 return prov_mapping.item_id
1273 return None
1274
1275 async def _drop_stale_album_artist_caches(self) -> None:
1276 """
1277 Drop this provider's own short-lived album/artist/folder-image caches.
1278
1279 Called once, before this sync's batch starts processing, whenever at least one
1280 metadata-file change queued a representative for reparsing. A queued representative's
1281 reparse must see that change, not a stale object left by a read within the last 120
1282 seconds; without this, a concurrent parse of the same folder (whether the queued
1283 representative itself or an unrelated sibling track reprocessed for another reason in
1284 this same batch) could read - or worse, write back to the database - the pre-change
1285 data, silently undoing the refresh.
1286 """
1287 for stale_category in (
1288 CACHE_CATEGORY_ALBUM_INFO,
1289 CACHE_CATEGORY_ARTIST_INFO,
1290 CACHE_CATEGORY_FOLDER_IMAGES,
1291 ):
1292 await self.cache.delete(key=None, category=stale_category, provider=self.instance_id)
1293
1294 async def _queue_changed_metadata_files(
1295 self,
1296 metadata_files: list[FileSystemItem],
1297 file_checksums: dict[str, str],
1298 cue_file_checksums: dict[str, set[str]],
1299 items_to_process: list[tuple[FileSystemItem, str | None]],
1300 force_refresh_tracks: set[str],
1301 ) -> None:
1302 """
1303 Queue the representative track of each changed local metadata file for reparsing.
1304
1305 A metadata file (NFO or recognized folder image) with no registered cache entry is a
1306 new or never-parsed file and is ignored here; a full reparse (a manual refresh, or the
1307 next time its representative track changes for any other reason) registers it. A
1308 registered file whose token is unchanged is also ignored. Only a registered file whose
1309 token changed queues its representative track, deduplicated against tracks already
1310 queued by this sync (whether from another changed metadata file or their own change).
1311
1312 This never writes the metadata-file cache itself: only actually reparsing the
1313 representative track (which re-reads the file) advances its registered token, so a
1314 failed parse is retried next sync instead of being silently marked as handled. A
1315 changed recognized image also has its own image cache invalidated unconditionally,
1316 even when its representative reparse itself is deduplicated away.
1317
1318 :param metadata_files: Local metadata files collected by this sync's walk.
1319 :param file_checksums: Previously stored checksum per provider item id.
1320 :param cue_file_checksums: Previously stored track checksums keyed by CUE relative_path.
1321 :param items_to_process: The sync's changed/new items; receives queued representatives.
1322 :param force_refresh_tracks: Receives the relative_path of every representative that
1323 was actually queued (new or deduplicated against one already queued); a non-empty
1324 result tells the caller to drop this provider's own short-lived album/artist/
1325 folder-image caches before processing, so a concurrent parse of the same folder
1326 cannot hand back or write back the pre-change data. Left empty when nothing was
1327 actually queued (e.g. every representative failed to resolve).
1328 """
1329 # one bulk load instead of one query per metadata file, which otherwise would mean
1330 # thousands of sequential cache reads on a large library every single sync
1331 registrations = await self.cache.get_all(
1332 provider=self.instance_id, category=CACHE_CATEGORY_METADATA_FILE
1333 )
1334 queued_tracks = {item.relative_path for item, _ in items_to_process}
1335 for meta_item in metadata_files:
1336 cached = registrations.get(meta_item.relative_path)
1337 if not cached:
1338 continue
1339 if cached.get("token") == meta_item.metadata_change_token:
1340 continue
1341 track_path = cached.get("track")
1342 if not track_path:
1343 continue
1344 if is_image_file(meta_item):
1345 # invalidate unconditionally, even when the representative reparse below is
1346 # deduplicated away (e.g. the track itself also changed, or another metadata
1347 # file already queued it this sync): the image keeps the same (provider, path)
1348 # identity, so skipping this here would leave its old bytes cached regardless
1349 await self.mass.metadata.invalidate_image_cache(
1350 self.instance_id, meta_item.relative_path
1351 )
1352 if track_path in queued_tracks:
1353 # already queued for another reason (its own content changed, or another
1354 # metadata file got here first): still needs the stale-cache drop below, since
1355 # that reparse must see this change too
1356 force_refresh_tracks.add(track_path)
1357 continue
1358 try:
1359 track_item = await self.resolve(track_path)
1360 except MediaNotFoundError, OSError:
1361 # the representative no longer resolves: leave the old token in place so a
1362 # future sync (once a fresh representative registers) or a manual refresh
1363 # can recover; this change is deferred, not lost - and since nothing is
1364 # actually queued, the stale-cache drop below is not needed either
1365 continue
1366 if track_item.is_dir:
1367 continue
1368 queued_tracks.add(track_path)
1369 force_refresh_tracks.add(track_path)
1370 if track_item.ext in CUE_EXTENSIONS:
1371 # a CUE sheet's own checksum is not tracked under its path in file_checksums,
1372 # only the synthetic per-track ids it expands into are
1373 prev_checksum = min(cue_file_checksums.get(track_path, set()), default=None)
1374 else:
1375 prev_checksum = file_checksums.get(track_path)
1376 items_to_process.append((track_item, prev_checksum))
1377
1378 async def _register_metadata_file(
1379 self, item: FileSystemItem, representative_track: str | None
1380 ) -> None:
1381 """
1382 Remember a local metadata file's current token and its representative track.
1383
1384 Called whenever album/artist parsing reads an NFO file or enumerates a recognized
1385 folder image, so a later sync can detect the file changing on disk and reparse just
1386 that one representative track. This cache is derivative, not authoritative: it is
1387 never consulted by parsing itself, only by the sync walk's change detection.
1388
1389 :param item: The metadata file (NFO or recognized folder image) that was just read.
1390 :param representative_track: The track whose reparse rebuilds this item; skipped when
1391 falsy, since there is then nothing useful to reparse later.
1392 """
1393 if not representative_track:
1394 return
1395 await self.cache.set(
1396 key=item.relative_path,
1397 data={"token": item.metadata_change_token, "track": representative_track},
1398 provider=self.instance_id,
1399 category=CACHE_CATEGORY_METADATA_FILE,
1400 expiration=METADATA_FILE_CACHE_EXPIRATION,
1401 # this is functional registration data the feature depends on, not a disposable
1402 # cache: keep it out of a generic "clear cache" action
1403 persistent=True,
1404 )
1405
1406 def _set_available(self, available: bool) -> None:
1407 """Update the provider availability and notify listeners on change."""
1408 if self.available == available:
1409 return
1410 self.available = available
1411 if available:
1412 self._cancel_availability_probe()
1413 else:
1414 self._schedule_availability_probe()
1415 self.mass.signal_event(EventType.PROVIDERS_UPDATED, data=self.mass.get_providers())
1416
1417 async def _is_reachable(self) -> bool:
1418 """Return whether the storage backing this provider can be read."""
1419 return bool(await isdir(self.base_path))
1420
1421 @property
1422 def _availability_probe_id(self) -> str:
1423 """Return the timer id of this provider's reachability checks."""
1424 return f"filesystem_availability_probe_{self.instance_id}"
1425
1426 def _schedule_availability_probe(self) -> None:
1427 """Arm the next reachability check."""
1428 self.mass.call_later(
1429 AVAILABILITY_PROBE_INTERVAL,
1430 self._probe_availability,
1431 task_id=self._availability_probe_id,
1432 )
1433
1434 def _cancel_availability_probe(self) -> None:
1435 """Stop checking for the storage coming back."""
1436 self.mass.cancel_timer(self._availability_probe_id)
1437
1438 async def _probe_availability(self) -> None:
1439 """Mark the provider available again once its storage can be read."""
1440 try:
1441 reachable = await self._is_reachable()
1442 except MusicAssistantError as err:
1443 # storage that is simply still gone, which is what this loop waits for
1444 self.logger.debug("%s is still unreachable: %s", self.name, err)
1445 reachable = False
1446 except Exception:
1447 # an unexpected failure must not end the loop, since it is what brings the
1448 # provider back, but it is a defect rather than an outage so it is logged loudly
1449 self.logger.exception("Reachability check for %s failed", self.name)
1450 reachable = False
1451 if self.unloading:
1452 # the provider was torn down while this check was running; re-arming here
1453 # would leave a timer firing against an instance nothing owns anymore
1454 return
1455 if reachable:
1456 self.logger.info("%s is reachable again", self.name)
1457 self._set_available(True)
1458 return
1459 self._schedule_availability_probe()
1460
1461 async def _process_item_async(
1462 self,
1463 item: FileSystemItem,
1464 prev_checksum: str | None,
1465 cur_filenames: set[str] | None = None,
1466 cue_stems: set[str] | None = None,
1467 prev_filenames: set[str] | None = None,
1468 ) -> bool:
1469 """
1470 Process a single item asynchronously.
1471
1472 :param item: The filesystem item to process.
1473 :param prev_checksum: Previous checksum from the database, or None for new items.
1474 :param cur_filenames: Set of current filenames being tracked (for CUE track IDs).
1475 :param cue_stems: Absolute paths (without extension) of CUE sheets in this scan,
1476 used to detect companion-CUE audio files without a filesystem stat.
1477 :param prev_filenames: The ids/paths the previous scan found, used to keep the
1478 ids of a CUE sheet that fails to parse.
1479 """
1480 try:
1481 self.logger.log(VERBOSE_LOG_LEVEL, "Processing: %s", item.relative_path)
1482
1483 if prev_checksum is not None:
1484 # the file changed on disk: drop cached artwork derived from it
1485 # (thumbnails, source bytes, palette) so re-read embedded art is
1486 # served fresh, for both reference forms of the image path
1487 await self.mass.metadata.invalidate_image_cache(
1488 self.instance_id, item.relative_path
1489 )
1490 await self.mass.metadata.invalidate_image_cache(
1491 self.instance_id, self._versioned_image_path(item.relative_path, prev_checksum)
1492 )
1493
1494 if item.ext in CUE_EXTENSIONS and self.media_content_type == "music":
1495 tracks = await self._cue.parse_tracks(item)
1496 for track in tracks:
1497 track.favorite = False
1498 await self.mass.music.tracks.add_item_to_library(
1499 track, overwrite_existing=prev_checksum is not None
1500 )
1501 if cur_filenames is not None:
1502 cur_filenames.add(track.item_id)
1503 return True
1504
1505 if item.ext in TRACK_EXTENSIONS and self.media_content_type == "music":
1506 if not self._sync_tracks:
1507 return False
1508 # skip audio files that have a companion CUE sheet
1509 if cue_stems is not None and item.absolute_path.rsplit(".", 1)[0] in cue_stems:
1510 return False
1511 tags = await async_parse_tags(item.absolute_path, item.file_size)
1512 track = await self._parse_track(item, tags)
1513 track.favorite = False # TODO: implement favorite status based on rating ?
1514 await self.mass.music.tracks.add_item_to_library(
1515 track, overwrite_existing=prev_checksum is not None
1516 )
1517 return True
1518
1519 if item.ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
1520 tags = await async_parse_tags(item.absolute_path, item.file_size)
1521 try:
1522 audiobook = await self._parse_audiobook(item, tags)
1523 except IsChapterFile:
1524 return True
1525 await self.mass.music.audiobooks.add_item_to_library(
1526 audiobook, overwrite_existing=prev_checksum is not None
1527 )
1528 return True
1529
1530 if item.ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
1531 tags = await async_parse_tags(item.absolute_path, item.file_size)
1532 episode = await self._parse_podcast_episode(item, tags)
1533 assert isinstance(episode.podcast, Podcast)
1534 await self.mass.music.podcasts.add_item_to_library(
1535 episode.podcast, overwrite_existing=prev_checksum is not None
1536 )
1537 return True
1538
1539 if item.ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
1540 if not self._sync_playlists:
1541 return False
1542 playlist = await self.get_playlist(item.relative_path)
1543 await self.mass.music.playlists.add_item_to_library(
1544 playlist, overwrite_existing=prev_checksum is not None
1545 )
1546 return True
1547
1548 except Exception as err:
1549 # we don't want the whole sync to crash on one file so we catch all exceptions here
1550 self.logger.error(
1551 "Error processing %s - %s",
1552 item.relative_path,
1553 str(err),
1554 exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
1555 )
1556 report_current_task_failure(f"Failed to process {item.relative_path}: {err}")
1557 # the file is still on the storage, so keep it in the scan result:
1558 # leaving it out makes the deletion step treat it as removed
1559 self._keep_failed_item(item, cur_filenames, prev_filenames)
1560 return False
1561
1562 def _keep_failed_item(
1563 self,
1564 item: FileSystemItem,
1565 cur_filenames: set[str] | None,
1566 prev_filenames: set[str] | None,
1567 ) -> None:
1568 """
1569 Keep an item that could not be processed in the scan result.
1570
1571 :param item: The item that failed to process.
1572 :param cur_filenames: Receives the ids/paths present in this scan.
1573 :param prev_filenames: The ids/paths the previous scan found.
1574 """
1575 if cur_filenames is None:
1576 return
1577 cur_filenames.add(item.relative_path)
1578 if (
1579 item.ext not in CUE_EXTENSIONS
1580 or self.media_content_type != "music"
1581 or not prev_filenames
1582 ):
1583 return
1584 # a CUE sheet stands in for one id per track it describes and those cannot be
1585 # rebuilt without parsing it, so carry over the ids of the previous scan
1586 cur_filenames.update(
1587 item_id
1588 for item_id in prev_filenames
1589 if (parsed := parse_cue_track_id(item_id)) and parsed[0] == item.relative_path
1590 )
1591
1592 async def _process_orphaned_albums_and_artists(self) -> None:
1593 """Process deletion of orphaned albums and artists."""
1594 assert self.mass.music.database
1595 # Remove albums without any tracks
1596 query = (
1597 f"SELECT item_id FROM {DB_TABLE_ALBUMS} "
1598 f"WHERE item_id not in ( SELECT album_id from {DB_TABLE_ALBUM_TRACKS}) "
1599 f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
1600 f"WHERE provider_instance = '{self.instance_id}' and media_type = 'album' )"
1601 )
1602 for db_row in await self.mass.music.database.get_rows_from_query(
1603 query,
1604 limit=100000,
1605 ):
1606 await self.mass.music.albums.remove_item_from_library(db_row["item_id"])
1607
1608 # Remove artists without any tracks or albums
1609 query = (
1610 f"SELECT item_id FROM {DB_TABLE_ARTISTS} "
1611 f"WHERE item_id not in "
1612 f"( select artist_id from {DB_TABLE_TRACK_ARTISTS} "
1613 f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} ) "
1614 f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
1615 f"WHERE provider_instance = '{self.instance_id}' and media_type = 'artist' )"
1616 )
1617 for db_row in await self.mass.music.database.get_rows_from_query(
1618 query,
1619 limit=100000,
1620 ):
1621 await self.mass.music.artists.remove_item_from_library(db_row["item_id"])
1622
1623 async def _process_deletions(self, deleted_files: set[str]) -> None:
1624 """Process all deletions."""
1625 # process deleted tracks/playlists
1626 album_ids = set()
1627 artist_ids = set()
1628 for file_path in deleted_files:
1629 if parse_cue_track_id(file_path) is not None and self.media_content_type == "music":
1630 controller = self.mass.music.get_controller(MediaType.TRACK)
1631 elif "." not in file_path:
1632 continue
1633 else:
1634 _, ext = file_path.rsplit(".", 1)
1635 if ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
1636 controller = self.mass.music.get_controller(MediaType.PODCAST_EPISODE)
1637 elif ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
1638 controller = self.mass.music.get_controller(MediaType.AUDIOBOOK)
1639 elif ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
1640 controller = self.mass.music.get_controller(MediaType.PLAYLIST)
1641 elif ext in TRACK_EXTENSIONS and self.media_content_type == "music":
1642 controller = self.mass.music.get_controller(MediaType.TRACK)
1643 else:
1644 # unsupported file extension?
1645 continue
1646
1647 if library_item := await controller.get_library_item_by_prov_id(
1648 file_path, self.instance_id
1649 ):
1650 if is_track(library_item):
1651 if library_item.album:
1652 album_ids.add(library_item.album.item_id)
1653 # need to fetch the library album to resolve the itemmapping
1654 db_album = await self.mass.music.albums.get_library_item(
1655 library_item.album.item_id
1656 )
1657 for artist in db_album.artists:
1658 artist_ids.add(artist.item_id)
1659 for artist in library_item.artists:
1660 artist_ids.add(artist.item_id)
1661 await controller.remove_item_from_library(library_item.item_id)
1662 # check if any albums need to be cleaned up
1663 for album_id in album_ids:
1664 if not await self.mass.music.albums.tracks(album_id, "library"):
1665 await self.mass.music.albums.remove_item_from_library(album_id)
1666 # check if any artists need to be cleaned up
1667 for artist_id in artist_ids:
1668 artist_albums = await self.mass.music.artists.albums(artist_id, "library")
1669 artist_tracks = await self.mass.music.artists.tracks(artist_id, "library")
1670 if not (artist_albums or artist_tracks):
1671 await self.mass.music.artists.remove_item_from_library(artist_id)
1672
1673 async def _get_playlist_local_image(self, file_item: FileSystemItem) -> MediaItemImage | None:
1674 """Return a local image alongside the playlist file (matching basename) if any."""
1675 cache_key = f"playlist_image.{file_item.relative_path}"
1676 cached = await self.cache.get(
1677 key=cache_key,
1678 provider=self.instance_id,
1679 category=CACHE_CATEGORY_FOLDER_IMAGES,
1680 base_class=MediaItemImage,
1681 )
1682 if cached is not None:
1683 return cached[0] if cached else None
1684 try:
1685 folder_files = await self._scandir(file_item.relative_parent_path)
1686 except OSError, MusicAssistantError:
1687 return None
1688 target = file_item.name.lower()
1689 result: MediaItemImage | None = None
1690 for item in folder_files:
1691 if item.is_dir or not item.ext:
1692 continue
1693 if item.ext.lower() not in IMAGE_EXTENSIONS:
1694 continue
1695 if item.name.lower() != target:
1696 continue
1697 result = MediaItemImage(
1698 type=ImageType.THUMB,
1699 path=item.relative_path,
1700 provider=self.instance_id,
1701 remotely_accessible=False,
1702 )
1703 break
1704 await self.cache.set(
1705 key=cache_key,
1706 data=[result.to_dict()] if result is not None else [],
1707 provider=self.instance_id,
1708 category=CACHE_CATEGORY_FOLDER_IMAGES,
1709 expiration=120,
1710 )
1711 return result
1712
1713 async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None:
1714 """Try to parse a track from a playlist line."""
1715 try:
1716 line = line.replace("file://", "").strip()
1717 # try to resolve the filename (both normal and url decoded):
1718 # - relative to the playlist folder (normpath resolves parent .. references)
1719 # - as-is: an absolute path, or relative to our base path
1720 # candidates stay relative so subclasses with virtual paths (cloud,
1721 # webdav) resolve them too, instead of leaking the server CWD
1722 for _line in (line, urllib.parse.unquote(line)):
1723 if playlist_path:
1724 normalized = posixpath.normpath(f"{playlist_path}/{_line}")
1725 with contextlib.suppress(FileNotFoundError, MediaNotFoundError):
1726 file_item = await self.resolve(normalized)
1727 return await self._get_playlist_line_track(file_item)
1728 with contextlib.suppress(FileNotFoundError, MediaNotFoundError):
1729 file_item = await self.resolve(_line)
1730 return await self._get_playlist_line_track(file_item)
1731 # all attempts failed
1732 raise MediaNotFoundError("Invalid path/uri")
1733
1734 except MusicAssistantError as err:
1735 self.logger.warning("Could not parse %s to track: %s", line, str(err))
1736
1737 return None
1738
1739 async def _get_playlist_line_track(self, file_item: FileSystemItem) -> Track:
1740 """
1741 Return the track for a resolved playlist entry.
1742
1743 :param file_item: The resolved file the playlist entry points at.
1744 """
1745 # filesystem tracks are synced into the library, so prefer the database over
1746 # (expensive) tag parsing - this keeps loading large playlists fast
1747 library_track = await self.mass.music.tracks.get_library_item_by_prov_id(
1748 file_item.relative_path, self.instance_id
1749 )
1750 # only trust the library item if its mapping for this file is available: the file
1751 # just resolved, so an unavailable mapping is stale (e.g. the file was missing
1752 # during the last scan) and would wrongly exclude the track from playback
1753 if library_track is not None and any(
1754 mapping.provider_instance == self.instance_id
1755 and mapping.item_id == file_item.relative_path
1756 and mapping.available
1757 for mapping in library_track.provider_mappings
1758 ):
1759 # callers expect the provider item identity here (not the library one),
1760 # e.g. for duplicate detection when editing the playlist
1761 library_track.item_id = file_item.relative_path
1762 library_track.provider = self.instance_id
1763 library_track.uri = create_uri(
1764 MediaType.TRACK, self.instance_id, file_item.relative_path
1765 )
1766 return library_track
1767 # not (yet) in the library: parse the file tags
1768 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1769 return await self._parse_track(file_item, tags)
1770
1771 @staticmethod
1772 def _versioned_image_path(relative_path: str, checksum: str | None) -> str:
1773 """Append the file checksum so the image cache busts when the file is replaced."""
1774 if checksum:
1775 return f"{relative_path}?cs={checksum}"
1776 return relative_path
1777
1778 @staticmethod
1779 def _codec_type_from_tags(tags: AudioTags) -> ContentType:
1780 """Return the audio codec detected by ffprobe, if any."""
1781 if tags.raw and (streams := tags.raw.get("streams")):
1782 if codec_name := streams[0].get("codec_name"):
1783 return ContentType.try_parse(codec_name)
1784 return ContentType.UNKNOWN
1785
1786 async def _parse_track(
1787 self, file_item: FileSystemItem, tags: AudioTags, full_album_metadata: bool = False
1788 ) -> Track:
1789 """Parse full track details from file tags."""
1790 # ruff: noqa: PLR0915
1791 name, version = parse_title_and_version(tags.title, tags.version)
1792 track = Track(
1793 item_id=file_item.relative_path,
1794 provider=self.instance_id,
1795 name=name,
1796 sort_name=tags.title_sort,
1797 version=version,
1798 provider_mappings={
1799 ProviderMapping(
1800 item_id=file_item.relative_path,
1801 provider_domain=self.domain,
1802 provider_instance=self.instance_id,
1803 audio_format=AudioFormat(
1804 content_type=ContentType.try_parse(file_item.ext or tags.format),
1805 codec_type=self._codec_type_from_tags(tags),
1806 sample_rate=tags.sample_rate,
1807 bit_depth=tags.bits_per_sample,
1808 channels=tags.channels,
1809 bit_rate=tags.bit_rate,
1810 ),
1811 details=file_item.checksum,
1812 in_library=True,
1813 )
1814 },
1815 disc_number=tags.disc or 0,
1816 track_number=tags.track or 0,
1817 date_added=(
1818 datetime.fromtimestamp(file_item.created_at, tz=UTC)
1819 if file_item.created_at
1820 else None
1821 ),
1822 )
1823
1824 if isrc_tags := tags.isrc:
1825 for isrsc in isrc_tags:
1826 track.external_ids.add((ExternalID.ISRC, isrsc))
1827
1828 if acoustid := tags.get("acoustid"):
1829 track.external_ids.add((ExternalID.ACOUSTID, acoustid))
1830
1831 # album
1832 album = track.album = (
1833 await self._parse_album(
1834 track_path=file_item.relative_path,
1835 track_tags=tags,
1836 track_created_at=file_item.created_at,
1837 )
1838 if tags.album
1839 else None
1840 )
1841
1842 # track artist(s)
1843 resolved_track_artists = await self._resolve_artists_with_mbids(
1844 tags.artists,
1845 tags.musicbrainz_artistids,
1846 tags.artist_sort_names,
1847 log_label="ARTISTS tag",
1848 )
1849 for name, mbid, sort_name in resolved_track_artists:
1850 # prefer the existing album artist object when it's the same artist
1851 if album_artist_match := self._match_album_artist(album, name, mbid):
1852 track.artists.append(album_artist_match)
1853 continue
1854 artist = await self._parse_artist(
1855 name, sort_name=sort_name, mbid=mbid, representative_track=file_item.relative_path
1856 )
1857 track.artists.append(artist)
1858
1859 # handle embedded cover image
1860 if tags.has_cover_image:
1861 # we do not actually embed the image in the metadata because that would consume too
1862 # much space and bandwidth. Instead we set the filename as value so the image can
1863 # be retrieved later in realtime.
1864 track.metadata.images = UniqueList(
1865 [
1866 MediaItemImage(
1867 type=ImageType.THUMB,
1868 path=file_item.relative_path,
1869 provider=self.instance_id,
1870 remotely_accessible=False,
1871 )
1872 ]
1873 )
1874
1875 # copy (embedded) album image from track (if the album itself doesn't have an image)
1876 if album and not album.image and track.image:
1877 album.metadata.images = UniqueList([track.image])
1878
1879 # parse other info
1880 track.duration = int(tags.duration or 0)
1881 track.metadata.genres = set(tags.genres)
1882 if tags.disc:
1883 track.disc_number = tags.disc
1884 if tags.track:
1885 track.track_number = tags.track
1886 track.metadata.copyright = tags.get("copyright")
1887 track.metadata.lyrics = tags.lyrics
1888 track.metadata.grouping = tags.get("grouping")
1889 track.metadata.description = tags.get("comment")
1890 explicit_tag = tags.get("itunesadvisory")
1891 if explicit_tag is not None:
1892 track.metadata.explicit = explicit_tag == "1"
1893 if recording_mbid := clean_mbid(tags.musicbrainz_recordingid, tags.filename):
1894 track.mbid = recording_mbid
1895
1896 # handle (optional) loudness measurement tag(s)
1897 if tags.track_loudness is not None:
1898 self.mass.create_task(
1899 self.mass.streams.audio_analysis.set_track_loudness(
1900 track.item_id,
1901 self.instance_id,
1902 tags.track_loudness,
1903 tags.track_album_loudness,
1904 )
1905 )
1906
1907 # possible lrclib metadata
1908 # synced lyrics are saved as "filename.lrc" by lrcget alongside
1909 # the actual file location - just change the file extension
1910 assert file_item.ext is not None # for type checking
1911 lrc_path = f"{file_item.relative_path.removesuffix(file_item.ext)}lrc"
1912 if await self.exists(lrc_path):
1913 try:
1914 raw = await self._read_file(lrc_path)
1915 track.metadata.lrc_lyrics = raw.decode("utf-8")
1916 except Exception as err:
1917 self.logger.warning(
1918 "Failed to read lyrics file %s: %s",
1919 lrc_path,
1920 str(err),
1921 )
1922 elif syn_lyrics := tags.synchronized_lyrics:
1923 track.metadata.lrc_lyrics = lyrics.convert_to_lrc_lyrics(syn_lyrics)
1924
1925 return track
1926
1927 async def _resolve_artists_with_mbids(
1928 self,
1929 parsed_names: tuple[str, ...],
1930 mbids: tuple[str, ...],
1931 sort_names: tuple[str, ...],
1932 log_label: str,
1933 ) -> list[tuple[str, str | None, str | None]]:
1934 """
1935 Return ``(name, mbid, sort_name)`` triples for a track's or album's artists.
1936
1937 When the parsed name count and the MBID count disagree, canonical names
1938 are looked up from MusicBrainz; otherwise the tag-parsed names are used.
1939
1940 :param parsed_names: Tag-parsed artist names.
1941 :param mbids: MusicBrainz artist IDs from the tag.
1942 :param sort_names: Sort names from the corresponding *sort tag.
1943 :param log_label: Tag name used in warning messages (e.g. "ARTISTS tag").
1944 """
1945
1946 def _sort_name(index: int) -> str | None:
1947 return sort_names[index] if index < len(sort_names) else None
1948
1949 def _from_tags() -> list[tuple[str, str | None, str | None]]:
1950 return [
1951 (
1952 name,
1953 mbids[i] if i < len(mbids) else None,
1954 _sort_name(i),
1955 )
1956 for i, name in enumerate(parsed_names)
1957 ]
1958
1959 if not mbids or len(parsed_names) == len(mbids):
1960 return _from_tags()
1961
1962 mb_provider = cast("MusicbrainzProvider | None", self.mass.get_provider("musicbrainz"))
1963 if mb_provider is None:
1964 self.logger.warning(
1965 "%s count (%d) doesn't match MBID count (%d) and MusicBrainz "
1966 "provider is not loaded; using tag-parsed names: %s",
1967 log_label,
1968 len(parsed_names),
1969 len(mbids),
1970 parsed_names,
1971 )
1972 return _from_tags()
1973
1974 mb_results = await mb_provider.resolve_artists_from_mbids(mbids)
1975 # counts disagree, so positional fallback to a tag name is unreliable;
1976 # drop any MBID whose lookup failed (already logged per-MBID)
1977 resolved: list[tuple[str, str | None, str | None]] = [
1978 mb_result for mb_result in mb_results if mb_result is not None
1979 ]
1980 if not resolved:
1981 self.logger.warning(
1982 "%s count (%d) didn't match MBID count (%d) and every MusicBrainz "
1983 "lookup failed; falling back to tag-parsed names: %s",
1984 log_label,
1985 len(parsed_names),
1986 len(mbids),
1987 parsed_names,
1988 )
1989 return _from_tags()
1990 self.logger.info(
1991 "%s count (%d) didn't match MBID count (%d); resolved canonical names "
1992 "via MusicBrainz: %s",
1993 log_label,
1994 len(parsed_names),
1995 len(mbids),
1996 [r[0] for r in resolved],
1997 )
1998 return resolved
1999
2000 def _match_album_artist(
2001 self, album: Album | None, name: str, mbid: str | None
2002 ) -> Artist | ItemMapping | None:
2003 """
2004 Return an existing album artist representing the same artist, if any.
2005
2006 Matches on MusicBrainz ID when available (names may differ when only one
2007 side was resolved against MusicBrainz), otherwise on exact name.
2008
2009 :param album: The track's album, if known.
2010 :param name: Resolved track-artist name.
2011 :param mbid: Resolved track-artist MusicBrainz ID, if any.
2012 """
2013 if not album:
2014 return None
2015 return next(
2016 (x for x in album.artists if (mbid and x.mbid == mbid) or x.name == name),
2017 None,
2018 )
2019
2020 async def _parse_artist(
2021 self,
2022 name: str,
2023 album_dir: str | None = None,
2024 sort_name: str | None = None,
2025 mbid: str | None = None,
2026 artist_path: str | None = None,
2027 representative_track: str | None = None,
2028 ) -> Artist:
2029 """Parse full (album) Artist."""
2030 if not artist_path:
2031 # we need to hunt for the artist (metadata) path on disk
2032 # this can either be relative to the album path or at root level
2033 # check if we have an artist folder for this artist at root level
2034 safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
2035 if await self.exists(name):
2036 artist_path = name
2037 elif await self.exists(safe_artist_name):
2038 artist_path = safe_artist_name
2039 elif album_dir and (foldermatch := get_artist_dir(name, album_dir=album_dir)):
2040 # try to find (album)artist folder based on album path
2041 artist_path = foldermatch
2042 else:
2043 # check if we have an existing item to retrieve the artist path
2044 async for item in self.mass.music.artists.iter_library_items(
2045 search=name, provider=self.instance_id
2046 ):
2047 if not compare_strings(name, item.name):
2048 continue
2049 for prov_mapping in item.provider_mappings:
2050 if prov_mapping.provider_instance != self.instance_id:
2051 continue
2052 if prov_mapping.url:
2053 artist_path = prov_mapping.url
2054 break
2055 if artist_path:
2056 break
2057
2058 # prefer (short lived) cache for a bit more speed
2059 if artist_path and (
2060 cache := await self.cache.get(
2061 key=artist_path,
2062 provider=self.instance_id,
2063 category=CACHE_CATEGORY_ARTIST_INFO,
2064 base_class=Artist,
2065 )
2066 ):
2067 return cache # type: ignore[no-any-return]
2068
2069 prov_artist_id = artist_path or name
2070 artist = Artist(
2071 item_id=prov_artist_id,
2072 provider=self.instance_id,
2073 name=name,
2074 sort_name=sort_name,
2075 provider_mappings={
2076 ProviderMapping(
2077 item_id=prov_artist_id,
2078 provider_domain=self.domain,
2079 provider_instance=self.instance_id,
2080 url=artist_path,
2081 in_library=True,
2082 )
2083 },
2084 )
2085 if mbid := clean_mbid(mbid, f"tags of artist {name}"):
2086 artist.mbid = mbid
2087 if not artist_path or not await self.exists(artist_path):
2088 return artist
2089
2090 # grab additional metadata within the Artist's folder
2091 nfo_file = os.path.join(artist_path, "artist.nfo")
2092 if await self.exists(nfo_file):
2093 # resolve before reading: the registered token must reflect the version we are
2094 # about to parse, not whatever the file happens to be once parsing has finished
2095 nfo_item = await self.resolve(nfo_file)
2096 # found NFO file with metadata
2097 # https://kodi.wiki/view/NFO_files/Artists
2098 try:
2099 data = (await self._read_file(nfo_file)).decode("utf-8")
2100 info = await asyncio.to_thread(xmltodict.parse, data)
2101 info = info["artist"]
2102 artist.name = info.get("title", info.get("name", name))
2103 if sort_name := info.get("sortname"):
2104 artist.sort_name = sort_name
2105 if mbid := clean_mbid(info.get("musicbrainzartistid"), nfo_file):
2106 artist.mbid = mbid
2107 if description := info.get("biography"):
2108 artist.metadata.description = description
2109 if genre := info.get("genre"):
2110 artist.metadata.genres = set(split_items(genre))
2111 # only a successful parse counts as having read this NFO: registering on a
2112 # malformed file would advance its token and treat the bad edit as handled,
2113 # permanently masking it (until unrelated changes trigger a full reparse)
2114 await self._register_metadata_file(nfo_item, representative_track)
2115 except (ExpatError, KeyError) as err:
2116 self.logger.warning(
2117 "Failed to parse artist NFO file %s: %s",
2118 nfo_file,
2119 str(err),
2120 )
2121 # find local images
2122 if images := await self._get_local_images(
2123 artist_path, extra_thumb_names=("artist",), representative_track=representative_track
2124 ):
2125 artist.metadata.images = UniqueList(images)
2126
2127 await self.cache.set(
2128 key=artist_path,
2129 data=artist.to_dict(),
2130 provider=self.instance_id,
2131 category=CACHE_CATEGORY_ARTIST_INFO,
2132 expiration=120,
2133 )
2134
2135 return artist
2136
2137 async def _parse_audiobook(self, file_item: FileSystemItem, tags: AudioTags) -> Audiobook:
2138 """
2139 Parse Audiobook details from file tags.
2140
2141 Audiobooks can be single files with embedded chapters or multiple files per folder.
2142 Only the first file (by track number or alphabetically) is processed as the audiobook.
2143 """
2144 # Skip files that aren't the first chapter.
2145 # A file carrying its own embedded chapter markers is a standalone audiobook,
2146 # so it should never be treated as a chapter file of another book.
2147 track_tag = tags.tags.get("track")
2148 if track_tag:
2149 track_num = try_parse_int(str(track_tag).split("/")[0], None)
2150 if track_num and track_num > 1 and not tags.chapters:
2151 raise IsChapterFile
2152 elif not tags.chapters:
2153 # No track tag and no embedded chapters -
2154 # assume part of a multi-file audiobook, only process the first file alphabetically
2155 items = await self._scandir(file_item.relative_parent_path)
2156 # Sort by filename for alphabetical ordering
2157 items.sort(key=lambda x: x.filename.lower())
2158 for item in items:
2159 if item.is_dir or item.ext not in AUDIOBOOK_EXTENSIONS:
2160 continue
2161 if item.absolute_path != file_item.absolute_path:
2162 raise IsChapterFile
2163 break
2164
2165 # For multi-file audiobooks, album tag is the book name, title is the chapter name
2166 if tags.album:
2167 book_name = tags.album
2168 sort_name = tags.album_sort
2169 elif (title := tags.tags.get("title")) and tags.track is None:
2170 book_name = title
2171 sort_name = tags.title_sort
2172 else:
2173 # file(s) without tags, use foldername
2174 book_name = file_item.parent_name
2175 sort_name = None
2176
2177 # collect all chapters
2178 total_duration, chapters = await self._get_chapters_for_audiobook(file_item, tags)
2179
2180 audio_book = Audiobook(
2181 item_id=file_item.relative_path,
2182 provider=self.instance_id,
2183 name=book_name,
2184 sort_name=sort_name,
2185 version=tags.version,
2186 duration=total_duration or int(tags.duration or 0),
2187 provider_mappings={
2188 ProviderMapping(
2189 item_id=file_item.relative_path,
2190 provider_domain=self.domain,
2191 provider_instance=self.instance_id,
2192 audio_format=AudioFormat(
2193 content_type=ContentType.try_parse(file_item.ext or tags.format),
2194 codec_type=self._codec_type_from_tags(tags),
2195 sample_rate=tags.sample_rate,
2196 bit_depth=tags.bits_per_sample,
2197 channels=tags.channels,
2198 bit_rate=tags.bit_rate,
2199 ),
2200 details=file_item.checksum,
2201 in_library=True,
2202 )
2203 },
2204 )
2205 audio_book.metadata.chapters = chapters
2206
2207 # handle embedded cover image
2208 if tags.has_cover_image:
2209 # we do not actually embed the image in the metadata because that would consume too
2210 # much space and bandwidth. Instead we set the filename as value so the image can
2211 # be retrieved later in realtime.
2212 audio_book.metadata.add_image(
2213 MediaItemImage(
2214 type=ImageType.THUMB,
2215 path=self._versioned_image_path(file_item.relative_path, file_item.checksum),
2216 provider=self.instance_id,
2217 remotely_accessible=False,
2218 )
2219 )
2220
2221 # parse other info
2222 audio_book.authors.set(tags.writers or tags.album_artists or tags.artists)
2223 audio_book.metadata.genres = (
2224 set(tags.genres) if tags.genres else {DEFAULT_AUDIOBOOK_PODCAST_GENRE}
2225 )
2226 audio_book.metadata.copyright = tags.get("copyright")
2227 audio_book.metadata.lyrics = tags.lyrics
2228 audio_book.metadata.description = tags.get("comment")
2229 explicit_tag = tags.get("itunesadvisory")
2230 if explicit_tag is not None:
2231 audio_book.metadata.explicit = explicit_tag == "1"
2232 if recording_mbid := clean_mbid(tags.musicbrainz_recordingid, tags.filename):
2233 audio_book.mbid = recording_mbid
2234
2235 # try to fetch additional metadata from the folder
2236 if not audio_book.image or not audio_book.metadata.description:
2237 # try to get an image by traversing files in the same folder
2238 for _item in await self._scandir(file_item.relative_parent_path):
2239 if "." not in _item.relative_path or _item.is_dir:
2240 continue
2241 if _item.ext in IMAGE_EXTENSIONS and not audio_book.image:
2242 audio_book.metadata.add_image(
2243 MediaItemImage(
2244 type=ImageType.THUMB,
2245 path=self._versioned_image_path(_item.relative_path, _item.checksum),
2246 provider=self.instance_id,
2247 remotely_accessible=False,
2248 )
2249 )
2250 if _item.ext == "txt" and not audio_book.metadata.description:
2251 # try to parse a description from a text file
2252 try:
2253 raw = await self._read_file(_item.relative_path)
2254 audio_book.metadata.description = raw.decode("utf-8")
2255 except Exception as err:
2256 self.logger.warning(
2257 "Could not read description from file %s: %s",
2258 _item.relative_path,
2259 str(err),
2260 )
2261
2262 # handle (optional) loudness measurement tag(s)
2263 if tags.track_loudness is not None:
2264 self.mass.create_task(
2265 self.mass.streams.audio_analysis.set_track_loudness(
2266 audio_book.item_id,
2267 self.instance_id,
2268 tags.track_loudness,
2269 tags.track_album_loudness,
2270 media_type=MediaType.AUDIOBOOK,
2271 )
2272 )
2273 return audio_book
2274
2275 async def _parse_podcast_episode(
2276 self, file_item: FileSystemItem, tags: AudioTags
2277 ) -> PodcastEpisode:
2278 """Parse full PodcastEpisode details from file tags."""
2279 # ruff: noqa: PLR0915
2280 podcast_name = tags.album or file_item.parent_name
2281 podcast_path = file_item.relative_parent_path
2282 episode = PodcastEpisode(
2283 item_id=file_item.relative_path,
2284 provider=self.instance_id,
2285 name=tags.title,
2286 sort_name=tags.title_sort,
2287 provider_mappings={
2288 ProviderMapping(
2289 item_id=file_item.relative_path,
2290 provider_domain=self.domain,
2291 provider_instance=self.instance_id,
2292 audio_format=AudioFormat(
2293 content_type=ContentType.try_parse(file_item.ext or tags.format),
2294 codec_type=self._codec_type_from_tags(tags),
2295 sample_rate=tags.sample_rate,
2296 bit_depth=tags.bits_per_sample,
2297 channels=tags.channels,
2298 bit_rate=tags.bit_rate,
2299 ),
2300 details=file_item.checksum,
2301 in_library=True,
2302 )
2303 },
2304 position=tags.track or 0,
2305 duration=try_parse_int(tags.duration) or 0,
2306 podcast=Podcast(
2307 item_id=podcast_path,
2308 provider=self.instance_id,
2309 name=podcast_name,
2310 sort_name=tags.album_sort,
2311 publisher=tags.tags.get("publisher"),
2312 provider_mappings={
2313 ProviderMapping(
2314 item_id=podcast_path,
2315 provider_domain=self.domain,
2316 provider_instance=self.instance_id,
2317 in_library=True,
2318 )
2319 },
2320 ),
2321 )
2322 # handle embedded cover image
2323 if tags.has_cover_image:
2324 # we do not actually embed the image in the metadata because that would consume too
2325 # much space and bandwidth. Instead we set the filename as value so the image can
2326 # be retrieved later in realtime.
2327 episode.metadata.add_image(
2328 MediaItemImage(
2329 type=ImageType.THUMB,
2330 path=file_item.relative_path,
2331 provider=self.instance_id,
2332 remotely_accessible=False,
2333 )
2334 )
2335 # parse other info
2336 episode.metadata.genres = (
2337 set(tags.genres) if tags.genres else {DEFAULT_AUDIOBOOK_PODCAST_GENRE}
2338 )
2339 episode.metadata.copyright = tags.get("copyright")
2340 episode.metadata.lyrics = tags.lyrics
2341 episode.metadata.description = tags.get("comment")
2342 explicit_tag = tags.get("itunesadvisory")
2343 if explicit_tag is not None:
2344 episode.metadata.explicit = explicit_tag == "1"
2345
2346 # handle (optional) chapters
2347 if tags.chapters:
2348 episode.metadata.chapters = [
2349 MediaItemChapter(
2350 position=chapter.chapter_id,
2351 name=chapter.title or f"Chapter {chapter.chapter_id}",
2352 start=chapter.position_start,
2353 end=chapter.position_end,
2354 )
2355 for chapter in tags.chapters
2356 ]
2357
2358 # try to fetch additional Podcast metadata from the folder
2359 assert isinstance(episode.podcast, Podcast)
2360 if images := await self._get_local_images(file_item.relative_parent_path):
2361 episode.podcast.metadata.images = images
2362 if metadata := await self._get_podcast_metadata(file_item.relative_parent_path):
2363 if title := metadata.get("title"):
2364 episode.podcast.name = title
2365 if sort_name := metadata.get("sorttitle"):
2366 episode.podcast.sort_name = sort_name
2367 if description := metadata.get("description"):
2368 episode.podcast.metadata.description = description
2369 if genres := metadata.get("genres"):
2370 episode.podcast.metadata.genres = set(genres)
2371 if publisher := metadata.get("publisher"):
2372 episode.podcast.publisher = publisher
2373 if image := metadata.get("imageURL"):
2374 episode.podcast.metadata.add_image(
2375 MediaItemImage(
2376 type=ImageType.THUMB,
2377 path=image,
2378 provider=self.instance_id,
2379 remotely_accessible=True,
2380 )
2381 )
2382 # copy (embedded) image from episode (or vice versa)
2383 if not episode.podcast.image and episode.image:
2384 episode.podcast.metadata.add_image(episode.image)
2385 elif not episode.image and episode.podcast.image:
2386 episode.metadata.add_image(episode.podcast.image)
2387 # ensure podcast has a default genre if none set
2388 if not episode.podcast.metadata.genres:
2389 episode.podcast.metadata.genres = {DEFAULT_AUDIOBOOK_PODCAST_GENRE}
2390
2391 # handle (optional) loudness measurement tag(s)
2392 if tags.track_loudness is not None:
2393 self.mass.create_task(
2394 self.mass.streams.audio_analysis.set_track_loudness(
2395 episode.item_id,
2396 self.instance_id,
2397 tags.track_loudness,
2398 tags.track_album_loudness,
2399 media_type=MediaType.PODCAST_EPISODE,
2400 )
2401 )
2402 return episode
2403
2404 async def _parse_sound_effect(self, file_item: FileSystemItem, tags: AudioTags) -> SoundEffect:
2405 """Parse full sound effect details from file tags."""
2406 sound_effect = SoundEffect(
2407 item_id=file_item.relative_path,
2408 provider=self.instance_id,
2409 name=tags.title,
2410 sort_name=tags.title_sort,
2411 duration=int(tags.duration or 0),
2412 provider_mappings={
2413 ProviderMapping(
2414 item_id=file_item.relative_path,
2415 provider_domain=self.domain,
2416 provider_instance=self.instance_id,
2417 audio_format=AudioFormat(
2418 content_type=ContentType.try_parse(file_item.ext or tags.format),
2419 codec_type=self._codec_type_from_tags(tags),
2420 sample_rate=tags.sample_rate,
2421 bit_depth=tags.bits_per_sample,
2422 channels=tags.channels,
2423 bit_rate=tags.bit_rate,
2424 ),
2425 details=file_item.checksum,
2426 in_library=True,
2427 )
2428 },
2429 )
2430 sound_effect.metadata.description = tags.get("comment")
2431 # handle embedded cover image
2432 if tags.has_cover_image:
2433 # we do not actually embed the image in the metadata because that would consume too
2434 # much space and bandwidth. Instead we set the filename as value so the image can
2435 # be retrieved later in realtime.
2436 sound_effect.metadata.add_image(
2437 MediaItemImage(
2438 type=ImageType.THUMB,
2439 path=file_item.relative_path,
2440 provider=self.instance_id,
2441 remotely_accessible=False,
2442 )
2443 )
2444 return sound_effect
2445
2446 async def _get_or_parse_sound_effect(self, file_item: FileSystemItem) -> SoundEffect:
2447 """Return the (cached) SoundEffect for the given file, parsing tags when needed."""
2448 cache_key = f"sound_effect.{file_item.relative_path}"
2449 cached_data: SoundEffect | None = await self.cache.get(
2450 cache_key,
2451 provider=self.instance_id,
2452 checksum=file_item.checksum,
2453 category=CACHE_CATEGORY_SOUND_EFFECTS,
2454 base_class=SoundEffect,
2455 )
2456 if cached_data is not None:
2457 return cached_data
2458 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2459 sound_effect = await self._parse_sound_effect(file_item, tags)
2460 await self.cache.set(
2461 cache_key,
2462 sound_effect.to_dict(),
2463 expiration=3600 * 24 * 365, # File timestamp checksum handles invalidation
2464 provider=self.instance_id,
2465 checksum=file_item.checksum,
2466 category=CACHE_CATEGORY_SOUND_EFFECTS,
2467 )
2468 return sound_effect
2469
2470 async def _parse_album(
2471 self,
2472 track_path: str,
2473 track_tags: AudioTags,
2474 track_created_at: int | None = None,
2475 representative_track: str | None = None,
2476 ) -> Album:
2477 """
2478 Parse Album metadata from Track tags.
2479
2480 :param track_path: Path to the track file.
2481 :param track_tags: Audio tags from the track.
2482 :param track_created_at: Creation timestamp of the track file (Unix epoch).
2483 :param representative_track: The path to register for metadata-file change detection,
2484 when it differs from `track_path` itself (a CUE sheet's tracks are parsed from their
2485 companion audio file, but that companion is not a synced item on its own, so the CUE
2486 sheet's own path is the one that must be re-queued when the album/artist folder's
2487 NFO or images change). Defaults to `track_path`.
2488 """
2489 assert track_tags.album
2490 representative_track = representative_track or track_path
2491 # work out if we have an album and/or disc folder
2492 # track_dir is the folder level where the tracks are located
2493 # this may be a separate disc folder (Disc 1, Disc 2 etc) underneath the album folder
2494 # or this is an album folder with the disc attached
2495 track_dir = os.path.dirname(track_path)
2496 album_dir = get_album_dir(track_dir, track_tags.album)
2497
2498 if album_dir and (
2499 cache := await self.cache.get(
2500 key=album_dir,
2501 provider=self.instance_id,
2502 category=CACHE_CATEGORY_ALBUM_INFO,
2503 base_class=Album,
2504 )
2505 ):
2506 return cache # type: ignore[no-any-return]
2507
2508 # album artist(s)
2509 album_artists: UniqueList[Artist | ItemMapping] = UniqueList()
2510 if track_tags.album_artists:
2511 resolved_album_artists = await self._resolve_artists_with_mbids(
2512 track_tags.album_artists,
2513 track_tags.musicbrainz_albumartistids,
2514 track_tags.album_artist_sort_names,
2515 log_label="ALBUMARTIST tag",
2516 )
2517 for name, mbid, sort_name in resolved_album_artists:
2518 artist = await self._parse_artist(
2519 name,
2520 album_dir=album_dir,
2521 sort_name=sort_name,
2522 mbid=mbid,
2523 representative_track=representative_track,
2524 )
2525 album_artists.append(artist)
2526 else:
2527 # album artist tag is missing, determine fallback
2528 fallback_action = self.config.get_value(CONF_ENTRY_MISSING_ALBUM_ARTIST.key)
2529 if fallback_action == "folder_name" and album_dir:
2530 possible_artist_folder = os.path.dirname(album_dir)
2531 self.logger.warning(
2532 "%s is missing ID3 tag [albumartist], using foldername %s as fallback",
2533 track_path,
2534 possible_artist_folder,
2535 )
2536 album_artist_str = Path(possible_artist_folder).name
2537 album_artists = UniqueList(
2538 [
2539 await self._parse_artist(
2540 name=album_artist_str,
2541 album_dir=album_dir,
2542 representative_track=representative_track,
2543 )
2544 ]
2545 )
2546 # fallback to track artists (if defined by user)
2547 elif fallback_action == "track_artist":
2548 self.logger.warning(
2549 "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
2550 track_path,
2551 )
2552 album_artists = UniqueList(
2553 [
2554 await self._parse_artist(
2555 name=track_artist_str,
2556 album_dir=album_dir,
2557 representative_track=representative_track,
2558 )
2559 for track_artist_str in track_tags.artists
2560 ]
2561 )
2562 # all other: fallback to various artists
2563 else:
2564 self.logger.warning(
2565 "%s is missing ID3 tag [albumartist], using %s as fallback",
2566 track_path,
2567 VARIOUS_ARTISTS_NAME,
2568 )
2569 album_artists = UniqueList(
2570 [
2571 await self._parse_artist(
2572 name=VARIOUS_ARTISTS_NAME,
2573 mbid=VARIOUS_ARTISTS_MBID,
2574 representative_track=representative_track,
2575 )
2576 ]
2577 )
2578
2579 if album_dir: # noqa: SIM108
2580 # prefer the path as id
2581 item_id = album_dir
2582 else:
2583 # create fake item_id based on artist + album
2584 item_id = album_artists[0].name + os.sep + track_tags.album
2585
2586 name, version = parse_title_and_version(track_tags.album)
2587 album = Album(
2588 item_id=item_id,
2589 provider=self.instance_id,
2590 name=name,
2591 version=version,
2592 sort_name=track_tags.album_sort,
2593 artists=album_artists,
2594 provider_mappings={
2595 ProviderMapping(
2596 item_id=item_id,
2597 provider_domain=self.domain,
2598 provider_instance=self.instance_id,
2599 url=album_dir,
2600 in_library=True,
2601 )
2602 },
2603 date_added=(
2604 datetime.fromtimestamp(track_created_at, tz=UTC) if track_created_at else None
2605 ),
2606 )
2607 if track_tags.barcode:
2608 album.external_ids.add((ExternalID.BARCODE, track_tags.barcode))
2609
2610 if album_mbid := clean_mbid(track_tags.musicbrainz_albumid, track_tags.filename):
2611 album.mbid = album_mbid
2612 if releasegroup_mbid := clean_mbid(
2613 track_tags.musicbrainz_releasegroupid, track_tags.filename
2614 ):
2615 album.add_external_id(ExternalID.MB_RELEASEGROUP, releasegroup_mbid)
2616 if track_tags.year:
2617 album.year = track_tags.year
2618 album.album_type = track_tags.album_type
2619
2620 # hunt for additional metadata and images in the folder structure
2621 if not album_dir:
2622 return album
2623
2624 for folder_path in (track_dir, album_dir):
2625 if not folder_path or not await self.exists(folder_path):
2626 continue
2627 nfo_file = os.path.join(folder_path, "album.nfo")
2628 if await self.exists(nfo_file):
2629 # resolve before reading: the registered token must reflect the version we
2630 # are about to parse, not whatever the file happens to be once parsing has
2631 # finished
2632 nfo_item = await self.resolve(nfo_file)
2633 # found NFO file with metadata
2634 # https://kodi.wiki/view/NFO_files/Artists
2635 try:
2636 data = (await self._read_file(nfo_file)).decode("utf-8")
2637 info = await asyncio.to_thread(xmltodict.parse, data)
2638 parse_album_nfo(album, info["album"], nfo_file)
2639 # only a successful parse counts as having read this NFO: registering on a
2640 # malformed file would advance its token and treat the bad edit as handled,
2641 # permanently masking it (until unrelated changes trigger a full reparse)
2642 await self._register_metadata_file(nfo_item, representative_track)
2643 except (ExpatError, KeyError) as err:
2644 self.logger.warning(
2645 "Failed to parse album NFO file %s: %s",
2646 nfo_file,
2647 str(err),
2648 )
2649
2650 # find local images
2651 if images := await self._get_local_images(
2652 folder_path, extra_thumb_names=("album",), representative_track=representative_track
2653 ):
2654 if album.metadata.images is None:
2655 album.metadata.images = UniqueList(images)
2656 else:
2657 album.metadata.images += images
2658
2659 await self.cache.set(
2660 key=album_dir,
2661 data=album.to_dict(),
2662 provider=self.instance_id,
2663 category=CACHE_CATEGORY_ALBUM_INFO,
2664 expiration=120,
2665 )
2666 return album
2667
2668 async def _get_local_images(
2669 self,
2670 folder: str,
2671 extra_thumb_names: tuple[str, ...] | None = None,
2672 representative_track: str | None = None,
2673 ) -> UniqueList[MediaItemImage]:
2674 """
2675 Return local images found in a given folderpath.
2676
2677 :param folder: The folder to look for images in.
2678 :param extra_thumb_names: Extra image stems (besides folder/cover) treated as a thumbnail.
2679 :param representative_track: The track whose reparse rebuilds an album/artist that reads
2680 this folder, so each recognized image found here can register itself for change
2681 detection. Omit for folders unrelated to that (e.g. podcast folders).
2682 """
2683 if (
2684 cache := await self.cache.get(
2685 key=folder,
2686 provider=self.instance_id,
2687 category=CACHE_CATEGORY_FOLDER_IMAGES,
2688 base_class=MediaItemImage,
2689 )
2690 ) is not None:
2691 return UniqueList(cache)
2692 if extra_thumb_names is None:
2693 extra_thumb_names = ()
2694 images: UniqueList[MediaItemImage] = UniqueList()
2695 folder_files = await self._scandir(folder)
2696 for item in folder_files:
2697 if "." not in item.relative_path or item.is_dir or not item.ext:
2698 continue
2699 if item.ext.lower() not in IMAGE_EXTENSIONS:
2700 continue
2701 # try match on filename = one of our imagetypes
2702 if item.name.lower() in ImageType:
2703 images.append(
2704 MediaItemImage(
2705 type=ImageType(item.name),
2706 path=item.relative_path,
2707 provider=self.instance_id,
2708 remotely_accessible=False,
2709 )
2710 )
2711 await self._register_metadata_file(item, representative_track)
2712
2713 # try alternative names for thumbs
2714 extra_thumb_names = ("folder", "cover", *extra_thumb_names)
2715 for item in folder_files:
2716 if "." not in item.relative_path or item.is_dir or not item.ext:
2717 continue
2718 if item.ext.lower() not in IMAGE_EXTENSIONS:
2719 continue
2720 if item.name.lower() not in extra_thumb_names:
2721 continue
2722 images.append(
2723 MediaItemImage(
2724 type=ImageType.THUMB,
2725 path=item.relative_path,
2726 provider=self.instance_id,
2727 remotely_accessible=False,
2728 )
2729 )
2730 await self._register_metadata_file(item, representative_track)
2731
2732 await self.cache.set(
2733 key=folder,
2734 data=[img.to_dict() for img in images],
2735 provider=self.instance_id,
2736 category=CACHE_CATEGORY_FOLDER_IMAGES,
2737 expiration=120,
2738 )
2739 return images
2740
2741 async def _get_stream_details_for_track(self, item_id: str) -> StreamDetails:
2742 """Return the streamdetails for a track/song."""
2743 if parse_cue_track_id(item_id) is not None:
2744 return await self._cue.get_stream_details(item_id)
2745
2746 library_item = await self.mass.music.tracks.get_library_item_by_prov_id(
2747 item_id, self.instance_id
2748 )
2749 if library_item is None:
2750 # this could be a file that has just been added, try parsing it
2751 file_item = await self.resolve(item_id)
2752 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2753 if not (library_item := await self._parse_track(file_item, tags)):
2754 msg = f"Item not found: {item_id}"
2755 raise MediaNotFoundError(msg)
2756
2757 prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
2758 file_item = await self.resolve(item_id)
2759
2760 return StreamDetails(
2761 provider=self.instance_id,
2762 item_id=item_id,
2763 audio_format=prov_mapping.audio_format,
2764 media_type=MediaType.TRACK,
2765 stream_type=StreamType.LOCAL_FILE,
2766 duration=library_item.duration,
2767 size=file_item.file_size,
2768 data=file_item,
2769 path=file_item.absolute_path,
2770 can_seek=True,
2771 allow_seek=True,
2772 )
2773
2774 async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamDetails:
2775 """Return the streamdetails for a podcast episode."""
2776 # podcasts episodes are never stored in the library so we need to parse the file
2777 file_item = await self.resolve(item_id)
2778 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2779 return StreamDetails(
2780 provider=self.instance_id,
2781 item_id=item_id,
2782 audio_format=AudioFormat(
2783 content_type=ContentType.try_parse(file_item.ext or tags.format),
2784 codec_type=self._codec_type_from_tags(tags),
2785 sample_rate=tags.sample_rate,
2786 bit_depth=tags.bits_per_sample,
2787 channels=tags.channels,
2788 bit_rate=tags.bit_rate,
2789 ),
2790 media_type=MediaType.PODCAST_EPISODE,
2791 stream_type=StreamType.LOCAL_FILE,
2792 duration=try_parse_int(tags.duration or 0),
2793 size=file_item.file_size,
2794 data=file_item,
2795 path=file_item.absolute_path,
2796 allow_seek=True,
2797 can_seek=True,
2798 )
2799
2800 async def _get_stream_details_for_sound_effect(self, item_id: str) -> StreamDetails:
2801 """Return the streamdetails for a sound effect."""
2802 # sound effects are never stored in the library so we parse the file,
2803 # served from cache unless the file changed on disk
2804 file_item = await self.resolve(item_id)
2805 sound_effect = await self._get_or_parse_sound_effect(file_item)
2806 prov_mapping = next(x for x in sound_effect.provider_mappings if x.item_id == item_id)
2807 return StreamDetails(
2808 provider=self.instance_id,
2809 item_id=item_id,
2810 audio_format=prov_mapping.audio_format,
2811 media_type=MediaType.SOUND_EFFECT,
2812 stream_type=StreamType.LOCAL_FILE,
2813 duration=sound_effect.duration,
2814 size=file_item.file_size,
2815 data=file_item,
2816 path=file_item.absolute_path,
2817 allow_seek=True,
2818 can_seek=True,
2819 )
2820
2821 async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails:
2822 """Return the streamdetails for an audiobook."""
2823 library_item = await self.mass.music.audiobooks.get_library_item_by_prov_id(
2824 item_id, self.instance_id
2825 )
2826 if library_item is None:
2827 # this could be a file that has just been added, try parsing it
2828 file_item = await self.resolve(item_id)
2829 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2830 if not (library_item := await self._parse_audiobook(file_item, tags)):
2831 msg = f"Item not found: {item_id}"
2832 raise MediaNotFoundError(msg)
2833
2834 prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
2835 file_item = await self.resolve(item_id)
2836 duration = library_item.duration
2837 file_based_chapters: list[tuple[str, float]] | None = await self.cache.get(
2838 key=file_item.relative_path,
2839 provider=self.instance_id,
2840 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
2841 )
2842 if file_based_chapters is None:
2843 # no cache available for this audiobook, we need to parse the chapters
2844 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2845 await self._parse_audiobook(file_item, tags)
2846 file_based_chapters = await self.cache.get(
2847 key=file_item.relative_path,
2848 provider=self.instance_id,
2849 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
2850 )
2851
2852 if file_based_chapters:
2853 # this is a multi-file audiobook
2854 return StreamDetails(
2855 provider=self.instance_id,
2856 item_id=item_id,
2857 audio_format=prov_mapping.audio_format,
2858 media_type=MediaType.AUDIOBOOK,
2859 stream_type=StreamType.LOCAL_FILE,
2860 duration=duration,
2861 path=[
2862 MultiPartPath(
2863 path=self._get_chapter_path(chapter_path),
2864 duration=chapter_duration,
2865 )
2866 for chapter_path, chapter_duration in file_based_chapters
2867 ],
2868 allow_seek=True,
2869 )
2870
2871 # regular single-file streaming, simply let ffmpeg deal with the file directly
2872 return StreamDetails(
2873 provider=self.instance_id,
2874 item_id=item_id,
2875 audio_format=prov_mapping.audio_format,
2876 media_type=MediaType.AUDIOBOOK,
2877 stream_type=StreamType.LOCAL_FILE,
2878 duration=library_item.duration,
2879 size=file_item.file_size,
2880 data=file_item,
2881 path=file_item.absolute_path,
2882 allow_seek=True,
2883 can_seek=True,
2884 )
2885
2886 def _get_chapter_path(self, relative_path: str) -> str:
2887 """Return absolute path for a chapter file. Override for network storage."""
2888 return self.get_absolute_path(relative_path)
2889
2890 async def _get_chapters_for_audiobook(
2891 self, audiobook_file_item: FileSystemItem, tags: AudioTags
2892 ) -> tuple[int, list[MediaItemChapter]]:
2893 """
2894 Return chapters for an audiobook.
2895
2896 Chapter sources in order of preference:
2897 1. Multiple files with track tags - sorted by track number
2898 2. Single file with embedded chapters - use embedded chapter markers
2899 3. Multiple files without track tags - sorted alphabetically (fallback)
2900 """
2901 chapters: list[MediaItemChapter] = []
2902 all_chapter_files: list[tuple[str, float]] = []
2903 total_duration = 0.0
2904
2905 # Scan folder for chapter files, separating tagged from untagged
2906 chapter_file_items: list[tuple[FileSystemItem, AudioTags]] = []
2907 untagged_file_items: list[tuple[FileSystemItem, AudioTags]] = []
2908
2909 items = await self._scandir(audiobook_file_item.relative_parent_path)
2910 # Sort by filename for consistent alphabetical ordering
2911 items.sort(key=lambda x: x.filename.lower())
2912
2913 for item in items:
2914 if "." not in item.relative_path or item.is_dir:
2915 continue
2916 if item.ext not in AUDIOBOOK_EXTENSIONS:
2917 continue
2918 item_tags = await async_parse_tags(item.absolute_path, item.file_size)
2919 if not (tags.album == item_tags.album or (item_tags.tags.get("title") is None)):
2920 continue
2921 if item_tags.tags.get("track") is None:
2922 untagged_file_items.append((item, item_tags))
2923 else:
2924 chapter_file_items.append((item, item_tags))
2925
2926 # Determine chapter source
2927 use_embedded = False
2928 use_alphabetical = False
2929
2930 if len(chapter_file_items) > 1:
2931 chapter_file_items.sort(key=lambda x: (x[1].disc or 0, x[1].track or 0))
2932 elif len(chapter_file_items) <= 1 and tags.chapters:
2933 use_embedded = True
2934 elif len(untagged_file_items) > 1:
2935 use_alphabetical = True
2936 chapter_file_items = untagged_file_items
2937 self.logger.info(
2938 "Audiobook files have no track tags, using alphabetical order: %s",
2939 tags.album,
2940 )
2941
2942 if use_embedded:
2943 chapters = [
2944 MediaItemChapter(
2945 position=chapter.chapter_id,
2946 name=chapter.title or f"Chapter {chapter.chapter_id}",
2947 start=chapter.position_start,
2948 end=chapter.position_end,
2949 )
2950 for chapter in tags.chapters
2951 ]
2952 total_duration = try_parse_int(tags.duration) or 0
2953 self.logger.log(
2954 VERBOSE_LOG_LEVEL,
2955 "Audiobook '%s': %d embedded chapters, duration=%d",
2956 tags.album,
2957 len(chapters),
2958 int(total_duration),
2959 )
2960 else:
2961 for position, (chapter_item, chapter_tags) in enumerate(chapter_file_items, start=1):
2962 if chapter_tags.duration is None:
2963 self.logger.warning(
2964 "Chapter file has no duration, skipping: %s",
2965 chapter_item.relative_path,
2966 )
2967 continue
2968 self.logger.debug("Chapter filename: %s", chapter_item.relative_path)
2969 chapters.append(
2970 MediaItemChapter(
2971 position=position,
2972 name=chapter_tags.title,
2973 start=total_duration,
2974 end=total_duration + chapter_tags.duration,
2975 )
2976 )
2977 all_chapter_files.append(
2978 (
2979 chapter_item.relative_path,
2980 chapter_tags.duration,
2981 )
2982 )
2983 total_duration += chapter_tags.duration
2984 sort_method = "alphabetical" if use_alphabetical else "track"
2985 self.logger.log(
2986 VERBOSE_LOG_LEVEL,
2987 "Audiobook '%s': %d files (%s order), duration=%d",
2988 tags.album,
2989 len(chapters),
2990 sort_method,
2991 int(total_duration),
2992 )
2993 # Cache chapter files for streaming
2994 await self.cache.set(
2995 key=audiobook_file_item.relative_path,
2996 data=all_chapter_files,
2997 provider=self.instance_id,
2998 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
2999 )
3000 return int(total_duration), chapters
3001
3002 async def _get_podcast_metadata(self, podcast_folder: str) -> dict[str, Any]:
3003 """Return metadata for a podcast."""
3004 if (
3005 cache := await self.cache.get(
3006 key=podcast_folder,
3007 provider=self.instance_id,
3008 category=CACHE_CATEGORY_PODCAST_METADATA,
3009 )
3010 ) is not None:
3011 return cast("dict[str, Any]", cache)
3012 data: dict[str, Any] = {}
3013 metadata_file = os.path.join(podcast_folder, "metadata.json")
3014 if await self.exists(metadata_file):
3015 # found json file with metadata
3016 raw = await self._read_file(metadata_file)
3017 data.update(json_loads(raw.decode("utf-8")))
3018 await self.cache.set(
3019 key=podcast_folder,
3020 data=data,
3021 provider=self.instance_id,
3022 category=CACHE_CATEGORY_PODCAST_METADATA,
3023 )
3024 return data
3025
3026 async def _scandir(self, path: str) -> list[FileSystemItem]:
3027 """List directory contents in natural sort order."""
3028 # raw scandir order depends on the underlying filesystem (e.g. hash order
3029 # on ext4) so sort to make browse and folder playback order deterministic
3030 abs_path = self.get_absolute_path(path)
3031 return await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True)
3032
3033 async def _read_file(self, path: str) -> bytes:
3034 """Read file contents. Override for network storage."""
3035 async with aiofiles.open(self.get_absolute_path(path), mode="rb") as f:
3036 return cast("bytes", await f.read())
3037