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