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