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