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