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