/
/
/
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 return file_item.absolute_path
1012
1013 async def check_write_access(self) -> None:
1014 """Perform check if we have write access."""
1015 # verify write access to determine we have playlist create/edit support
1016 # overwrite with provider specific implementation if needed
1017 temp_file_name = self.get_absolute_path(f"{shortuuid.random(8)}.txt")
1018 try:
1019 async with aiofiles.open(temp_file_name, "w") as _file:
1020 await _file.write("test")
1021 await asyncio.to_thread(os.remove, temp_file_name)
1022 self.write_access = True
1023 except Exception as err:
1024 self.logger.debug("Write access disabled: %s", str(err))
1025
1026 async def resolve(self, file_path: str) -> FileSystemItem:
1027 """Resolve (absolute or relative) path to FileSystemItem."""
1028 absolute_path = self.get_absolute_path(file_path)
1029
1030 def _create_item() -> FileSystemItem:
1031 if os.path.isdir(absolute_path):
1032 return FileSystemItem(
1033 filename=Path(file_path).name,
1034 relative_path=get_relative_path(self.base_path, file_path),
1035 absolute_path=absolute_path,
1036 is_dir=True,
1037 )
1038 stat_info = Path(absolute_path).stat(follow_symlinks=False)
1039 return FileSystemItem(
1040 filename=Path(file_path).name,
1041 relative_path=get_relative_path(self.base_path, file_path),
1042 absolute_path=absolute_path,
1043 is_dir=False,
1044 checksum=str(int(stat_info.st_mtime)),
1045 file_size=stat_info.st_size,
1046 )
1047
1048 return await asyncio.to_thread(_create_item)
1049
1050 async def exists(self, file_path: str) -> bool:
1051 """Return bool is this FileSystem musicprovider has given file/dir."""
1052 if not file_path:
1053 return False
1054 try:
1055 abs_path = self.get_absolute_path(file_path)
1056 except MediaNotFoundError:
1057 # a path that escapes the base directory simply does not exist here
1058 return False
1059 return bool(await exists(abs_path))
1060
1061 def get_absolute_path(self, file_path: str) -> str:
1062 """Return absolute path for given file path."""
1063 return get_absolute_path(self.base_path, file_path)
1064
1065 async def _enumerate_files_for_sync(
1066 self,
1067 *,
1068 file_checksums: dict[str, str],
1069 cue_file_checksums: dict[str, str],
1070 cur_filenames: set[str],
1071 items_to_process: list[tuple[FileSystemItem, str | None]],
1072 unchanged_cue_items: list[FileSystemItem],
1073 cue_stems: set[str],
1074 scan_errors: ScanErrors,
1075 ) -> None:
1076 """
1077 Walk every supported file under the provider root and populate the sync buckets.
1078
1079 Override in subclasses that cannot use a local ``os.scandir`` walk.
1080 Implementations must route each discovered file through
1081 :meth:`_classify_scan_item`, report every unreadable directory to
1082 ``scan_errors`` and stop the walk once it reports ``aborted``.
1083
1084 :param file_checksums: Previously stored checksum per provider item id.
1085 :param cue_file_checksums: Previously stored checksum keyed by CUE relative_path.
1086 :param cur_filenames: Receives the ids/paths present in this scan.
1087 :param items_to_process: Receives changed or new items to process.
1088 :param unchanged_cue_items: Receives CUE sheets whose checksum matches.
1089 :param cue_stems: Receives absolute paths (minus extension) of CUE sheets.
1090 :param scan_errors: Receives the errors raised while walking the tree.
1091 """
1092 ignore_album_playlists = self.media_content_type == "music" and bool(
1093 self.config.get_value(CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS.key)
1094 )
1095
1096 def _walk() -> None:
1097 for scanned, item in enumerate(
1098 recursive_iter(
1099 self.base_path,
1100 self.base_path,
1101 SUPPORTED_EXTENSIONS,
1102 self.logger,
1103 scan_errors=scan_errors,
1104 ),
1105 start=1,
1106 ):
1107 if scanned % 500 == 0:
1108 update_current_task_progress_text(f"Scanning files: {scanned} found")
1109 self._classify_scan_item(
1110 item,
1111 file_checksums=file_checksums,
1112 cue_file_checksums=cue_file_checksums,
1113 cur_filenames=cur_filenames,
1114 items_to_process=items_to_process,
1115 unchanged_cue_items=unchanged_cue_items,
1116 cue_stems=cue_stems,
1117 ignore_album_playlists=ignore_album_playlists,
1118 )
1119
1120 await asyncio.to_thread(_walk)
1121
1122 def _classify_scan_item(
1123 self,
1124 item: FileSystemItem,
1125 *,
1126 file_checksums: dict[str, str],
1127 cue_file_checksums: dict[str, str],
1128 cur_filenames: set[str],
1129 items_to_process: list[tuple[FileSystemItem, str | None]],
1130 unchanged_cue_items: list[FileSystemItem],
1131 cue_stems: set[str],
1132 ignore_album_playlists: bool,
1133 ) -> None:
1134 """
1135 Route a single scanned file into the correct sync bucket.
1136
1137 :param item: The file to classify.
1138 :param file_checksums: Previously stored checksum per provider item id.
1139 :param cue_file_checksums: Previously stored checksum keyed by CUE relative_path.
1140 :param cur_filenames: Receives the ids/paths present in this scan.
1141 :param items_to_process: Receives changed or new items to process.
1142 :param unchanged_cue_items: Receives CUE sheets whose checksum matches.
1143 :param cue_stems: Receives absolute paths (minus extension) of CUE sheets.
1144 :param ignore_album_playlists: When True, skip playlists nested inside
1145 album directories.
1146 """
1147 # a file this provider never imports gets no mapping, so it would flag as
1148 # changed on every sync; it is still on disk, so record it as present
1149 if not self._is_imported_file(item):
1150 cur_filenames.add(item.relative_path)
1151 return
1152 # skip playlists in album directories if configured
1153 if (
1154 item.ext in PLAYLIST_EXTENSIONS
1155 and ignore_album_playlists
1156 and len(item.relative_path.split("/")) > 2
1157 ):
1158 return
1159 is_cue = item.ext in CUE_EXTENSIONS and self.media_content_type == "music"
1160 if is_cue:
1161 cue_stems.add(item.absolute_path.rsplit(".", 1)[0])
1162 prev_checksum = cue_file_checksums.get(item.relative_path)
1163 else:
1164 prev_checksum = file_checksums.get(item.relative_path)
1165 if item.checksum == prev_checksum:
1166 # unchanged, just record it as still present
1167 cur_filenames.add(item.relative_path)
1168 if is_cue:
1169 unchanged_cue_items.append(item)
1170 else:
1171 items_to_process.append((item, prev_checksum))
1172
1173 def _is_imported_file(self, item: FileSystemItem) -> bool:
1174 """Return True when this provider imports the given file into the library."""
1175 if self.media_content_type == "music":
1176 if item.ext in CUE_EXTENSIONS:
1177 return True
1178 if item.ext in TRACK_EXTENSIONS:
1179 return self._sync_tracks
1180 if item.ext in PLAYLIST_EXTENSIONS:
1181 return self._sync_playlists
1182 return False
1183 if self.media_content_type == "audiobooks":
1184 return item.ext in AUDIOBOOK_EXTENSIONS
1185 if self.media_content_type == "podcasts":
1186 return item.ext in PODCAST_EPISODE_EXTENSIONS
1187 return False
1188
1189 def _set_available(self, available: bool) -> None:
1190 """Update the provider availability and notify listeners on change."""
1191 if self.available == available:
1192 return
1193 self.available = available
1194 self.mass.signal_event(EventType.PROVIDERS_UPDATED, data=self.mass.get_providers())
1195
1196 async def _process_item_async(
1197 self,
1198 item: FileSystemItem,
1199 prev_checksum: str | None,
1200 cur_filenames: set[str] | None = None,
1201 cue_stems: set[str] | None = None,
1202 prev_filenames: set[str] | None = None,
1203 ) -> bool:
1204 """
1205 Process a single item asynchronously.
1206
1207 :param item: The filesystem item to process.
1208 :param prev_checksum: Previous checksum from the database, or None for new items.
1209 :param cur_filenames: Set of current filenames being tracked (for CUE track IDs).
1210 :param cue_stems: Absolute paths (without extension) of CUE sheets in this scan,
1211 used to detect companion-CUE audio files without a filesystem stat.
1212 :param prev_filenames: The ids/paths the previous scan found, used to keep the
1213 ids of a CUE sheet that fails to parse.
1214 """
1215 try:
1216 self.logger.log(VERBOSE_LOG_LEVEL, "Processing: %s", item.relative_path)
1217
1218 if prev_checksum is not None:
1219 # the file changed on disk: drop cached artwork derived from it
1220 # (thumbnails, source bytes, palette) so re-read embedded art is
1221 # served fresh, for both reference forms of the image path
1222 await self.mass.metadata.invalidate_image_cache(
1223 self.instance_id, item.relative_path
1224 )
1225 await self.mass.metadata.invalidate_image_cache(
1226 self.instance_id, self._versioned_image_path(item.relative_path, prev_checksum)
1227 )
1228
1229 if item.ext in CUE_EXTENSIONS and self.media_content_type == "music":
1230 tracks = await self._cue.parse_tracks(item)
1231 for track in tracks:
1232 track.favorite = False
1233 await self.mass.music.tracks.add_item_to_library(
1234 track, overwrite_existing=prev_checksum is not None
1235 )
1236 if cur_filenames is not None:
1237 cur_filenames.add(track.item_id)
1238 return True
1239
1240 if item.ext in TRACK_EXTENSIONS and self.media_content_type == "music":
1241 if not self._sync_tracks:
1242 return False
1243 # skip audio files that have a companion CUE sheet
1244 if cue_stems is not None and item.absolute_path.rsplit(".", 1)[0] in cue_stems:
1245 return False
1246 tags = await async_parse_tags(item.absolute_path, item.file_size)
1247 track = await self._parse_track(item, tags)
1248 track.favorite = False # TODO: implement favorite status based on rating ?
1249 await self.mass.music.tracks.add_item_to_library(
1250 track, overwrite_existing=prev_checksum is not None
1251 )
1252 return True
1253
1254 if item.ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
1255 tags = await async_parse_tags(item.absolute_path, item.file_size)
1256 try:
1257 audiobook = await self._parse_audiobook(item, tags)
1258 except IsChapterFile:
1259 return True
1260 await self.mass.music.audiobooks.add_item_to_library(
1261 audiobook, overwrite_existing=prev_checksum is not None
1262 )
1263 return True
1264
1265 if item.ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
1266 tags = await async_parse_tags(item.absolute_path, item.file_size)
1267 episode = await self._parse_podcast_episode(item, tags)
1268 assert isinstance(episode.podcast, Podcast)
1269 await self.mass.music.podcasts.add_item_to_library(
1270 episode.podcast, overwrite_existing=prev_checksum is not None
1271 )
1272 return True
1273
1274 if item.ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
1275 if not self._sync_playlists:
1276 return False
1277 playlist = await self.get_playlist(item.relative_path)
1278 await self.mass.music.playlists.add_item_to_library(
1279 playlist, overwrite_existing=prev_checksum is not None
1280 )
1281 return True
1282
1283 except Exception as err:
1284 # we don't want the whole sync to crash on one file so we catch all exceptions here
1285 self.logger.error(
1286 "Error processing %s - %s",
1287 item.relative_path,
1288 str(err),
1289 exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
1290 )
1291 report_current_task_failure(f"Failed to process {item.relative_path}: {err}")
1292 # the file is still on the storage, so keep it in the scan result:
1293 # leaving it out makes the deletion step treat it as removed
1294 self._keep_failed_item(item, cur_filenames, prev_filenames)
1295 return False
1296
1297 def _keep_failed_item(
1298 self,
1299 item: FileSystemItem,
1300 cur_filenames: set[str] | None,
1301 prev_filenames: set[str] | None,
1302 ) -> None:
1303 """
1304 Keep an item that could not be processed in the scan result.
1305
1306 :param item: The item that failed to process.
1307 :param cur_filenames: Receives the ids/paths present in this scan.
1308 :param prev_filenames: The ids/paths the previous scan found.
1309 """
1310 if cur_filenames is None:
1311 return
1312 cur_filenames.add(item.relative_path)
1313 if (
1314 item.ext not in CUE_EXTENSIONS
1315 or self.media_content_type != "music"
1316 or not prev_filenames
1317 ):
1318 return
1319 # a CUE sheet stands in for one id per track it describes and those cannot be
1320 # rebuilt without parsing it, so carry over the ids of the previous scan
1321 cur_filenames.update(
1322 item_id
1323 for item_id in prev_filenames
1324 if (parsed := parse_cue_track_id(item_id)) and parsed[0] == item.relative_path
1325 )
1326
1327 async def _process_orphaned_albums_and_artists(self) -> None:
1328 """Process deletion of orphaned albums and artists."""
1329 assert self.mass.music.database
1330 # Remove albums without any tracks
1331 query = (
1332 f"SELECT item_id FROM {DB_TABLE_ALBUMS} "
1333 f"WHERE item_id not in ( SELECT album_id from {DB_TABLE_ALBUM_TRACKS}) "
1334 f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
1335 f"WHERE provider_instance = '{self.instance_id}' and media_type = 'album' )"
1336 )
1337 for db_row in await self.mass.music.database.get_rows_from_query(
1338 query,
1339 limit=100000,
1340 ):
1341 await self.mass.music.albums.remove_item_from_library(db_row["item_id"])
1342
1343 # Remove artists without any tracks or albums
1344 query = (
1345 f"SELECT item_id FROM {DB_TABLE_ARTISTS} "
1346 f"WHERE item_id not in "
1347 f"( select artist_id from {DB_TABLE_TRACK_ARTISTS} "
1348 f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} ) "
1349 f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
1350 f"WHERE provider_instance = '{self.instance_id}' and media_type = 'artist' )"
1351 )
1352 for db_row in await self.mass.music.database.get_rows_from_query(
1353 query,
1354 limit=100000,
1355 ):
1356 await self.mass.music.artists.remove_item_from_library(db_row["item_id"])
1357
1358 async def _process_deletions(self, deleted_files: set[str]) -> None:
1359 """Process all deletions."""
1360 # process deleted tracks/playlists
1361 album_ids = set()
1362 artist_ids = set()
1363 for file_path in deleted_files:
1364 if parse_cue_track_id(file_path) is not None and self.media_content_type == "music":
1365 controller = self.mass.music.get_controller(MediaType.TRACK)
1366 elif "." not in file_path:
1367 continue
1368 else:
1369 _, ext = file_path.rsplit(".", 1)
1370 if ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
1371 controller = self.mass.music.get_controller(MediaType.PODCAST_EPISODE)
1372 elif ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
1373 controller = self.mass.music.get_controller(MediaType.AUDIOBOOK)
1374 elif ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
1375 controller = self.mass.music.get_controller(MediaType.PLAYLIST)
1376 elif ext in TRACK_EXTENSIONS and self.media_content_type == "music":
1377 controller = self.mass.music.get_controller(MediaType.TRACK)
1378 else:
1379 # unsupported file extension?
1380 continue
1381
1382 if library_item := await controller.get_library_item_by_prov_id(
1383 file_path, self.instance_id
1384 ):
1385 if is_track(library_item):
1386 if library_item.album:
1387 album_ids.add(library_item.album.item_id)
1388 # need to fetch the library album to resolve the itemmapping
1389 db_album = await self.mass.music.albums.get_library_item(
1390 library_item.album.item_id
1391 )
1392 for artist in db_album.artists:
1393 artist_ids.add(artist.item_id)
1394 for artist in library_item.artists:
1395 artist_ids.add(artist.item_id)
1396 await controller.remove_item_from_library(library_item.item_id)
1397 # check if any albums need to be cleaned up
1398 for album_id in album_ids:
1399 if not await self.mass.music.albums.tracks(album_id, "library"):
1400 await self.mass.music.albums.remove_item_from_library(album_id)
1401 # check if any artists need to be cleaned up
1402 for artist_id in artist_ids:
1403 artist_albums = await self.mass.music.artists.albums(artist_id, "library")
1404 artist_tracks = await self.mass.music.artists.tracks(artist_id, "library")
1405 if not (artist_albums or artist_tracks):
1406 await self.mass.music.artists.remove_item_from_library(artist_id)
1407
1408 async def _get_playlist_local_image(self, file_item: FileSystemItem) -> MediaItemImage | None:
1409 """Return a local image alongside the playlist file (matching basename) if any."""
1410 cache_key = f"playlist_image.{file_item.relative_path}"
1411 cached = await self.cache.get(
1412 key=cache_key,
1413 provider=self.instance_id,
1414 category=CACHE_CATEGORY_FOLDER_IMAGES,
1415 base_class=MediaItemImage,
1416 )
1417 if cached is not None:
1418 return cached[0] if cached else None
1419 try:
1420 folder_files = await self._scandir(file_item.relative_parent_path)
1421 except OSError, MusicAssistantError:
1422 return None
1423 target = file_item.name.lower()
1424 result: MediaItemImage | None = None
1425 for item in folder_files:
1426 if item.is_dir or not item.ext:
1427 continue
1428 if item.ext.lower() not in IMAGE_EXTENSIONS:
1429 continue
1430 if item.name.lower() != target:
1431 continue
1432 result = MediaItemImage(
1433 type=ImageType.THUMB,
1434 path=item.relative_path,
1435 provider=self.instance_id,
1436 remotely_accessible=False,
1437 )
1438 break
1439 await self.cache.set(
1440 key=cache_key,
1441 data=[result.to_dict()] if result is not None else [],
1442 provider=self.instance_id,
1443 category=CACHE_CATEGORY_FOLDER_IMAGES,
1444 expiration=120,
1445 )
1446 return result
1447
1448 async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None:
1449 """Try to parse a track from a playlist line."""
1450 try:
1451 line = line.replace("file://", "").strip()
1452 # try to resolve the filename (both normal and url decoded):
1453 # - relative to the playlist folder (normpath resolves parent .. references)
1454 # - as-is: an absolute path, or relative to our base path
1455 # candidates stay relative so subclasses with virtual paths (cloud,
1456 # webdav) resolve them too, instead of leaking the server CWD
1457 for _line in (line, urllib.parse.unquote(line)):
1458 if playlist_path:
1459 normalized = posixpath.normpath(f"{playlist_path}/{_line}")
1460 with contextlib.suppress(FileNotFoundError, MediaNotFoundError):
1461 file_item = await self.resolve(normalized)
1462 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1463 return await self._parse_track(file_item, tags)
1464 with contextlib.suppress(FileNotFoundError, MediaNotFoundError):
1465 file_item = await self.resolve(_line)
1466 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1467 return await self._parse_track(file_item, tags)
1468 # all attempts failed
1469 raise MediaNotFoundError("Invalid path/uri")
1470
1471 except MusicAssistantError as err:
1472 self.logger.warning("Could not parse %s to track: %s", line, str(err))
1473
1474 return None
1475
1476 @staticmethod
1477 def _versioned_image_path(relative_path: str, checksum: str | None) -> str:
1478 """Append the file checksum so the image cache busts when the file is replaced."""
1479 if checksum:
1480 return f"{relative_path}?cs={checksum}"
1481 return relative_path
1482
1483 async def _parse_track(
1484 self, file_item: FileSystemItem, tags: AudioTags, full_album_metadata: bool = False
1485 ) -> Track:
1486 """Parse full track details from file tags."""
1487 # ruff: noqa: PLR0915
1488 name, version = parse_title_and_version(tags.title, tags.version)
1489 track = Track(
1490 item_id=file_item.relative_path,
1491 provider=self.instance_id,
1492 name=name,
1493 sort_name=tags.title_sort,
1494 version=version,
1495 provider_mappings={
1496 ProviderMapping(
1497 item_id=file_item.relative_path,
1498 provider_domain=self.domain,
1499 provider_instance=self.instance_id,
1500 audio_format=AudioFormat(
1501 content_type=ContentType.try_parse(file_item.ext or tags.format),
1502 sample_rate=tags.sample_rate,
1503 bit_depth=tags.bits_per_sample,
1504 channels=tags.channels,
1505 bit_rate=tags.bit_rate,
1506 ),
1507 details=file_item.checksum,
1508 in_library=True,
1509 )
1510 },
1511 disc_number=tags.disc or 0,
1512 track_number=tags.track or 0,
1513 date_added=(
1514 datetime.fromtimestamp(file_item.created_at, tz=UTC)
1515 if file_item.created_at
1516 else None
1517 ),
1518 )
1519
1520 if isrc_tags := tags.isrc:
1521 for isrsc in isrc_tags:
1522 track.external_ids.add((ExternalID.ISRC, isrsc))
1523
1524 if acoustid := tags.get("acoustid"):
1525 track.external_ids.add((ExternalID.ACOUSTID, acoustid))
1526
1527 # album
1528 album = track.album = (
1529 await self._parse_album(
1530 track_path=file_item.relative_path,
1531 track_tags=tags,
1532 track_created_at=file_item.created_at,
1533 )
1534 if tags.album
1535 else None
1536 )
1537
1538 # track artist(s)
1539 resolved_track_artists = await self._resolve_artists_with_mbids(
1540 tags.artists,
1541 tags.musicbrainz_artistids,
1542 tags.artist_sort_names,
1543 log_label="ARTISTS tag",
1544 )
1545 for name, mbid, sort_name in resolved_track_artists:
1546 # prefer the existing album artist object when it's the same artist
1547 if album_artist_match := self._match_album_artist(album, name, mbid):
1548 track.artists.append(album_artist_match)
1549 continue
1550 artist = await self._parse_artist(name, sort_name=sort_name, mbid=mbid)
1551 track.artists.append(artist)
1552
1553 # handle embedded cover image
1554 if tags.has_cover_image:
1555 # we do not actually embed the image in the metadata because that would consume too
1556 # much space and bandwidth. Instead we set the filename as value so the image can
1557 # be retrieved later in realtime.
1558 track.metadata.images = UniqueList(
1559 [
1560 MediaItemImage(
1561 type=ImageType.THUMB,
1562 path=file_item.relative_path,
1563 provider=self.instance_id,
1564 remotely_accessible=False,
1565 )
1566 ]
1567 )
1568
1569 # copy (embedded) album image from track (if the album itself doesn't have an image)
1570 if album and not album.image and track.image:
1571 album.metadata.images = UniqueList([track.image])
1572
1573 # parse other info
1574 track.duration = int(tags.duration or 0)
1575 track.metadata.genres = set(tags.genres)
1576 if tags.disc:
1577 track.disc_number = tags.disc
1578 if tags.track:
1579 track.track_number = tags.track
1580 track.metadata.copyright = tags.get("copyright")
1581 track.metadata.lyrics = tags.lyrics
1582 track.metadata.grouping = tags.get("grouping")
1583 track.metadata.description = tags.get("comment")
1584 explicit_tag = tags.get("itunesadvisory")
1585 if explicit_tag is not None:
1586 track.metadata.explicit = explicit_tag == "1"
1587 if recording_mbid := clean_mbid(tags.musicbrainz_recordingid, tags.filename):
1588 track.mbid = recording_mbid
1589
1590 # handle (optional) loudness measurement tag(s)
1591 if tags.track_loudness is not None:
1592 self.mass.create_task(
1593 self.mass.streams.audio_analysis.set_track_loudness(
1594 track.item_id,
1595 self.instance_id,
1596 tags.track_loudness,
1597 tags.track_album_loudness,
1598 )
1599 )
1600
1601 # possible lrclib metadata
1602 # synced lyrics are saved as "filename.lrc" by lrcget alongside
1603 # the actual file location - just change the file extension
1604 assert file_item.ext is not None # for type checking
1605 lrc_path = f"{file_item.relative_path.removesuffix(file_item.ext)}lrc"
1606 if await self.exists(lrc_path):
1607 try:
1608 raw = await self._read_file(lrc_path)
1609 track.metadata.lrc_lyrics = raw.decode("utf-8")
1610 except Exception as err:
1611 self.logger.warning(
1612 "Failed to read lyrics file %s: %s",
1613 lrc_path,
1614 str(err),
1615 )
1616 elif syn_lyrics := tags.synchronized_lyrics:
1617 track.metadata.lrc_lyrics = lyrics.convert_to_lrc_lyrics(syn_lyrics)
1618
1619 return track
1620
1621 async def _resolve_artists_with_mbids(
1622 self,
1623 parsed_names: tuple[str, ...],
1624 mbids: tuple[str, ...],
1625 sort_names: tuple[str, ...],
1626 log_label: str,
1627 ) -> list[tuple[str, str | None, str | None]]:
1628 """
1629 Return ``(name, mbid, sort_name)`` triples for a track's or album's artists.
1630
1631 When the parsed name count and the MBID count disagree, canonical names
1632 are looked up from MusicBrainz; otherwise the tag-parsed names are used.
1633
1634 :param parsed_names: Tag-parsed artist names.
1635 :param mbids: MusicBrainz artist IDs from the tag.
1636 :param sort_names: Sort names from the corresponding *sort tag.
1637 :param log_label: Tag name used in warning messages (e.g. "ARTISTS tag").
1638 """
1639
1640 def _sort_name(index: int) -> str | None:
1641 return sort_names[index] if index < len(sort_names) else None
1642
1643 def _from_tags() -> list[tuple[str, str | None, str | None]]:
1644 return [
1645 (
1646 name,
1647 mbids[i] if i < len(mbids) else None,
1648 _sort_name(i),
1649 )
1650 for i, name in enumerate(parsed_names)
1651 ]
1652
1653 if not mbids or len(parsed_names) == len(mbids):
1654 return _from_tags()
1655
1656 mb_provider = cast("MusicbrainzProvider | None", self.mass.get_provider("musicbrainz"))
1657 if mb_provider is None:
1658 self.logger.warning(
1659 "%s count (%d) doesn't match MBID count (%d) and MusicBrainz "
1660 "provider is not loaded; using tag-parsed names: %s",
1661 log_label,
1662 len(parsed_names),
1663 len(mbids),
1664 parsed_names,
1665 )
1666 return _from_tags()
1667
1668 mb_results = await mb_provider.resolve_artists_from_mbids(mbids)
1669 # counts disagree, so positional fallback to a tag name is unreliable;
1670 # drop any MBID whose lookup failed (already logged per-MBID)
1671 resolved: list[tuple[str, str | None, str | None]] = [
1672 mb_result for mb_result in mb_results if mb_result is not None
1673 ]
1674 if not resolved:
1675 self.logger.warning(
1676 "%s count (%d) didn't match MBID count (%d) and every MusicBrainz "
1677 "lookup failed; falling back to tag-parsed names: %s",
1678 log_label,
1679 len(parsed_names),
1680 len(mbids),
1681 parsed_names,
1682 )
1683 return _from_tags()
1684 self.logger.info(
1685 "%s count (%d) didn't match MBID count (%d); resolved canonical names "
1686 "via MusicBrainz: %s",
1687 log_label,
1688 len(parsed_names),
1689 len(mbids),
1690 [r[0] for r in resolved],
1691 )
1692 return resolved
1693
1694 def _match_album_artist(
1695 self, album: Album | None, name: str, mbid: str | None
1696 ) -> Artist | ItemMapping | None:
1697 """
1698 Return an existing album artist representing the same artist, if any.
1699
1700 Matches on MusicBrainz ID when available (names may differ when only one
1701 side was resolved against MusicBrainz), otherwise on exact name.
1702
1703 :param album: The track's album, if known.
1704 :param name: Resolved track-artist name.
1705 :param mbid: Resolved track-artist MusicBrainz ID, if any.
1706 """
1707 if not album:
1708 return None
1709 return next(
1710 (x for x in album.artists if (mbid and x.mbid == mbid) or x.name == name),
1711 None,
1712 )
1713
1714 async def _parse_artist(
1715 self,
1716 name: str,
1717 album_dir: str | None = None,
1718 sort_name: str | None = None,
1719 mbid: str | None = None,
1720 artist_path: str | None = None,
1721 ) -> Artist:
1722 """Parse full (album) Artist."""
1723 if not artist_path:
1724 # we need to hunt for the artist (metadata) path on disk
1725 # this can either be relative to the album path or at root level
1726 # check if we have an artist folder for this artist at root level
1727 safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
1728 if await self.exists(name):
1729 artist_path = name
1730 elif await self.exists(safe_artist_name):
1731 artist_path = safe_artist_name
1732 elif album_dir and (foldermatch := get_artist_dir(name, album_dir=album_dir)):
1733 # try to find (album)artist folder based on album path
1734 artist_path = foldermatch
1735 else:
1736 # check if we have an existing item to retrieve the artist path
1737 async for item in self.mass.music.artists.iter_library_items(
1738 search=name, provider=self.instance_id
1739 ):
1740 if not compare_strings(name, item.name):
1741 continue
1742 for prov_mapping in item.provider_mappings:
1743 if prov_mapping.provider_instance != self.instance_id:
1744 continue
1745 if prov_mapping.url:
1746 artist_path = prov_mapping.url
1747 break
1748 if artist_path:
1749 break
1750
1751 # prefer (short lived) cache for a bit more speed
1752 if artist_path and (
1753 cache := await self.cache.get(
1754 key=artist_path,
1755 provider=self.instance_id,
1756 category=CACHE_CATEGORY_ARTIST_INFO,
1757 base_class=Artist,
1758 )
1759 ):
1760 return cache # type: ignore[no-any-return]
1761
1762 prov_artist_id = artist_path or name
1763 artist = Artist(
1764 item_id=prov_artist_id,
1765 provider=self.instance_id,
1766 name=name,
1767 sort_name=sort_name,
1768 provider_mappings={
1769 ProviderMapping(
1770 item_id=prov_artist_id,
1771 provider_domain=self.domain,
1772 provider_instance=self.instance_id,
1773 url=artist_path,
1774 in_library=True,
1775 )
1776 },
1777 )
1778 if mbid := clean_mbid(mbid, f"tags of artist {name}"):
1779 artist.mbid = mbid
1780 if not artist_path or not await self.exists(artist_path):
1781 return artist
1782
1783 # grab additional metadata within the Artist's folder
1784 nfo_file = os.path.join(artist_path, "artist.nfo")
1785 if await self.exists(nfo_file):
1786 # found NFO file with metadata
1787 # https://kodi.wiki/view/NFO_files/Artists
1788 try:
1789 data = (await self._read_file(nfo_file)).decode("utf-8")
1790 info = await asyncio.to_thread(xmltodict.parse, data)
1791 info = info["artist"]
1792 artist.name = info.get("title", info.get("name", name))
1793 if sort_name := info.get("sortname"):
1794 artist.sort_name = sort_name
1795 if mbid := clean_mbid(info.get("musicbrainzartistid"), nfo_file):
1796 artist.mbid = mbid
1797 if description := info.get("biography"):
1798 artist.metadata.description = description
1799 if genre := info.get("genre"):
1800 artist.metadata.genres = set(split_items(genre))
1801 except (ExpatError, KeyError) as err:
1802 self.logger.warning(
1803 "Failed to parse artist NFO file %s: %s",
1804 nfo_file,
1805 str(err),
1806 )
1807 # find local images
1808 if images := await self._get_local_images(artist_path, extra_thumb_names=("artist",)):
1809 artist.metadata.images = UniqueList(images)
1810
1811 await self.cache.set(
1812 key=artist_path,
1813 data=artist.to_dict(),
1814 provider=self.instance_id,
1815 category=CACHE_CATEGORY_ARTIST_INFO,
1816 expiration=120,
1817 )
1818
1819 return artist
1820
1821 async def _parse_audiobook(self, file_item: FileSystemItem, tags: AudioTags) -> Audiobook:
1822 """
1823 Parse Audiobook details from file tags.
1824
1825 Audiobooks can be single files with embedded chapters or multiple files per folder.
1826 Only the first file (by track number or alphabetically) is processed as the audiobook.
1827 """
1828 # Skip files that aren't the first chapter.
1829 # A file carrying its own embedded chapter markers is a standalone audiobook,
1830 # so it should never be treated as a chapter file of another book.
1831 track_tag = tags.tags.get("track")
1832 if track_tag:
1833 track_num = try_parse_int(str(track_tag).split("/")[0], None)
1834 if track_num and track_num > 1 and not tags.chapters:
1835 raise IsChapterFile
1836 elif not tags.chapters:
1837 # No track tag and no embedded chapters -
1838 # assume part of a multi-file audiobook, only process the first file alphabetically
1839 items = await self._scandir(file_item.relative_parent_path)
1840 # Sort by filename for alphabetical ordering
1841 items.sort(key=lambda x: x.filename.lower())
1842 for item in items:
1843 if item.is_dir or item.ext not in AUDIOBOOK_EXTENSIONS:
1844 continue
1845 if item.absolute_path != file_item.absolute_path:
1846 raise IsChapterFile
1847 break
1848
1849 # For multi-file audiobooks, album tag is the book name, title is the chapter name
1850 if tags.album:
1851 book_name = tags.album
1852 sort_name = tags.album_sort
1853 elif (title := tags.tags.get("title")) and tags.track is None:
1854 book_name = title
1855 sort_name = tags.title_sort
1856 else:
1857 # file(s) without tags, use foldername
1858 book_name = file_item.parent_name
1859 sort_name = None
1860
1861 # collect all chapters
1862 total_duration, chapters = await self._get_chapters_for_audiobook(file_item, tags)
1863
1864 audio_book = Audiobook(
1865 item_id=file_item.relative_path,
1866 provider=self.instance_id,
1867 name=book_name,
1868 sort_name=sort_name,
1869 version=tags.version,
1870 duration=total_duration or int(tags.duration or 0),
1871 provider_mappings={
1872 ProviderMapping(
1873 item_id=file_item.relative_path,
1874 provider_domain=self.domain,
1875 provider_instance=self.instance_id,
1876 audio_format=AudioFormat(
1877 content_type=ContentType.try_parse(file_item.ext or tags.format),
1878 sample_rate=tags.sample_rate,
1879 bit_depth=tags.bits_per_sample,
1880 channels=tags.channels,
1881 bit_rate=tags.bit_rate,
1882 ),
1883 details=file_item.checksum,
1884 in_library=True,
1885 )
1886 },
1887 )
1888 audio_book.metadata.chapters = chapters
1889
1890 # handle embedded cover image
1891 if tags.has_cover_image:
1892 # we do not actually embed the image in the metadata because that would consume too
1893 # much space and bandwidth. Instead we set the filename as value so the image can
1894 # be retrieved later in realtime.
1895 audio_book.metadata.add_image(
1896 MediaItemImage(
1897 type=ImageType.THUMB,
1898 path=self._versioned_image_path(file_item.relative_path, file_item.checksum),
1899 provider=self.instance_id,
1900 remotely_accessible=False,
1901 )
1902 )
1903
1904 # parse other info
1905 audio_book.authors.set(tags.writers or tags.album_artists or tags.artists)
1906 audio_book.metadata.genres = (
1907 set(tags.genres) if tags.genres else {DEFAULT_AUDIOBOOK_PODCAST_GENRE}
1908 )
1909 audio_book.metadata.copyright = tags.get("copyright")
1910 audio_book.metadata.lyrics = tags.lyrics
1911 audio_book.metadata.description = tags.get("comment")
1912 explicit_tag = tags.get("itunesadvisory")
1913 if explicit_tag is not None:
1914 audio_book.metadata.explicit = explicit_tag == "1"
1915 if recording_mbid := clean_mbid(tags.musicbrainz_recordingid, tags.filename):
1916 audio_book.mbid = recording_mbid
1917
1918 # try to fetch additional metadata from the folder
1919 if not audio_book.image or not audio_book.metadata.description:
1920 # try to get an image by traversing files in the same folder
1921 for _item in await self._scandir(file_item.relative_parent_path):
1922 if "." not in _item.relative_path or _item.is_dir:
1923 continue
1924 if _item.ext in IMAGE_EXTENSIONS and not audio_book.image:
1925 audio_book.metadata.add_image(
1926 MediaItemImage(
1927 type=ImageType.THUMB,
1928 path=self._versioned_image_path(_item.relative_path, _item.checksum),
1929 provider=self.instance_id,
1930 remotely_accessible=False,
1931 )
1932 )
1933 if _item.ext == "txt" and not audio_book.metadata.description:
1934 # try to parse a description from a text file
1935 try:
1936 raw = await self._read_file(_item.relative_path)
1937 audio_book.metadata.description = raw.decode("utf-8")
1938 except Exception as err:
1939 self.logger.warning(
1940 "Could not read description from file %s: %s",
1941 _item.relative_path,
1942 str(err),
1943 )
1944
1945 # handle (optional) loudness measurement tag(s)
1946 if tags.track_loudness is not None:
1947 self.mass.create_task(
1948 self.mass.streams.audio_analysis.set_track_loudness(
1949 audio_book.item_id,
1950 self.instance_id,
1951 tags.track_loudness,
1952 tags.track_album_loudness,
1953 media_type=MediaType.AUDIOBOOK,
1954 )
1955 )
1956 return audio_book
1957
1958 async def _parse_podcast_episode(
1959 self, file_item: FileSystemItem, tags: AudioTags
1960 ) -> PodcastEpisode:
1961 """Parse full PodcastEpisode details from file tags."""
1962 # ruff: noqa: PLR0915
1963 podcast_name = tags.album or file_item.parent_name
1964 podcast_path = file_item.relative_parent_path
1965 episode = PodcastEpisode(
1966 item_id=file_item.relative_path,
1967 provider=self.instance_id,
1968 name=tags.title,
1969 sort_name=tags.title_sort,
1970 provider_mappings={
1971 ProviderMapping(
1972 item_id=file_item.relative_path,
1973 provider_domain=self.domain,
1974 provider_instance=self.instance_id,
1975 audio_format=AudioFormat(
1976 content_type=ContentType.try_parse(file_item.ext or tags.format),
1977 sample_rate=tags.sample_rate,
1978 bit_depth=tags.bits_per_sample,
1979 channels=tags.channels,
1980 bit_rate=tags.bit_rate,
1981 ),
1982 details=file_item.checksum,
1983 in_library=True,
1984 )
1985 },
1986 position=tags.track or 0,
1987 duration=try_parse_int(tags.duration) or 0,
1988 podcast=Podcast(
1989 item_id=podcast_path,
1990 provider=self.instance_id,
1991 name=podcast_name,
1992 sort_name=tags.album_sort,
1993 publisher=tags.tags.get("publisher"),
1994 provider_mappings={
1995 ProviderMapping(
1996 item_id=podcast_path,
1997 provider_domain=self.domain,
1998 provider_instance=self.instance_id,
1999 in_library=True,
2000 )
2001 },
2002 ),
2003 )
2004 # handle embedded cover image
2005 if tags.has_cover_image:
2006 # we do not actually embed the image in the metadata because that would consume too
2007 # much space and bandwidth. Instead we set the filename as value so the image can
2008 # be retrieved later in realtime.
2009 episode.metadata.add_image(
2010 MediaItemImage(
2011 type=ImageType.THUMB,
2012 path=file_item.relative_path,
2013 provider=self.instance_id,
2014 remotely_accessible=False,
2015 )
2016 )
2017 # parse other info
2018 episode.metadata.genres = (
2019 set(tags.genres) if tags.genres else {DEFAULT_AUDIOBOOK_PODCAST_GENRE}
2020 )
2021 episode.metadata.copyright = tags.get("copyright")
2022 episode.metadata.lyrics = tags.lyrics
2023 episode.metadata.description = tags.get("comment")
2024 explicit_tag = tags.get("itunesadvisory")
2025 if explicit_tag is not None:
2026 episode.metadata.explicit = explicit_tag == "1"
2027
2028 # handle (optional) chapters
2029 if tags.chapters:
2030 episode.metadata.chapters = [
2031 MediaItemChapter(
2032 position=chapter.chapter_id,
2033 name=chapter.title or f"Chapter {chapter.chapter_id}",
2034 start=chapter.position_start,
2035 end=chapter.position_end,
2036 )
2037 for chapter in tags.chapters
2038 ]
2039
2040 # try to fetch additional Podcast metadata from the folder
2041 assert isinstance(episode.podcast, Podcast)
2042 if images := await self._get_local_images(file_item.relative_parent_path):
2043 episode.podcast.metadata.images = images
2044 if metadata := await self._get_podcast_metadata(file_item.relative_parent_path):
2045 if title := metadata.get("title"):
2046 episode.podcast.name = title
2047 if sort_name := metadata.get("sorttitle"):
2048 episode.podcast.sort_name = sort_name
2049 if description := metadata.get("description"):
2050 episode.podcast.metadata.description = description
2051 if genres := metadata.get("genres"):
2052 episode.podcast.metadata.genres = set(genres)
2053 if publisher := metadata.get("publisher"):
2054 episode.podcast.publisher = publisher
2055 if image := metadata.get("imageURL"):
2056 episode.podcast.metadata.add_image(
2057 MediaItemImage(
2058 type=ImageType.THUMB,
2059 path=image,
2060 provider=self.instance_id,
2061 remotely_accessible=True,
2062 )
2063 )
2064 # copy (embedded) image from episode (or vice versa)
2065 if not episode.podcast.image and episode.image:
2066 episode.podcast.metadata.add_image(episode.image)
2067 elif not episode.image and episode.podcast.image:
2068 episode.metadata.add_image(episode.podcast.image)
2069 # ensure podcast has a default genre if none set
2070 if not episode.podcast.metadata.genres:
2071 episode.podcast.metadata.genres = {DEFAULT_AUDIOBOOK_PODCAST_GENRE}
2072
2073 # handle (optional) loudness measurement tag(s)
2074 if tags.track_loudness is not None:
2075 self.mass.create_task(
2076 self.mass.streams.audio_analysis.set_track_loudness(
2077 episode.item_id,
2078 self.instance_id,
2079 tags.track_loudness,
2080 tags.track_album_loudness,
2081 media_type=MediaType.PODCAST_EPISODE,
2082 )
2083 )
2084 return episode
2085
2086 async def _parse_sound_effect(self, file_item: FileSystemItem, tags: AudioTags) -> SoundEffect:
2087 """Parse full sound effect details from file tags."""
2088 sound_effect = SoundEffect(
2089 item_id=file_item.relative_path,
2090 provider=self.instance_id,
2091 name=tags.title,
2092 sort_name=tags.title_sort,
2093 duration=int(tags.duration or 0),
2094 provider_mappings={
2095 ProviderMapping(
2096 item_id=file_item.relative_path,
2097 provider_domain=self.domain,
2098 provider_instance=self.instance_id,
2099 audio_format=AudioFormat(
2100 content_type=ContentType.try_parse(file_item.ext or tags.format),
2101 sample_rate=tags.sample_rate,
2102 bit_depth=tags.bits_per_sample,
2103 channels=tags.channels,
2104 bit_rate=tags.bit_rate,
2105 ),
2106 details=file_item.checksum,
2107 in_library=True,
2108 )
2109 },
2110 )
2111 sound_effect.metadata.description = tags.get("comment")
2112 # handle embedded cover image
2113 if tags.has_cover_image:
2114 # we do not actually embed the image in the metadata because that would consume too
2115 # much space and bandwidth. Instead we set the filename as value so the image can
2116 # be retrieved later in realtime.
2117 sound_effect.metadata.add_image(
2118 MediaItemImage(
2119 type=ImageType.THUMB,
2120 path=file_item.relative_path,
2121 provider=self.instance_id,
2122 remotely_accessible=False,
2123 )
2124 )
2125 return sound_effect
2126
2127 async def _get_or_parse_sound_effect(self, file_item: FileSystemItem) -> SoundEffect:
2128 """Return the (cached) SoundEffect for the given file, parsing tags when needed."""
2129 cache_key = f"sound_effect.{file_item.relative_path}"
2130 cached_data: SoundEffect | None = await self.cache.get(
2131 cache_key,
2132 provider=self.instance_id,
2133 checksum=file_item.checksum,
2134 category=CACHE_CATEGORY_SOUND_EFFECTS,
2135 base_class=SoundEffect,
2136 )
2137 if cached_data is not None:
2138 return cached_data
2139 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2140 sound_effect = await self._parse_sound_effect(file_item, tags)
2141 await self.cache.set(
2142 cache_key,
2143 sound_effect.to_dict(),
2144 expiration=3600 * 24 * 365, # File timestamp checksum handles invalidation
2145 provider=self.instance_id,
2146 checksum=file_item.checksum,
2147 category=CACHE_CATEGORY_SOUND_EFFECTS,
2148 )
2149 return sound_effect
2150
2151 async def _parse_album(
2152 self, track_path: str, track_tags: AudioTags, track_created_at: int | None = None
2153 ) -> Album:
2154 """
2155 Parse Album metadata from Track tags.
2156
2157 :param track_path: Path to the track file.
2158 :param track_tags: Audio tags from the track.
2159 :param track_created_at: Creation timestamp of the track file (Unix epoch).
2160 """
2161 assert track_tags.album
2162 # work out if we have an album and/or disc folder
2163 # track_dir is the folder level where the tracks are located
2164 # this may be a separate disc folder (Disc 1, Disc 2 etc) underneath the album folder
2165 # or this is an album folder with the disc attached
2166 track_dir = os.path.dirname(track_path)
2167 album_dir = get_album_dir(track_dir, track_tags.album)
2168
2169 if album_dir and (
2170 cache := await self.cache.get(
2171 key=album_dir,
2172 provider=self.instance_id,
2173 category=CACHE_CATEGORY_ALBUM_INFO,
2174 base_class=Album,
2175 )
2176 ):
2177 return cache # type: ignore[no-any-return]
2178
2179 # album artist(s)
2180 album_artists: UniqueList[Artist | ItemMapping] = UniqueList()
2181 if track_tags.album_artists:
2182 resolved_album_artists = await self._resolve_artists_with_mbids(
2183 track_tags.album_artists,
2184 track_tags.musicbrainz_albumartistids,
2185 track_tags.album_artist_sort_names,
2186 log_label="ALBUMARTIST tag",
2187 )
2188 for name, mbid, sort_name in resolved_album_artists:
2189 artist = await self._parse_artist(
2190 name, album_dir=album_dir, sort_name=sort_name, mbid=mbid
2191 )
2192 album_artists.append(artist)
2193 else:
2194 # album artist tag is missing, determine fallback
2195 fallback_action = self.config.get_value(CONF_ENTRY_MISSING_ALBUM_ARTIST.key)
2196 if fallback_action == "folder_name" and album_dir:
2197 possible_artist_folder = os.path.dirname(album_dir)
2198 self.logger.warning(
2199 "%s is missing ID3 tag [albumartist], using foldername %s as fallback",
2200 track_path,
2201 possible_artist_folder,
2202 )
2203 album_artist_str = possible_artist_folder.rsplit(os.sep)[-1]
2204 album_artists = UniqueList(
2205 [await self._parse_artist(name=album_artist_str, album_dir=album_dir)]
2206 )
2207 # fallback to track artists (if defined by user)
2208 elif fallback_action == "track_artist":
2209 self.logger.warning(
2210 "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
2211 track_path,
2212 )
2213 album_artists = UniqueList(
2214 [
2215 await self._parse_artist(name=track_artist_str, album_dir=album_dir)
2216 for track_artist_str in track_tags.artists
2217 ]
2218 )
2219 # all other: fallback to various artists
2220 else:
2221 self.logger.warning(
2222 "%s is missing ID3 tag [albumartist], using %s as fallback",
2223 track_path,
2224 VARIOUS_ARTISTS_NAME,
2225 )
2226 album_artists = UniqueList(
2227 [await self._parse_artist(name=VARIOUS_ARTISTS_NAME, mbid=VARIOUS_ARTISTS_MBID)]
2228 )
2229
2230 if album_dir: # noqa: SIM108
2231 # prefer the path as id
2232 item_id = album_dir
2233 else:
2234 # create fake item_id based on artist + album
2235 item_id = album_artists[0].name + os.sep + track_tags.album
2236
2237 name, version = parse_title_and_version(track_tags.album)
2238 album = Album(
2239 item_id=item_id,
2240 provider=self.instance_id,
2241 name=name,
2242 version=version,
2243 sort_name=track_tags.album_sort,
2244 artists=album_artists,
2245 provider_mappings={
2246 ProviderMapping(
2247 item_id=item_id,
2248 provider_domain=self.domain,
2249 provider_instance=self.instance_id,
2250 url=album_dir,
2251 in_library=True,
2252 )
2253 },
2254 date_added=(
2255 datetime.fromtimestamp(track_created_at, tz=UTC) if track_created_at else None
2256 ),
2257 )
2258 if track_tags.barcode:
2259 album.external_ids.add((ExternalID.BARCODE, track_tags.barcode))
2260
2261 if album_mbid := clean_mbid(track_tags.musicbrainz_albumid, track_tags.filename):
2262 album.mbid = album_mbid
2263 if releasegroup_mbid := clean_mbid(
2264 track_tags.musicbrainz_releasegroupid, track_tags.filename
2265 ):
2266 album.add_external_id(ExternalID.MB_RELEASEGROUP, releasegroup_mbid)
2267 if track_tags.year:
2268 album.year = track_tags.year
2269 album.album_type = track_tags.album_type
2270
2271 # hunt for additional metadata and images in the folder structure
2272 if not album_dir:
2273 return album
2274
2275 for folder_path in (track_dir, album_dir):
2276 if not folder_path or not await self.exists(folder_path):
2277 continue
2278 nfo_file = os.path.join(folder_path, "album.nfo")
2279 if await self.exists(nfo_file):
2280 # found NFO file with metadata
2281 # https://kodi.wiki/view/NFO_files/Artists
2282 try:
2283 data = (await self._read_file(nfo_file)).decode("utf-8")
2284 info = await asyncio.to_thread(xmltodict.parse, data)
2285 parse_album_nfo(album, info["album"], nfo_file)
2286 except (ExpatError, KeyError) as err:
2287 self.logger.warning(
2288 "Failed to parse album NFO file %s: %s",
2289 nfo_file,
2290 str(err),
2291 )
2292
2293 # find local images
2294 if images := await self._get_local_images(folder_path, extra_thumb_names=("album",)):
2295 if album.metadata.images is None:
2296 album.metadata.images = UniqueList(images)
2297 else:
2298 album.metadata.images += images
2299
2300 await self.cache.set(
2301 key=album_dir,
2302 data=album.to_dict(),
2303 provider=self.instance_id,
2304 category=CACHE_CATEGORY_ALBUM_INFO,
2305 expiration=120,
2306 )
2307 return album
2308
2309 async def _get_local_images(
2310 self, folder: str, extra_thumb_names: tuple[str, ...] | None = None
2311 ) -> UniqueList[MediaItemImage]:
2312 """Return local images found in a given folderpath."""
2313 if (
2314 cache := await self.cache.get(
2315 key=folder,
2316 provider=self.instance_id,
2317 category=CACHE_CATEGORY_FOLDER_IMAGES,
2318 base_class=MediaItemImage,
2319 )
2320 ) is not None:
2321 return UniqueList(cache)
2322 if extra_thumb_names is None:
2323 extra_thumb_names = ()
2324 images: UniqueList[MediaItemImage] = UniqueList()
2325 folder_files = await self._scandir(folder)
2326 for item in folder_files:
2327 if "." not in item.relative_path or item.is_dir or not item.ext:
2328 continue
2329 if item.ext.lower() not in IMAGE_EXTENSIONS:
2330 continue
2331 # try match on filename = one of our imagetypes
2332 if item.name.lower() in ImageType:
2333 images.append(
2334 MediaItemImage(
2335 type=ImageType(item.name),
2336 path=item.relative_path,
2337 provider=self.instance_id,
2338 remotely_accessible=False,
2339 )
2340 )
2341
2342 # try alternative names for thumbs
2343 extra_thumb_names = ("folder", "cover", *extra_thumb_names)
2344 for item in folder_files:
2345 if "." not in item.relative_path or item.is_dir or not item.ext:
2346 continue
2347 if item.ext.lower() not in IMAGE_EXTENSIONS:
2348 continue
2349 if item.name.lower() not in extra_thumb_names:
2350 continue
2351 images.append(
2352 MediaItemImage(
2353 type=ImageType.THUMB,
2354 path=item.relative_path,
2355 provider=self.instance_id,
2356 remotely_accessible=False,
2357 )
2358 )
2359
2360 await self.cache.set(
2361 key=folder,
2362 data=[img.to_dict() for img in images],
2363 provider=self.instance_id,
2364 category=CACHE_CATEGORY_FOLDER_IMAGES,
2365 expiration=120,
2366 )
2367 return images
2368
2369 async def _get_stream_details_for_track(self, item_id: str) -> StreamDetails:
2370 """Return the streamdetails for a track/song."""
2371 if parse_cue_track_id(item_id) is not None:
2372 return await self._cue.get_stream_details(item_id)
2373
2374 library_item = await self.mass.music.tracks.get_library_item_by_prov_id(
2375 item_id, self.instance_id
2376 )
2377 if library_item is None:
2378 # this could be a file that has just been added, try parsing it
2379 file_item = await self.resolve(item_id)
2380 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2381 if not (library_item := await self._parse_track(file_item, tags)):
2382 msg = f"Item not found: {item_id}"
2383 raise MediaNotFoundError(msg)
2384
2385 prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
2386 file_item = await self.resolve(item_id)
2387
2388 return StreamDetails(
2389 provider=self.instance_id,
2390 item_id=item_id,
2391 audio_format=prov_mapping.audio_format,
2392 media_type=MediaType.TRACK,
2393 stream_type=StreamType.LOCAL_FILE,
2394 duration=library_item.duration,
2395 size=file_item.file_size,
2396 data=file_item,
2397 path=file_item.absolute_path,
2398 can_seek=True,
2399 allow_seek=True,
2400 )
2401
2402 async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamDetails:
2403 """Return the streamdetails for a podcast episode."""
2404 # podcasts episodes are never stored in the library so we need to parse the file
2405 file_item = await self.resolve(item_id)
2406 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2407 return StreamDetails(
2408 provider=self.instance_id,
2409 item_id=item_id,
2410 audio_format=AudioFormat(
2411 content_type=ContentType.try_parse(file_item.ext or tags.format),
2412 sample_rate=tags.sample_rate,
2413 bit_depth=tags.bits_per_sample,
2414 channels=tags.channels,
2415 bit_rate=tags.bit_rate,
2416 ),
2417 media_type=MediaType.PODCAST_EPISODE,
2418 stream_type=StreamType.LOCAL_FILE,
2419 duration=try_parse_int(tags.duration or 0),
2420 size=file_item.file_size,
2421 data=file_item,
2422 path=file_item.absolute_path,
2423 allow_seek=True,
2424 can_seek=True,
2425 )
2426
2427 async def _get_stream_details_for_sound_effect(self, item_id: str) -> StreamDetails:
2428 """Return the streamdetails for a sound effect."""
2429 # sound effects are never stored in the library so we parse the file,
2430 # served from cache unless the file changed on disk
2431 file_item = await self.resolve(item_id)
2432 sound_effect = await self._get_or_parse_sound_effect(file_item)
2433 prov_mapping = next(x for x in sound_effect.provider_mappings if x.item_id == item_id)
2434 return StreamDetails(
2435 provider=self.instance_id,
2436 item_id=item_id,
2437 audio_format=prov_mapping.audio_format,
2438 media_type=MediaType.SOUND_EFFECT,
2439 stream_type=StreamType.LOCAL_FILE,
2440 duration=sound_effect.duration,
2441 size=file_item.file_size,
2442 data=file_item,
2443 path=file_item.absolute_path,
2444 allow_seek=True,
2445 can_seek=True,
2446 )
2447
2448 async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails:
2449 """Return the streamdetails for an audiobook."""
2450 library_item = await self.mass.music.audiobooks.get_library_item_by_prov_id(
2451 item_id, self.instance_id
2452 )
2453 if library_item is None:
2454 # this could be a file that has just been added, try parsing it
2455 file_item = await self.resolve(item_id)
2456 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2457 if not (library_item := await self._parse_audiobook(file_item, tags)):
2458 msg = f"Item not found: {item_id}"
2459 raise MediaNotFoundError(msg)
2460
2461 prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
2462 file_item = await self.resolve(item_id)
2463 duration = library_item.duration
2464 file_based_chapters: list[tuple[str, float]] | None = await self.cache.get(
2465 key=file_item.relative_path,
2466 provider=self.instance_id,
2467 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
2468 )
2469 if file_based_chapters is None:
2470 # no cache available for this audiobook, we need to parse the chapters
2471 tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
2472 await self._parse_audiobook(file_item, tags)
2473 file_based_chapters = await self.cache.get(
2474 key=file_item.relative_path,
2475 provider=self.instance_id,
2476 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
2477 )
2478
2479 if file_based_chapters:
2480 # this is a multi-file audiobook
2481 return StreamDetails(
2482 provider=self.instance_id,
2483 item_id=item_id,
2484 audio_format=prov_mapping.audio_format,
2485 media_type=MediaType.AUDIOBOOK,
2486 stream_type=StreamType.LOCAL_FILE,
2487 duration=duration,
2488 path=[
2489 MultiPartPath(
2490 path=self._get_chapter_path(chapter_path),
2491 duration=chapter_duration,
2492 )
2493 for chapter_path, chapter_duration in file_based_chapters
2494 ],
2495 allow_seek=True,
2496 )
2497
2498 # regular single-file streaming, simply let ffmpeg deal with the file directly
2499 return StreamDetails(
2500 provider=self.instance_id,
2501 item_id=item_id,
2502 audio_format=prov_mapping.audio_format,
2503 media_type=MediaType.AUDIOBOOK,
2504 stream_type=StreamType.LOCAL_FILE,
2505 duration=library_item.duration,
2506 size=file_item.file_size,
2507 data=file_item,
2508 path=file_item.absolute_path,
2509 allow_seek=True,
2510 can_seek=True,
2511 )
2512
2513 def _get_chapter_path(self, relative_path: str) -> str:
2514 """Return absolute path for a chapter file. Override for network storage."""
2515 return self.get_absolute_path(relative_path)
2516
2517 async def _get_chapters_for_audiobook(
2518 self, audiobook_file_item: FileSystemItem, tags: AudioTags
2519 ) -> tuple[int, list[MediaItemChapter]]:
2520 """
2521 Return chapters for an audiobook.
2522
2523 Chapter sources in order of preference:
2524 1. Multiple files with track tags - sorted by track number
2525 2. Single file with embedded chapters - use embedded chapter markers
2526 3. Multiple files without track tags - sorted alphabetically (fallback)
2527 """
2528 chapters: list[MediaItemChapter] = []
2529 all_chapter_files: list[tuple[str, float]] = []
2530 total_duration = 0.0
2531
2532 # Scan folder for chapter files, separating tagged from untagged
2533 chapter_file_items: list[tuple[FileSystemItem, AudioTags]] = []
2534 untagged_file_items: list[tuple[FileSystemItem, AudioTags]] = []
2535
2536 items = await self._scandir(audiobook_file_item.relative_parent_path)
2537 # Sort by filename for consistent alphabetical ordering
2538 items.sort(key=lambda x: x.filename.lower())
2539
2540 for item in items:
2541 if "." not in item.relative_path or item.is_dir:
2542 continue
2543 if item.ext not in AUDIOBOOK_EXTENSIONS:
2544 continue
2545 item_tags = await async_parse_tags(item.absolute_path, item.file_size)
2546 if not (tags.album == item_tags.album or (item_tags.tags.get("title") is None)):
2547 continue
2548 if item_tags.tags.get("track") is None:
2549 untagged_file_items.append((item, item_tags))
2550 else:
2551 chapter_file_items.append((item, item_tags))
2552
2553 # Determine chapter source
2554 use_embedded = False
2555 use_alphabetical = False
2556
2557 if len(chapter_file_items) > 1:
2558 chapter_file_items.sort(key=lambda x: (x[1].disc or 0, x[1].track or 0))
2559 elif len(chapter_file_items) <= 1 and tags.chapters:
2560 use_embedded = True
2561 elif len(untagged_file_items) > 1:
2562 use_alphabetical = True
2563 chapter_file_items = untagged_file_items
2564 self.logger.info(
2565 "Audiobook files have no track tags, using alphabetical order: %s",
2566 tags.album,
2567 )
2568
2569 if use_embedded:
2570 chapters = [
2571 MediaItemChapter(
2572 position=chapter.chapter_id,
2573 name=chapter.title or f"Chapter {chapter.chapter_id}",
2574 start=chapter.position_start,
2575 end=chapter.position_end,
2576 )
2577 for chapter in tags.chapters
2578 ]
2579 total_duration = try_parse_int(tags.duration) or 0
2580 self.logger.log(
2581 VERBOSE_LOG_LEVEL,
2582 "Audiobook '%s': %d embedded chapters, duration=%d",
2583 tags.album,
2584 len(chapters),
2585 int(total_duration),
2586 )
2587 else:
2588 for position, (chapter_item, chapter_tags) in enumerate(chapter_file_items, start=1):
2589 if chapter_tags.duration is None:
2590 self.logger.warning(
2591 "Chapter file has no duration, skipping: %s",
2592 chapter_item.relative_path,
2593 )
2594 continue
2595 self.logger.debug("Chapter filename: %s", chapter_item.relative_path)
2596 chapters.append(
2597 MediaItemChapter(
2598 position=position,
2599 name=chapter_tags.title,
2600 start=total_duration,
2601 end=total_duration + chapter_tags.duration,
2602 )
2603 )
2604 all_chapter_files.append(
2605 (
2606 chapter_item.relative_path,
2607 chapter_tags.duration,
2608 )
2609 )
2610 total_duration += chapter_tags.duration
2611 sort_method = "alphabetical" if use_alphabetical else "track"
2612 self.logger.log(
2613 VERBOSE_LOG_LEVEL,
2614 "Audiobook '%s': %d files (%s order), duration=%d",
2615 tags.album,
2616 len(chapters),
2617 sort_method,
2618 int(total_duration),
2619 )
2620 # Cache chapter files for streaming
2621 await self.cache.set(
2622 key=audiobook_file_item.relative_path,
2623 data=all_chapter_files,
2624 provider=self.instance_id,
2625 category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
2626 )
2627 return int(total_duration), chapters
2628
2629 async def _get_podcast_metadata(self, podcast_folder: str) -> dict[str, Any]:
2630 """Return metadata for a podcast."""
2631 if (
2632 cache := await self.cache.get(
2633 key=podcast_folder,
2634 provider=self.instance_id,
2635 category=CACHE_CATEGORY_PODCAST_METADATA,
2636 )
2637 ) is not None:
2638 return cast("dict[str, Any]", cache)
2639 data: dict[str, Any] = {}
2640 metadata_file = os.path.join(podcast_folder, "metadata.json")
2641 if await self.exists(metadata_file):
2642 # found json file with metadata
2643 raw = await self._read_file(metadata_file)
2644 data.update(json_loads(raw.decode("utf-8")))
2645 await self.cache.set(
2646 key=podcast_folder,
2647 data=data,
2648 provider=self.instance_id,
2649 category=CACHE_CATEGORY_PODCAST_METADATA,
2650 )
2651 return data
2652
2653 async def _scandir(self, path: str) -> list[FileSystemItem]:
2654 """List directory contents in natural sort order."""
2655 # raw scandir order depends on the underlying filesystem (e.g. hash order
2656 # on ext4) so sort to make browse and folder playback order deterministic
2657 abs_path = self.get_absolute_path(path)
2658 return await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True)
2659
2660 async def _read_file(self, path: str) -> bytes:
2661 """Read file contents. Override for network storage."""
2662 async with aiofiles.open(self.get_absolute_path(path), mode="rb") as f:
2663 return cast("bytes", await f.read())
2664