music-assistant-server

78.9 KBPY
__init__.py
78.9 KB1,886 lines • python
1"""Filesystem musicprovider support for MusicAssistant."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import logging
8import os
9import os.path
10import time
11import urllib.parse
12from collections.abc import AsyncGenerator, Iterator, Sequence
13from datetime import UTC, datetime
14from pathlib import Path
15from typing import TYPE_CHECKING, Any, cast
16
17import aiofiles
18import shortuuid
19import xmltodict
20from aiofiles.os import wrap
21from music_assistant_models.enums import (
22    ContentType,
23    ExternalID,
24    ImageType,
25    MediaType,
26    ProviderFeature,
27    StreamType,
28)
29from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError, SetupFailedError
30from music_assistant_models.media_items import (
31    Album,
32    Artist,
33    Audiobook,
34    AudioFormat,
35    BrowseFolder,
36    ItemMapping,
37    MediaItemChapter,
38    MediaItemImage,
39    MediaItemType,
40    Playlist,
41    Podcast,
42    PodcastEpisode,
43    ProviderMapping,
44    SearchResults,
45    Track,
46    UniqueList,
47    is_track,
48)
49from music_assistant_models.streamdetails import MultiPartPath, StreamDetails
50
51from music_assistant.constants import (
52    CONF_PATH,
53    DB_TABLE_ALBUM_ARTISTS,
54    DB_TABLE_ALBUM_TRACKS,
55    DB_TABLE_ALBUMS,
56    DB_TABLE_ARTISTS,
57    DB_TABLE_PROVIDER_MAPPINGS,
58    DB_TABLE_TRACK_ARTISTS,
59    VARIOUS_ARTISTS_MBID,
60    VARIOUS_ARTISTS_NAME,
61    VERBOSE_LOG_LEVEL,
62)
63from music_assistant.helpers.compare import compare_strings, create_safe_string
64from music_assistant.helpers.json import json_loads
65from music_assistant.helpers.playlists import parse_m3u, parse_pls
66from music_assistant.helpers.tags import AudioTags, async_parse_tags, parse_tags, split_items
67from music_assistant.helpers.util import (
68    TaskManager,
69    detect_charset,
70    parse_title_and_version,
71    try_parse_int,
72)
73from music_assistant.models.music_provider import MusicProvider
74
75from .constants import (
76    AUDIOBOOK_EXTENSIONS,
77    CACHE_CATEGORY_ALBUM_INFO,
78    CACHE_CATEGORY_ARTIST_INFO,
79    CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
80    CACHE_CATEGORY_FOLDER_IMAGES,
81    CACHE_CATEGORY_PODCAST_METADATA,
82    CONF_ENTRY_CONTENT_TYPE,
83    CONF_ENTRY_CONTENT_TYPE_READ_ONLY,
84    CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
85    CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
86    CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
87    CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
88    CONF_ENTRY_LIBRARY_SYNC_TRACKS,
89    CONF_ENTRY_MISSING_ALBUM_ARTIST,
90    CONF_ENTRY_PATH,
91    IMAGE_EXTENSIONS,
92    PLAYLIST_EXTENSIONS,
93    PODCAST_EPISODE_EXTENSIONS,
94    SUPPORTED_EXTENSIONS,
95    TRACK_EXTENSIONS,
96    IsChapterFile,
97)
98from .helpers import (
99    IGNORE_DIRS,
100    FileSystemItem,
101    get_absolute_path,
102    get_album_dir,
103    get_artist_dir,
104    get_relative_path,
105    sorted_scandir,
106)
107
108if TYPE_CHECKING:
109    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
110    from music_assistant_models.provider import ProviderManifest
111
112    from music_assistant.mass import MusicAssistant
113    from music_assistant.models import ProviderInstanceType
114
115
116isdir = wrap(os.path.isdir)
117isfile = wrap(os.path.isfile)
118exists = wrap(os.path.exists)
119makedirs = wrap(os.makedirs)
120scandir = wrap(os.scandir)
121
122SUPPORTED_FEATURES = {
123    ProviderFeature.BROWSE,
124    ProviderFeature.SEARCH,
125}
126
127
128async def setup(
129    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
130) -> ProviderInstanceType:
131    """Initialize provider(instance) with given configuration."""
132    base_path = cast("str", config.get_value(CONF_PATH))
133    return LocalFileSystemProvider(mass, manifest, config, base_path)
134
135
136async def get_config_entries(
137    mass: MusicAssistant,
138    instance_id: str | None = None,
139    action: str | None = None,
140    values: dict[str, ConfigValueType] | None = None,
141) -> tuple[ConfigEntry, ...]:
142    """
143    Return Config entries to setup this provider.
144
145    instance_id: id of an existing provider instance (None if new instance setup).
146    action: [optional] action key called from config entries UI.
147    values: the (intermediate) raw values for config entries sent with the action.
148    """
149    # ruff: noqa: ARG001
150    base_entries = [
151        CONF_ENTRY_PATH,
152        CONF_ENTRY_MISSING_ALBUM_ARTIST,
153        CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
154        CONF_ENTRY_LIBRARY_SYNC_TRACKS,
155        CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
156        CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
157        CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
158    ]
159    if instance_id is None or values is None:
160        return (CONF_ENTRY_CONTENT_TYPE, *base_entries)
161    return (CONF_ENTRY_CONTENT_TYPE_READ_ONLY, *base_entries)
162
163
164class LocalFileSystemProvider(MusicProvider):
165    """
166    Implementation of a musicprovider for (local) files.
167
168    Reads ID3 tags from file and falls back to parsing filename.
169    Optionally reads metadata from nfo files and images in folder structure <artist>/<album>.
170    Supports m3u files for playlists.
171    """
172
173    def __init__(
174        self,
175        mass: MusicAssistant,
176        manifest: ProviderManifest,
177        config: ProviderConfig,
178        base_path: str,
179    ) -> None:
180        """Initialize MusicProvider."""
181        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
182        self.base_path: str = base_path
183        self.write_access: bool = False
184        self.sync_running: bool = False
185        self.media_content_type = cast("str", config.get_value(CONF_ENTRY_CONTENT_TYPE.key))
186
187    @property
188    def supported_features(self) -> set[ProviderFeature]:
189        """Return the features supported by this Provider."""
190        base_features = {*SUPPORTED_FEATURES}
191        if self.media_content_type == "audiobooks":
192            return {ProviderFeature.LIBRARY_AUDIOBOOKS, *base_features}
193        if self.media_content_type == "podcasts":
194            return {ProviderFeature.LIBRARY_PODCASTS, *base_features}
195        music_features = {
196            ProviderFeature.LIBRARY_ALBUMS,
197            ProviderFeature.LIBRARY_ARTISTS,
198            ProviderFeature.LIBRARY_TRACKS,
199            ProviderFeature.LIBRARY_PLAYLISTS,
200            *base_features,
201        }
202        if self.write_access:
203            music_features.add(ProviderFeature.PLAYLIST_TRACKS_EDIT)
204            music_features.add(ProviderFeature.PLAYLIST_CREATE)
205        return music_features
206
207    @property
208    def is_streaming_provider(self) -> bool:
209        """Return True if the provider is a streaming provider."""
210        return False
211
212    @property
213    def instance_name_postfix(self) -> str | None:
214        """Return a (default) instance name postfix for this provider instance."""
215        return self.base_path.split(os.sep)[-1]
216
217    async def handle_async_init(self) -> None:
218        """Handle async initialization of the provider."""
219        if not await isdir(self.base_path):
220            msg = f"Music Directory {self.base_path} does not exist"
221            raise SetupFailedError(msg)
222        await self.check_write_access()
223
224    async def search(
225        self,
226        search_query: str,
227        media_types: list[MediaType] | None,
228        limit: int = 5,
229    ) -> SearchResults:
230        """Perform search on this file based musicprovider."""
231        result = SearchResults()
232        # searching the filesystem is slow and unreliable,
233        # so instead we just query the db...
234        if media_types is None or MediaType.TRACK in media_types:
235            result.tracks = await self.mass.music.tracks.get_library_items_by_query(
236                search=search_query, provider_filter=[self.instance_id], limit=limit
237            )
238
239        if media_types is None or MediaType.ALBUM in media_types:
240            result.albums = await self.mass.music.albums.get_library_items_by_query(
241                search=search_query,
242                provider_filter=[self.instance_id],
243                limit=limit,
244            )
245
246        if media_types is None or MediaType.ARTIST in media_types:
247            result.artists = await self.mass.music.artists.get_library_items_by_query(
248                search=search_query,
249                provider_filter=[self.instance_id],
250                limit=limit,
251            )
252        if media_types is None or MediaType.PLAYLIST in media_types:
253            result.playlists = await self.mass.music.playlists.get_library_items_by_query(
254                search=search_query,
255                provider_filter=[self.instance_id],
256                limit=limit,
257            )
258        if media_types is None or MediaType.AUDIOBOOK in media_types:
259            result.audiobooks = await self.mass.music.audiobooks.get_library_items_by_query(
260                search=search_query,
261                provider_filter=[self.instance_id],
262                limit=limit,
263            )
264        if media_types is None or MediaType.PODCAST in media_types:
265            result.podcasts = await self.mass.music.podcasts.get_library_items_by_query(
266                search=search_query,
267                provider_filter=[self.instance_id],
268                limit=limit,
269            )
270        return result
271
272    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
273        """Browse this provider's items.
274
275        :param path: The path to browse, (e.g. provid://artists).
276        """
277        # for audiobooks and podcasts we just return all library items
278        if self.media_content_type == "podcasts":
279            return await self.mass.music.podcasts.library_items(provider=self.instance_id)
280        if self.media_content_type == "audiobooks":
281            return await self.mass.music.audiobooks.library_items(provider=self.instance_id)
282        items: list[MediaItemType | ItemMapping | BrowseFolder] = []
283        item_path = path.split("://", 1)[1]
284        if not item_path:
285            item_path = ""
286        abs_path = self.get_absolute_path(item_path)
287        for item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True):
288            if not item.is_dir and ("." not in item.filename or not item.ext):
289                # skip system files and files without extension
290                continue
291
292            if item.is_dir:
293                items.append(
294                    BrowseFolder(
295                        item_id=item.relative_path,
296                        provider=self.instance_id,
297                        path=f"{self.instance_id}://{item.relative_path}",
298                        name=item.filename,
299                        # mark folder as playable, assuming it contains tracks underneath
300                        is_playable=True,
301                    )
302                )
303            elif item.ext in TRACK_EXTENSIONS:
304                items.append(
305                    ItemMapping(
306                        media_type=MediaType.TRACK,
307                        item_id=item.relative_path,
308                        provider=self.instance_id,
309                        name=item.filename,
310                    )
311                )
312            elif item.ext in PLAYLIST_EXTENSIONS:
313                items.append(
314                    ItemMapping(
315                        media_type=MediaType.PLAYLIST,
316                        item_id=item.relative_path,
317                        provider=self.instance_id,
318                        name=item.filename,
319                    )
320                )
321        return items
322
323    async def sync_library(self, media_type: MediaType) -> None:
324        """Run library sync for this provider."""
325        if media_type in (MediaType.ARTIST, MediaType.ALBUM):
326            # artists and albums are synced as part of track sync
327            return
328        assert self.mass.music.database
329        start_time = time.time()
330        if self.sync_running:
331            self.logger.warning("Library sync already running for %s", self.name)
332            return
333        self.logger.info(
334            "Started Library sync for %s",
335            self.name,
336        )
337        file_checksums: dict[str, str] = {}
338        # NOTE: we always run a scan of the entire library, as we need to detect changes
339        # we ignore any given mediatype(s) and just scan all supported files
340        query = (
341            f"SELECT provider_item_id, details FROM {DB_TABLE_PROVIDER_MAPPINGS} "
342            f"WHERE provider_instance = '{self.instance_id}' "
343            f"AND media_type in ('track', 'playlist', 'audiobook', 'podcast_episode')"
344        )
345        for db_row in await self.mass.music.database.get_rows_from_query(query, limit=0):
346            file_checksums[db_row["provider_item_id"]] = str(db_row["details"])
347        # find all supported files in the base directory and all subfolders
348        # we work bottom up, as-in we derive all info from the tracks
349        cur_filenames = set()
350        prev_filenames = set(file_checksums.keys())
351
352        # NOTE: we do the entire traversing of the directory structure, including parsing tags
353        # in a single executor thread to save the overhead of having to spin up tons of tasks
354        def listdir(path: str) -> Iterator[FileSystemItem]:
355            """Recursively traverse directory entries."""
356            for item in os.scandir(path):
357                # ignore invalid filenames
358                if item.name in IGNORE_DIRS or item.name.startswith((".", "_")):
359                    continue
360                if item.is_dir(follow_symlinks=False):
361                    yield from listdir(item.path)
362                elif item.is_file(follow_symlinks=False):
363                    # skip files without extension
364                    if "." not in item.name:
365                        continue
366                    ext = item.name.rsplit(".", 1)[1].lower()
367                    if ext not in SUPPORTED_EXTENSIONS:
368                        # skip unsupported file extension
369                        continue
370                    try:
371                        yield FileSystemItem.from_dir_entry(item, self.base_path)
372                    except OSError as err:
373                        # Skip files that cannot be stat'd (e.g., invalid encoding on SMB mounts)
374                        # This typically happens with emoji or special unicode characters
375                        self.logger.debug(
376                            "Skipping file %s due to stat error: %s",
377                            item.path,
378                            str(err),
379                        )
380
381        def run_sync() -> None:
382            """Run the actual sync (in an executor job)."""
383            self.sync_running = True
384            try:
385                for item in listdir(self.base_path):
386                    prev_checksum = file_checksums.get(item.relative_path)
387                    if self._process_item(item, prev_checksum):
388                        cur_filenames.add(item.relative_path)
389            finally:
390                self.sync_running = False
391
392        await asyncio.to_thread(run_sync)
393
394        end_time = time.time()
395        self.logger.info(
396            "Library sync for %s completed in %.2f seconds",
397            self.name,
398            end_time - start_time,
399        )
400        # work out deletions
401        deleted_files = prev_filenames - cur_filenames
402        await self._process_deletions(deleted_files)
403
404        # process orphaned albums and artists
405        await self._process_orphaned_albums_and_artists()
406
407    def _process_item(self, item: FileSystemItem, prev_checksum: str | None) -> bool:
408        """Process a single item. NOT async friendly."""
409        try:
410            self.logger.log(VERBOSE_LOG_LEVEL, "Processing: %s", item.relative_path)
411
412            # ignore playlists that are in album directories
413            # we need to run this check early because the setting may have changed
414            if (
415                item.ext in PLAYLIST_EXTENSIONS
416                and self.media_content_type == "music"
417                and self.config.get_value(CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS.key)
418            ):
419                # we assume this in a bit of a dumb way by just checking if the playlist
420                # is more than 1 level deep in the directory structure
421                if len(item.relative_path.split("/")) > 2:
422                    return False
423
424            # return early if the item did not change (checksum still the same)
425            if item.checksum == prev_checksum:
426                return True
427
428            if item.ext in TRACK_EXTENSIONS and self.media_content_type == "music":
429                # handle track item
430                tags = parse_tags(item.absolute_path, item.file_size)
431
432                async def process_track() -> None:
433                    track = await self._parse_track(item, tags)
434                    # add/update track to db
435                    # note that filesystem items are always overwriting existing info
436                    # when they are detected as changed
437                    track.favorite = False  # TODO: implement favorite status based on rating ?
438                    await self.mass.music.tracks.add_item_to_library(
439                        track, overwrite_existing=prev_checksum is not None
440                    )
441
442                asyncio.run_coroutine_threadsafe(process_track(), self.mass.loop).result()
443                return True
444
445            if item.ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
446                # handle audiobook item
447                tags = parse_tags(item.absolute_path, item.file_size)
448
449                async def process_audiobook() -> None:
450                    try:
451                        audiobook = await self._parse_audiobook(item, tags)
452                    except IsChapterFile:
453                        return
454                    # add/update audiobook to db
455                    # note that filesystem items are always overwriting existing info
456                    # when they are detected as changed
457                    await self.mass.music.audiobooks.add_item_to_library(
458                        audiobook, overwrite_existing=prev_checksum is not None
459                    )
460
461                asyncio.run_coroutine_threadsafe(process_audiobook(), self.mass.loop).result()
462                return True
463
464            if item.ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
465                # handle podcast(episode) item
466                tags = parse_tags(item.absolute_path, item.file_size)
467
468                async def process_episode() -> None:
469                    episode = await self._parse_podcast_episode(item, tags)
470                    assert isinstance(episode.podcast, Podcast)
471                    # add/update episode to db
472                    # note that filesystem items are always overwriting existing info
473                    # when they are detected as changed
474                    await self.mass.music.podcasts.add_item_to_library(
475                        episode.podcast, overwrite_existing=prev_checksum is not None
476                    )
477
478                asyncio.run_coroutine_threadsafe(process_episode(), self.mass.loop).result()
479                return True
480
481            if item.ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
482                # handle playlist item
483
484                async def process_playlist() -> None:
485                    playlist = await self.get_playlist(item.relative_path)
486                    # add/update playlist to db
487                    await self.mass.music.playlists.add_item_to_library(
488                        playlist,
489                        overwrite_existing=prev_checksum is not None,
490                    )
491
492                asyncio.run_coroutine_threadsafe(process_playlist(), self.mass.loop).result()
493                return True
494
495        except Exception as err:
496            # we don't want the whole sync to crash on one file so we catch all exceptions here
497            self.logger.error(
498                "Error processing %s - %s",
499                item.relative_path,
500                str(err),
501                exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
502            )
503        return False
504
505    async def _process_orphaned_albums_and_artists(self) -> None:
506        """Process deletion of orphaned albums and artists."""
507        assert self.mass.music.database
508        # Remove albums without any tracks
509        query = (
510            f"SELECT item_id FROM {DB_TABLE_ALBUMS} "
511            f"WHERE item_id not in ( SELECT album_id from {DB_TABLE_ALBUM_TRACKS}) "
512            f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
513            f"WHERE provider_instance = '{self.instance_id}' and media_type = 'album' )"
514        )
515        for db_row in await self.mass.music.database.get_rows_from_query(
516            query,
517            limit=100000,
518        ):
519            await self.mass.music.albums.remove_item_from_library(db_row["item_id"])
520
521        # Remove artists without any tracks or albums
522        query = (
523            f"SELECT item_id FROM {DB_TABLE_ARTISTS} "
524            f"WHERE item_id not in "
525            f"( select artist_id from {DB_TABLE_TRACK_ARTISTS} "
526            f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} ) "
527            f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
528            f"WHERE provider_instance = '{self.instance_id}' and media_type = 'artist' )"
529        )
530        for db_row in await self.mass.music.database.get_rows_from_query(
531            query,
532            limit=100000,
533        ):
534            await self.mass.music.artists.remove_item_from_library(db_row["item_id"])
535
536    async def _process_deletions(self, deleted_files: set[str]) -> None:
537        """Process all deletions."""
538        # process deleted tracks/playlists
539        album_ids = set()
540        artist_ids = set()
541        for file_path in deleted_files:
542            _, ext = file_path.rsplit(".", 1)
543            if ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts":
544                controller = self.mass.music.get_controller(MediaType.PODCAST_EPISODE)
545            elif ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks":
546                controller = self.mass.music.get_controller(MediaType.AUDIOBOOK)
547            elif ext in PLAYLIST_EXTENSIONS and self.media_content_type == "music":
548                controller = self.mass.music.get_controller(MediaType.PLAYLIST)
549            elif ext in TRACK_EXTENSIONS and self.media_content_type == "music":
550                controller = self.mass.music.get_controller(MediaType.TRACK)
551            else:
552                # unsupported file extension?
553                continue
554
555            if library_item := await controller.get_library_item_by_prov_id(
556                file_path, self.instance_id
557            ):
558                if is_track(library_item):
559                    if library_item.album:
560                        album_ids.add(library_item.album.item_id)
561                        # need to fetch the library album to resolve the itemmapping
562                        db_album = await self.mass.music.albums.get_library_item(
563                            library_item.album.item_id
564                        )
565                        for artist in db_album.artists:
566                            artist_ids.add(artist.item_id)
567                    for artist in library_item.artists:
568                        artist_ids.add(artist.item_id)
569                await controller.remove_item_from_library(library_item.item_id)
570        # check if any albums need to be cleaned up
571        for album_id in album_ids:
572            if not await self.mass.music.albums.tracks(album_id, "library"):
573                await self.mass.music.albums.remove_item_from_library(album_id)
574        # check if any artists need to be cleaned up
575        for artist_id in artist_ids:
576            artist_albums = await self.mass.music.artists.albums(artist_id, "library")
577            artist_tracks = await self.mass.music.artists.tracks(artist_id, "library")
578            if not (artist_albums or artist_tracks):
579                await self.mass.music.artists.remove_item_from_library(artist_id)
580
581    async def get_artist(self, prov_artist_id: str) -> Artist:
582        """Get full artist details by id."""
583        db_artist = await self.mass.music.artists.get_library_item_by_prov_id(
584            prov_artist_id, self.instance_id
585        )
586        if not db_artist:
587            # this may happen if the artist is not in the db yet
588            # e.g. when browsing the filesystem
589            if await self.exists(prov_artist_id):
590                return await self._parse_artist(prov_artist_id, artist_path=prov_artist_id)
591            return await self._parse_artist(prov_artist_id)
592
593        # prov_artist_id is either an actual (relative) path or a name (as fallback)
594        safe_artist_name = create_safe_string(prov_artist_id, lowercase=False, replace_space=False)
595        if await self.exists(prov_artist_id):
596            artist_path = prov_artist_id
597        elif await self.exists(safe_artist_name):
598            artist_path = safe_artist_name
599        else:
600            for prov_mapping in db_artist.provider_mappings:
601                if prov_mapping.provider_instance != self.instance_id:
602                    continue
603                if prov_mapping.url:
604                    artist_path = prov_mapping.url
605                    break
606            else:
607                # this is an artist without an actual path on disk
608                # return the info we already have in the db
609                return db_artist
610        return await self._parse_artist(
611            db_artist.name,
612            sort_name=db_artist.sort_name,
613            mbid=db_artist.mbid,
614            artist_path=artist_path,
615        )
616
617    async def get_album(self, prov_album_id: str) -> Album:
618        """Get full album details by id."""
619        for track in await self.get_album_tracks(prov_album_id):
620            for prov_mapping in track.provider_mappings:
621                if prov_mapping.provider_instance == self.instance_id:
622                    file_item = await self.resolve(prov_mapping.item_id)
623                    tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
624                    full_track = await self._parse_track(file_item, tags)
625                    assert isinstance(full_track.album, Album)
626                    return full_track.album
627        msg = f"Album not found: {prov_album_id}"
628        raise MediaNotFoundError(msg)
629
630    async def get_track(self, prov_track_id: str) -> Track:
631        """Get full track details by id."""
632        # ruff: noqa: PLR0915
633        if not await self.exists(prov_track_id):
634            msg = f"Track path does not exist: {prov_track_id}"
635            raise MediaNotFoundError(msg)
636
637        file_item = await self.resolve(prov_track_id)
638        tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
639        return await self._parse_track(file_item, tags=tags, full_album_metadata=True)
640
641    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
642        """Get full playlist details by id."""
643        if not await self.exists(prov_playlist_id):
644            msg = f"Playlist path does not exist: {prov_playlist_id}"
645            raise MediaNotFoundError(msg)
646
647        file_item = await self.resolve(prov_playlist_id)
648        playlist = Playlist(
649            item_id=file_item.relative_path,
650            provider=self.instance_id,
651            name=file_item.name,
652            provider_mappings={
653                ProviderMapping(
654                    item_id=file_item.relative_path,
655                    provider_domain=self.domain,
656                    provider_instance=self.instance_id,
657                    details=file_item.checksum,
658                )
659            },
660        )
661        playlist.is_editable = ProviderFeature.PLAYLIST_TRACKS_EDIT in self.supported_features
662        # only playlists in the root are editable - all other are read only
663        if "/" in prov_playlist_id or "\\" in prov_playlist_id:
664            playlist.is_editable = False
665        # we do not (yet) have support to edit/create pls playlists, only m3u files can be edited
666        if file_item.ext == "pls":
667            playlist.is_editable = False
668        playlist.owner = self.name
669        return playlist
670
671    async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
672        """Get full audiobook details by id."""
673        # ruff: noqa: PLR0915
674        if not await self.exists(prov_audiobook_id):
675            msg = f"Audiobook path does not exist: {prov_audiobook_id}"
676            raise MediaNotFoundError(msg)
677
678        file_item = await self.resolve(prov_audiobook_id)
679        tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
680        return await self._parse_audiobook(file_item, tags=tags)
681
682    async def get_podcast(self, prov_podcast_id: str) -> Podcast:
683        """Get full podcast details by id."""
684        async for episode in self.get_podcast_episodes(prov_podcast_id):
685            assert isinstance(episode.podcast, Podcast)
686            return episode.podcast
687        msg = f"Podcast not found: {prov_podcast_id}"
688        raise MediaNotFoundError(msg)
689
690    async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
691        """Get album tracks for given album id."""
692        # filesystem items are always stored in db so we can query the database
693        db_album = await self.mass.music.albums.get_library_item_by_prov_id(
694            prov_album_id, self.instance_id
695        )
696        if db_album is None:
697            msg = f"Album not found: {prov_album_id}"
698            raise MediaNotFoundError(msg)
699        album_tracks = await self.mass.music.albums.get_library_album_tracks(db_album.item_id)
700        return [
701            track
702            for track in album_tracks
703            if any(x.provider_instance == self.instance_id for x in track.provider_mappings)
704        ]
705
706    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
707        """Get playlist tracks."""
708        result: list[Track] = []
709        if page > 0:
710            # paging not (yet) supported
711            return result
712        if not await self.exists(prov_playlist_id):
713            msg = f"Playlist path does not exist: {prov_playlist_id}"
714            raise MediaNotFoundError(msg)
715
716        file_item = await self.resolve(prov_playlist_id)
717        # We are using the checksum of the playlist file here to invalidate the cache
718        # when a change has been made to the playlist file (ie track addition/deletion)
719        cache_checksum = file_item.checksum
720
721        cache_key = f"get_playlist_tracks.{prov_playlist_id}"
722        cached_data = await self.mass.cache.get(
723            cache_key,
724            provider=self.instance_id,
725            checksum=cache_checksum,
726            category=0,
727        )
728        if cached_data is not None:
729            if cached_data and isinstance(cached_data[0], dict):
730                return [Track.from_dict(track_dict) for track_dict in cached_data]
731            return cast("list[Track]", cached_data)
732
733        _, ext = prov_playlist_id.rsplit(".", 1)
734        try:
735            # get playlist file contents
736            playlist_filename = self.get_absolute_path(prov_playlist_id)
737            async with aiofiles.open(playlist_filename, mode="rb") as _file:
738                playlist_data_raw = await _file.read()
739                encoding = await detect_charset(playlist_data_raw)
740                playlist_data = playlist_data_raw.decode(encoding, errors="replace")
741
742            if ext in ("m3u", "m3u8"):
743                playlist_lines = parse_m3u(playlist_data)
744            else:
745                playlist_lines = parse_pls(playlist_data)
746
747            for idx, playlist_line in enumerate(playlist_lines, 1):
748                if "#EXT" in playlist_line.path:
749                    continue
750                if track := await self._parse_playlist_line(
751                    playlist_line.path, os.path.dirname(prov_playlist_id)
752                ):
753                    track.position = idx
754                    result.append(track)
755
756        except Exception as err:
757            self.logger.warning(
758                "Error while parsing playlist %s: %s",
759                prov_playlist_id,
760                str(err),
761                exc_info=err if self.logger.isEnabledFor(10) else None,
762            )
763
764        await self.mass.cache.set(
765            key=cache_key,
766            data=result,
767            expiration=3600 * 24,  # Cache for 24 hours
768            provider=self.instance_id,
769            checksum=cache_checksum,
770            category=0,
771        )
772
773        return result
774
775    async def get_podcast_episodes(
776        self, prov_podcast_id: str
777    ) -> AsyncGenerator[PodcastEpisode, None]:
778        """Get podcast episodes for given podcast id."""
779        episodes: list[PodcastEpisode] = []
780
781        async def _process_podcast_episode(item: FileSystemItem) -> None:
782            tags = await async_parse_tags(item.absolute_path, item.file_size)
783            try:
784                episode = await self._parse_podcast_episode(item, tags)
785            except MusicAssistantError as err:
786                self.logger.warning(
787                    "Could not parse uri/file %s to podcast episode: %s",
788                    item.relative_path,
789                    str(err),
790                )
791            else:
792                episodes.append(episode)
793
794        async with TaskManager(self.mass, 25) as tm:
795            for item in await asyncio.to_thread(sorted_scandir, self.base_path, prov_podcast_id):
796                if "." not in item.relative_path or item.is_dir:
797                    continue
798                if item.ext not in PODCAST_EPISODE_EXTENSIONS:
799                    continue
800                tm.create_task(_process_podcast_episode(item))
801
802        for episode in episodes:
803            yield episode
804
805    async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None:
806        """Try to parse a track from a playlist line."""
807        try:
808            line = line.replace("file://", "").strip()
809            # try to resolve the filename (both normal and url decoded):
810            # - as an absolute path
811            # - relative to the playlist path
812            # - relative to our base path
813            # - relative to the playlist path with a leading slash
814            for _line in (line, urllib.parse.unquote(line)):
815                for filename in (
816                    # try to resolve the line by resolving it against the (absolute) playlist path
817                    # use the path.resolve step in between to auto-resolve parent item references
818                    (Path(self.get_absolute_path(playlist_path)) / _line).resolve().as_posix(),
819                    # try to resolve the line as a full absolute (or relative to music dir) path
820                    _line,
821                ):
822                    with contextlib.suppress(FileNotFoundError):
823                        file_item = await self.resolve(filename)
824                        tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
825                        return await self._parse_track(file_item, tags)
826            # all attempts failed
827            raise MediaNotFoundError("Invalid path/uri")
828
829        except MusicAssistantError as err:
830            self.logger.warning("Could not parse %s to track: %s", line, str(err))
831
832        return None
833
834    async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
835        """Add track(s) to playlist."""
836        if not await self.exists(prov_playlist_id):
837            msg = f"Playlist path does not exist: {prov_playlist_id}"
838            raise MediaNotFoundError(msg)
839        playlist_filename = self.get_absolute_path(prov_playlist_id)
840        async with aiofiles.open(playlist_filename, encoding="utf-8") as _file:
841            playlist_data = await _file.read()
842        for file_path in prov_track_ids:
843            track = await self.get_track(file_path)
844            playlist_data += f"\n#EXTINF:{track.duration or 0},{track.name}\n{file_path}\n"
845
846        # write playlist file (always in utf-8)
847        async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
848            await _file.write(playlist_data)
849
850    async def remove_playlist_tracks(
851        self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
852    ) -> None:
853        """Remove track(s) from playlist."""
854        if not await self.exists(prov_playlist_id):
855            msg = f"Playlist path does not exist: {prov_playlist_id}"
856            raise MediaNotFoundError(msg)
857        _, ext = prov_playlist_id.rsplit(".", 1)
858        # get playlist file contents
859        playlist_filename = self.get_absolute_path(prov_playlist_id)
860        async with aiofiles.open(playlist_filename, encoding="utf-8") as _file:
861            playlist_data = await _file.read()
862        # get current contents first
863        if ext in ("m3u", "m3u8"):
864            playlist_items = parse_m3u(playlist_data)
865        else:
866            playlist_items = parse_pls(playlist_data)
867        # remove items by index
868        for i in sorted(positions_to_remove, reverse=True):
869            # position = index + 1
870            del playlist_items[i - 1]
871        # build new playlist data
872        new_playlist_data = "#EXTM3U\n"
873        for item in playlist_items:
874            new_playlist_data += f"\n#EXTINF:{item.length or 0},{item.title}\n{item.path}\n"
875        async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
876            await _file.write(new_playlist_data)
877
878    async def create_playlist(self, name: str) -> Playlist:
879        """Create a new playlist on provider with given name."""
880        # creating a new playlist on the filesystem is as easy
881        # as creating a new (empty) file with the m3u extension...
882        # filename = await self.resolve(f"{name}.m3u")
883        filename = f"{name}.m3u"
884        playlist_filename = self.get_absolute_path(filename)
885        async with aiofiles.open(playlist_filename, "w", encoding="utf-8") as _file:
886            await _file.write("#EXTM3U\n")
887        return await self.get_playlist(filename)
888
889    async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
890        """Return the content details for the given track when it will be streamed."""
891        try:
892            if media_type == MediaType.AUDIOBOOK:
893                return await self._get_stream_details_for_audiobook(item_id)
894            if media_type == MediaType.PODCAST_EPISODE:
895                return await self._get_stream_details_for_podcast_episode(item_id)
896            return await self._get_stream_details_for_track(item_id)
897        except FileNotFoundError:
898            self.logger.warning(
899                "File not found for media item %s",
900                item_id,
901            )
902            msg = f"Media file not found: {item_id}"
903            raise MediaNotFoundError(msg)
904
905    async def resolve_image(self, path: str) -> str | bytes:
906        """
907        Resolve an image from an image path.
908
909        This either returns (a generator to get) raw bytes of the image or
910        a string with an http(s) URL or local path that is accessible from the server.
911        """
912        file_item = await self.resolve(path)
913        return file_item.absolute_path
914
915    async def _parse_track(
916        self, file_item: FileSystemItem, tags: AudioTags, full_album_metadata: bool = False
917    ) -> Track:
918        """Parse full track details from file tags."""
919        # ruff: noqa: PLR0915
920        name, version = parse_title_and_version(tags.title, tags.version)
921        track = Track(
922            item_id=file_item.relative_path,
923            provider=self.instance_id,
924            name=name,
925            sort_name=tags.title_sort,
926            version=version,
927            provider_mappings={
928                ProviderMapping(
929                    item_id=file_item.relative_path,
930                    provider_domain=self.domain,
931                    provider_instance=self.instance_id,
932                    audio_format=AudioFormat(
933                        content_type=ContentType.try_parse(file_item.ext or tags.format),
934                        sample_rate=tags.sample_rate,
935                        bit_depth=tags.bits_per_sample,
936                        channels=tags.channels,
937                        bit_rate=tags.bit_rate,
938                    ),
939                    details=file_item.checksum,
940                    in_library=True,
941                )
942            },
943            disc_number=tags.disc or 0,
944            track_number=tags.track or 0,
945            date_added=(
946                datetime.fromtimestamp(file_item.created_at, tz=UTC)
947                if file_item.created_at
948                else None
949            ),
950        )
951
952        if isrc_tags := tags.isrc:
953            for isrsc in isrc_tags:
954                track.external_ids.add((ExternalID.ISRC, isrsc))
955
956        if acoustid := tags.get("acoustid"):
957            track.external_ids.add((ExternalID.ACOUSTID, acoustid))
958
959        # album
960        album = track.album = (
961            await self._parse_album(
962                track_path=file_item.relative_path,
963                track_tags=tags,
964                track_created_at=file_item.created_at,
965            )
966            if tags.album
967            else None
968        )
969
970        # track artist(s)
971        for index, track_artist_str in enumerate(tags.artists):
972            # prefer album artist if match
973            if album and (
974                album_artist_match := next(
975                    (x for x in album.artists if x.name == track_artist_str), None
976                )
977            ):
978                track.artists.append(album_artist_match)
979                continue
980            artist = await self._parse_artist(
981                track_artist_str,
982                sort_name=(
983                    tags.artist_sort_names[index] if index < len(tags.artist_sort_names) else None
984                ),
985                mbid=(
986                    tags.musicbrainz_artistids[index]
987                    if index < len(tags.musicbrainz_artistids)
988                    else None
989                ),
990            )
991            track.artists.append(artist)
992
993        # handle embedded cover image
994        if tags.has_cover_image:
995            # we do not actually embed the image in the metadata because that would consume too
996            # much space and bandwidth. Instead we set the filename as value so the image can
997            # be retrieved later in realtime.
998            track.metadata.images = UniqueList(
999                [
1000                    MediaItemImage(
1001                        type=ImageType.THUMB,
1002                        path=file_item.relative_path,
1003                        provider=self.instance_id,
1004                        remotely_accessible=False,
1005                    )
1006                ]
1007            )
1008
1009        # copy (embedded) album image from track (if the album itself doesn't have an image)
1010        if album and not album.image and track.image:
1011            album.metadata.images = UniqueList([track.image])
1012
1013        # parse other info
1014        track.duration = int(tags.duration or 0)
1015        track.metadata.genres = set(tags.genres)
1016        if tags.disc:
1017            track.disc_number = tags.disc
1018        if tags.track:
1019            track.track_number = tags.track
1020        track.metadata.copyright = tags.get("copyright")
1021        track.metadata.lyrics = tags.lyrics
1022        track.metadata.grouping = tags.get("grouping")
1023        track.metadata.description = tags.get("comment")
1024        explicit_tag = tags.get("itunesadvisory")
1025        if explicit_tag is not None:
1026            track.metadata.explicit = explicit_tag == "1"
1027        if tags.musicbrainz_recordingid:
1028            track.mbid = tags.musicbrainz_recordingid
1029
1030        # handle (optional) loudness measurement tag(s)
1031        if tags.track_loudness is not None:
1032            self.mass.create_task(
1033                self.mass.music.set_loudness(
1034                    track.item_id,
1035                    self.instance_id,
1036                    tags.track_loudness,
1037                    tags.track_album_loudness,
1038                )
1039            )
1040
1041        # possible lrclib metadata
1042        # synced lyrics are saved as "filename.lrc" by lrcget alongside
1043        # the actual file location - just change the file extension
1044        assert file_item.ext is not None  # for type checking
1045        lrc_path = f"{file_item.absolute_path.removesuffix(file_item.ext)}lrc"
1046        if await self.exists(lrc_path):
1047            try:
1048                async with aiofiles.open(lrc_path, encoding="utf-8") as lrc_file:
1049                    track.metadata.lrc_lyrics = await lrc_file.read()
1050            except Exception as err:
1051                self.logger.warning(
1052                    "Failed to read lyrics file %s: %s",
1053                    lrc_path,
1054                    str(err),
1055                )
1056
1057        return track
1058
1059    async def _parse_artist(
1060        self,
1061        name: str,
1062        album_dir: str | None = None,
1063        sort_name: str | None = None,
1064        mbid: str | None = None,
1065        artist_path: str | None = None,
1066    ) -> Artist:
1067        """Parse full (album) Artist."""
1068        if not artist_path:
1069            # we need to hunt for the artist (metadata) path on disk
1070            # this can either be relative to the album path or at root level
1071            # check if we have an artist folder for this artist at root level
1072            safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
1073            if await self.exists(name):
1074                artist_path = name
1075            elif await self.exists(safe_artist_name):
1076                artist_path = safe_artist_name
1077            elif album_dir and (foldermatch := get_artist_dir(name, album_dir=album_dir)):
1078                # try to find (album)artist folder based on album path
1079                artist_path = foldermatch
1080            else:
1081                # check if we have an existing item to retrieve the artist path
1082                async for item in self.mass.music.artists.iter_library_items(
1083                    search=name, provider=self.instance_id
1084                ):
1085                    if not compare_strings(name, item.name):
1086                        continue
1087                    for prov_mapping in item.provider_mappings:
1088                        if prov_mapping.provider_instance != self.instance_id:
1089                            continue
1090                        if prov_mapping.url:
1091                            artist_path = prov_mapping.url
1092                            break
1093                    if artist_path:
1094                        break
1095
1096        # prefer (short lived) cache for a bit more speed
1097        if artist_path and (
1098            cache := await self.cache.get(
1099                key=artist_path, provider=self.instance_id, category=CACHE_CATEGORY_ARTIST_INFO
1100            )
1101        ):
1102            return cast("Artist", cache)
1103
1104        prov_artist_id = artist_path or name
1105        artist = Artist(
1106            item_id=prov_artist_id,
1107            provider=self.instance_id,
1108            name=name,
1109            sort_name=sort_name,
1110            provider_mappings={
1111                ProviderMapping(
1112                    item_id=prov_artist_id,
1113                    provider_domain=self.domain,
1114                    provider_instance=self.instance_id,
1115                    url=artist_path,
1116                    in_library=True,
1117                )
1118            },
1119        )
1120        if mbid:
1121            artist.mbid = mbid
1122        if not artist_path:
1123            return artist
1124
1125        # grab additional metadata within the Artist's folder
1126        nfo_file = os.path.join(artist_path, "artist.nfo")
1127        if await self.exists(nfo_file):
1128            # found NFO file with metadata
1129            # https://kodi.wiki/view/NFO_files/Artists
1130            nfo_file = self.get_absolute_path(nfo_file)
1131            async with aiofiles.open(nfo_file) as _file:
1132                data = await _file.read()
1133            info = await asyncio.to_thread(xmltodict.parse, data)
1134            info = info["artist"]
1135            artist.name = info.get("title", info.get("name", name))
1136            if sort_name := info.get("sortname"):
1137                artist.sort_name = sort_name
1138            if mbid := info.get("musicbrainzartistid"):
1139                artist.mbid = mbid
1140            if description := info.get("biography"):
1141                artist.metadata.description = description
1142            if genre := info.get("genre"):
1143                artist.metadata.genres = set(split_items(genre))
1144        # find local images
1145        if images := await self._get_local_images(artist_path, extra_thumb_names=("artist",)):
1146            artist.metadata.images = UniqueList(images)
1147
1148        await self.cache.set(
1149            key=artist_path,
1150            data=artist,
1151            provider=self.instance_id,
1152            category=CACHE_CATEGORY_ARTIST_INFO,
1153            expiration=120,
1154        )
1155
1156        return artist
1157
1158    async def _parse_audiobook(self, file_item: FileSystemItem, tags: AudioTags) -> Audiobook:
1159        """Parse full Audiobook details from file tags."""
1160        # an audiobook can either be a single file with chapters embedded in the file
1161        # or a folder with multiple files (each file being a chapter)
1162        # we only scrape all tags from the first file in the folder
1163        if tags.track and tags.track > 1:
1164            raise IsChapterFile
1165        # in case of a multi-file audiobook, the title is the chapter name
1166        # and the album is the actual audiobook name
1167        # so we prefer the album name as the audiobook name
1168        if tags.album:
1169            book_name = tags.album
1170            sort_name = tags.album_sort
1171        elif (title := tags.tags.get("title")) and tags.track is None:
1172            book_name = title
1173            sort_name = tags.title_sort
1174        else:
1175            # file(s) without tags, use foldername
1176            book_name = file_item.parent_name
1177            sort_name = None
1178
1179        # collect all chapters
1180        total_duration, chapters = await self._get_chapters_for_audiobook(file_item, tags)
1181
1182        audio_book = Audiobook(
1183            item_id=file_item.relative_path,
1184            provider=self.instance_id,
1185            name=book_name,
1186            sort_name=sort_name,
1187            version=tags.version,
1188            duration=total_duration or int(tags.duration or 0),
1189            provider_mappings={
1190                ProviderMapping(
1191                    item_id=file_item.relative_path,
1192                    provider_domain=self.domain,
1193                    provider_instance=self.instance_id,
1194                    audio_format=AudioFormat(
1195                        content_type=ContentType.try_parse(file_item.ext or tags.format),
1196                        sample_rate=tags.sample_rate,
1197                        bit_depth=tags.bits_per_sample,
1198                        channels=tags.channels,
1199                        bit_rate=tags.bit_rate,
1200                    ),
1201                    details=file_item.checksum,
1202                    in_library=True,
1203                )
1204            },
1205        )
1206        audio_book.metadata.chapters = chapters
1207
1208        # handle embedded cover image
1209        if tags.has_cover_image:
1210            # we do not actually embed the image in the metadata because that would consume too
1211            # much space and bandwidth. Instead we set the filename as value so the image can
1212            # be retrieved later in realtime.
1213            audio_book.metadata.add_image(
1214                MediaItemImage(
1215                    type=ImageType.THUMB,
1216                    path=file_item.relative_path,
1217                    provider=self.instance_id,
1218                    remotely_accessible=False,
1219                )
1220            )
1221
1222        # parse other info
1223        audio_book.authors.set(tags.writers or tags.album_artists or tags.artists)
1224        audio_book.metadata.genres = set(tags.genres)
1225        audio_book.metadata.copyright = tags.get("copyright")
1226        audio_book.metadata.lyrics = tags.lyrics
1227        audio_book.metadata.description = tags.get("comment")
1228        explicit_tag = tags.get("itunesadvisory")
1229        if explicit_tag is not None:
1230            audio_book.metadata.explicit = explicit_tag == "1"
1231        if tags.musicbrainz_recordingid:
1232            audio_book.mbid = tags.musicbrainz_recordingid
1233
1234        # try to fetch additional metadata from the folder
1235        if not audio_book.image or not audio_book.metadata.description:
1236            # try to get an image by traversing files in the same folder
1237            abs_path = self.get_absolute_path(file_item.parent_path)
1238            for _item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path):
1239                if "." not in _item.relative_path or _item.is_dir:
1240                    continue
1241                if _item.ext in IMAGE_EXTENSIONS and not audio_book.image:
1242                    audio_book.metadata.add_image(
1243                        MediaItemImage(
1244                            type=ImageType.THUMB,
1245                            path=_item.relative_path,
1246                            provider=self.instance_id,
1247                            remotely_accessible=False,
1248                        )
1249                    )
1250                if _item.ext == "txt" and not audio_book.metadata.description:
1251                    # try to parse a description from a text file
1252                    try:
1253                        async with aiofiles.open(_item.absolute_path, encoding="utf-8") as _file:
1254                            description = await _file.read()
1255                        audio_book.metadata.description = description
1256                    except Exception as err:
1257                        self.logger.warning(
1258                            "Could not read description from file %s: %s",
1259                            _item.relative_path,
1260                            str(err),
1261                        )
1262
1263        # handle (optional) loudness measurement tag(s)
1264        if tags.track_loudness is not None:
1265            self.mass.create_task(
1266                self.mass.music.set_loudness(
1267                    audio_book.item_id,
1268                    self.instance_id,
1269                    tags.track_loudness,
1270                    tags.track_album_loudness,
1271                    media_type=MediaType.AUDIOBOOK,
1272                )
1273            )
1274        return audio_book
1275
1276    async def _parse_podcast_episode(
1277        self, file_item: FileSystemItem, tags: AudioTags
1278    ) -> PodcastEpisode:
1279        """Parse full PodcastEpisode details from file tags."""
1280        # ruff: noqa: PLR0915
1281        podcast_name = tags.album or file_item.parent_name
1282        podcast_path = get_relative_path(self.base_path, file_item.parent_path)
1283        episode = PodcastEpisode(
1284            item_id=file_item.relative_path,
1285            provider=self.instance_id,
1286            name=tags.title,
1287            sort_name=tags.title_sort,
1288            provider_mappings={
1289                ProviderMapping(
1290                    item_id=file_item.relative_path,
1291                    provider_domain=self.domain,
1292                    provider_instance=self.instance_id,
1293                    audio_format=AudioFormat(
1294                        content_type=ContentType.try_parse(file_item.ext or tags.format),
1295                        sample_rate=tags.sample_rate,
1296                        bit_depth=tags.bits_per_sample,
1297                        channels=tags.channels,
1298                        bit_rate=tags.bit_rate,
1299                    ),
1300                    details=file_item.checksum,
1301                    in_library=True,
1302                )
1303            },
1304            position=tags.track or 0,
1305            duration=try_parse_int(tags.duration) or 0,
1306            podcast=Podcast(
1307                item_id=podcast_path,
1308                provider=self.instance_id,
1309                name=podcast_name,
1310                sort_name=tags.album_sort,
1311                publisher=tags.tags.get("publisher"),
1312                provider_mappings={
1313                    ProviderMapping(
1314                        item_id=podcast_path,
1315                        provider_domain=self.domain,
1316                        provider_instance=self.instance_id,
1317                    )
1318                },
1319            ),
1320        )
1321        # handle embedded cover image
1322        if tags.has_cover_image:
1323            # we do not actually embed the image in the metadata because that would consume too
1324            # much space and bandwidth. Instead we set the filename as value so the image can
1325            # be retrieved later in realtime.
1326            episode.metadata.add_image(
1327                MediaItemImage(
1328                    type=ImageType.THUMB,
1329                    path=file_item.relative_path,
1330                    provider=self.instance_id,
1331                    remotely_accessible=False,
1332                )
1333            )
1334        # parse other info
1335        episode.metadata.genres = set(tags.genres)
1336        episode.metadata.copyright = tags.get("copyright")
1337        episode.metadata.lyrics = tags.lyrics
1338        episode.metadata.description = tags.get("comment")
1339        explicit_tag = tags.get("itunesadvisory")
1340        if explicit_tag is not None:
1341            episode.metadata.explicit = explicit_tag == "1"
1342
1343        # handle (optional) chapters
1344        if tags.chapters:
1345            episode.metadata.chapters = [
1346                MediaItemChapter(
1347                    position=chapter.chapter_id,
1348                    name=chapter.title or f"Chapter {chapter.chapter_id}",
1349                    start=chapter.position_start,
1350                    end=chapter.position_end,
1351                )
1352                for chapter in tags.chapters
1353            ]
1354
1355        # try to fetch additional Podcast metadata from the folder
1356        assert isinstance(episode.podcast, Podcast)
1357        if images := await self._get_local_images(file_item.parent_path):
1358            episode.podcast.metadata.images = images
1359        if metadata := await self._get_podcast_metadata(file_item.parent_path):
1360            if title := metadata.get("title"):
1361                episode.podcast.name = title
1362            if sort_name := metadata.get("sorttitle"):
1363                episode.podcast.sort_name = sort_name
1364            if description := metadata.get("description"):
1365                episode.podcast.metadata.description = description
1366            if genres := metadata.get("genres"):
1367                episode.podcast.metadata.genres = set(genres)
1368            if publisher := metadata.get("publisher"):
1369                episode.podcast.publisher = publisher
1370            if image := metadata.get("imageURL"):
1371                episode.podcast.metadata.add_image(
1372                    MediaItemImage(
1373                        type=ImageType.THUMB,
1374                        path=image,
1375                        provider=self.instance_id,
1376                        remotely_accessible=True,
1377                    )
1378                )
1379        # copy (embedded) image from episode (or vice versa)
1380        if not episode.podcast.image and episode.image:
1381            episode.podcast.metadata.add_image(episode.image)
1382        elif not episode.image and episode.podcast.image:
1383            episode.metadata.add_image(episode.podcast.image)
1384
1385        # handle (optional) loudness measurement tag(s)
1386        if tags.track_loudness is not None:
1387            self.mass.create_task(
1388                self.mass.music.set_loudness(
1389                    episode.item_id,
1390                    self.instance_id,
1391                    tags.track_loudness,
1392                    tags.track_album_loudness,
1393                    media_type=MediaType.PODCAST_EPISODE,
1394                )
1395            )
1396        return episode
1397
1398    async def _parse_album(
1399        self, track_path: str, track_tags: AudioTags, track_created_at: int | None = None
1400    ) -> Album:
1401        """Parse Album metadata from Track tags.
1402
1403        :param track_path: Path to the track file.
1404        :param track_tags: Audio tags from the track.
1405        :param track_created_at: Creation timestamp of the track file (Unix epoch).
1406        """
1407        assert track_tags.album
1408        # work out if we have an album and/or disc folder
1409        # track_dir is the folder level where the tracks are located
1410        # this may be a separate disc folder (Disc 1, Disc 2 etc) underneath the album folder
1411        # or this is an album folder with the disc attached
1412        track_dir = os.path.dirname(track_path)
1413        album_dir = get_album_dir(track_dir, track_tags.album)
1414
1415        if album_dir and (
1416            cache := await self.cache.get(
1417                key=album_dir,
1418                provider=self.instance_id,
1419                category=CACHE_CATEGORY_ALBUM_INFO,
1420            )
1421        ):
1422            return cast("Album", cache)
1423
1424        # album artist(s)
1425        album_artists: UniqueList[Artist | ItemMapping] = UniqueList()
1426        if track_tags.album_artists:
1427            for index, album_artist_str in enumerate(track_tags.album_artists):
1428                artist = await self._parse_artist(
1429                    album_artist_str,
1430                    album_dir=album_dir,
1431                    sort_name=(
1432                        track_tags.album_artist_sort_names[index]
1433                        if index < len(track_tags.album_artist_sort_names)
1434                        else None
1435                    ),
1436                    mbid=(
1437                        track_tags.musicbrainz_albumartistids[index]
1438                        if index < len(track_tags.musicbrainz_albumartistids)
1439                        else None
1440                    ),
1441                )
1442                album_artists.append(artist)
1443        else:
1444            # album artist tag is missing, determine fallback
1445            fallback_action = self.config.get_value(CONF_ENTRY_MISSING_ALBUM_ARTIST.key)
1446            if fallback_action == "folder_name" and album_dir:
1447                possible_artist_folder = os.path.dirname(album_dir)
1448                self.logger.warning(
1449                    "%s is missing ID3 tag [albumartist], using foldername %s as fallback",
1450                    track_path,
1451                    possible_artist_folder,
1452                )
1453                album_artist_str = possible_artist_folder.rsplit(os.sep)[-1]
1454                album_artists = UniqueList(
1455                    [await self._parse_artist(name=album_artist_str, album_dir=album_dir)]
1456                )
1457            # fallback to track artists (if defined by user)
1458            elif fallback_action == "track_artist":
1459                self.logger.warning(
1460                    "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
1461                    track_path,
1462                )
1463                album_artists = UniqueList(
1464                    [
1465                        await self._parse_artist(name=track_artist_str, album_dir=album_dir)
1466                        for track_artist_str in track_tags.artists
1467                    ]
1468                )
1469            # all other: fallback to various artists
1470            else:
1471                self.logger.warning(
1472                    "%s is missing ID3 tag [albumartist], using %s as fallback",
1473                    track_path,
1474                    VARIOUS_ARTISTS_NAME,
1475                )
1476                album_artists = UniqueList(
1477                    [await self._parse_artist(name=VARIOUS_ARTISTS_NAME, mbid=VARIOUS_ARTISTS_MBID)]
1478                )
1479
1480        if album_dir:  # noqa: SIM108
1481            # prefer the path as id
1482            item_id = album_dir
1483        else:
1484            # create fake item_id based on artist + album
1485            item_id = album_artists[0].name + os.sep + track_tags.album
1486
1487        name, version = parse_title_and_version(track_tags.album)
1488        album = Album(
1489            item_id=item_id,
1490            provider=self.instance_id,
1491            name=name,
1492            version=version,
1493            sort_name=track_tags.album_sort,
1494            artists=album_artists,
1495            provider_mappings={
1496                ProviderMapping(
1497                    item_id=item_id,
1498                    provider_domain=self.domain,
1499                    provider_instance=self.instance_id,
1500                    url=album_dir,
1501                    in_library=True,
1502                )
1503            },
1504            date_added=(
1505                datetime.fromtimestamp(track_created_at, tz=UTC) if track_created_at else None
1506            ),
1507        )
1508        if track_tags.barcode:
1509            album.external_ids.add((ExternalID.BARCODE, track_tags.barcode))
1510
1511        if track_tags.musicbrainz_albumid:
1512            album.mbid = track_tags.musicbrainz_albumid
1513        if track_tags.musicbrainz_releasegroupid:
1514            album.add_external_id(ExternalID.MB_RELEASEGROUP, track_tags.musicbrainz_releasegroupid)
1515        if track_tags.year:
1516            album.year = track_tags.year
1517        album.album_type = track_tags.album_type
1518
1519        # hunt for additional metadata and images in the folder structure
1520        if not album_dir:
1521            return album
1522
1523        for folder_path in (track_dir, album_dir):
1524            if not folder_path or not await self.exists(folder_path):
1525                continue
1526            nfo_file = os.path.join(folder_path, "album.nfo")
1527            if await self.exists(nfo_file):
1528                # found NFO file with metadata
1529                # https://kodi.wiki/view/NFO_files/Artists
1530                nfo_file = self.get_absolute_path(nfo_file)
1531                async with aiofiles.open(nfo_file) as _file:
1532                    data = await _file.read()
1533                info = await asyncio.to_thread(xmltodict.parse, data)
1534                info = info["album"]
1535                album.name = info.get("title", info.get("name", name))
1536                if sort_name := info.get("sortname"):
1537                    album.sort_name = sort_name
1538                if releasegroup_id := info.get("musicbrainzreleasegroupid"):
1539                    album.add_external_id(ExternalID.MB_RELEASEGROUP, releasegroup_id)
1540                if album_id := info.get("musicbrainzalbumid"):
1541                    album.add_external_id(ExternalID.MB_ALBUM, album_id)
1542                if mb_artist_id := info.get("musicbrainzalbumartistid"):
1543                    if album.artists and not album.artists[0].mbid:
1544                        album.artists[0].mbid = mb_artist_id
1545                if description := info.get("review"):
1546                    album.metadata.description = description
1547                if year := info.get("year"):
1548                    album.year = int(year)
1549                if genre := info.get("genre"):
1550                    album.metadata.genres = set(split_items(genre))
1551            # parse name/version
1552            album.name, album.version = parse_title_and_version(album.name)
1553            # find local images
1554            if images := await self._get_local_images(folder_path, extra_thumb_names=("album",)):
1555                if album.metadata.images is None:
1556                    album.metadata.images = UniqueList(images)
1557                else:
1558                    album.metadata.images += images
1559        await self.cache.set(
1560            key=album_dir,
1561            data=album,
1562            provider=self.instance_id,
1563            category=CACHE_CATEGORY_ALBUM_INFO,
1564            expiration=120,
1565        )
1566        return album
1567
1568    async def _get_local_images(
1569        self, folder: str, extra_thumb_names: tuple[str, ...] | None = None
1570    ) -> UniqueList[MediaItemImage]:
1571        """Return local images found in a given folderpath."""
1572        if (
1573            cache := await self.cache.get(
1574                key=folder, provider=self.instance_id, category=CACHE_CATEGORY_FOLDER_IMAGES
1575            )
1576        ) is not None:
1577            return cast("UniqueList[MediaItemImage]", cache)
1578        if extra_thumb_names is None:
1579            extra_thumb_names = ()
1580        images: UniqueList[MediaItemImage] = UniqueList()
1581        abs_path = self.get_absolute_path(folder)
1582        folder_files = await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=False)
1583        for item in folder_files:
1584            if "." not in item.relative_path or item.is_dir or not item.ext:
1585                continue
1586            if item.ext.lower() not in IMAGE_EXTENSIONS:
1587                continue
1588            # try match on filename = one of our imagetypes
1589            if item.name.lower() in ImageType:
1590                images.append(
1591                    MediaItemImage(
1592                        type=ImageType(item.name),
1593                        path=item.relative_path,
1594                        provider=self.instance_id,
1595                        remotely_accessible=False,
1596                    )
1597                )
1598
1599        # try alternative names for thumbs
1600        extra_thumb_names = ("folder", "cover", *extra_thumb_names)
1601        for item in folder_files:
1602            if "." not in item.relative_path or item.is_dir or not item.ext:
1603                continue
1604            if item.ext.lower() not in IMAGE_EXTENSIONS:
1605                continue
1606            if item.name.lower() not in extra_thumb_names:
1607                continue
1608            images.append(
1609                MediaItemImage(
1610                    type=ImageType.THUMB,
1611                    path=item.relative_path,
1612                    provider=self.instance_id,
1613                    remotely_accessible=False,
1614                )
1615            )
1616
1617        await self.cache.set(
1618            key=folder,
1619            data=images,
1620            provider=self.instance_id,
1621            category=CACHE_CATEGORY_FOLDER_IMAGES,
1622            expiration=120,
1623        )
1624        return images
1625
1626    async def check_write_access(self) -> None:
1627        """Perform check if we have write access."""
1628        # verify write access to determine we have playlist create/edit support
1629        # overwrite with provider specific implementation if needed
1630        temp_file_name = self.get_absolute_path(f"{shortuuid.random(8)}.txt")
1631        try:
1632            async with aiofiles.open(temp_file_name, "w") as _file:
1633                await _file.write("test")
1634            await asyncio.to_thread(os.remove, temp_file_name)
1635            self.write_access = True
1636        except Exception as err:
1637            self.logger.debug("Write access disabled: %s", str(err))
1638
1639    async def resolve(
1640        self,
1641        file_path: str,
1642    ) -> FileSystemItem:
1643        """Resolve (absolute or relative) path to FileSystemItem."""
1644        absolute_path = self.get_absolute_path(file_path)
1645
1646        def _create_item() -> FileSystemItem:
1647            if os.path.isdir(absolute_path):
1648                return FileSystemItem(
1649                    filename=os.path.basename(file_path),
1650                    relative_path=get_relative_path(self.base_path, file_path),
1651                    absolute_path=absolute_path,
1652                    is_dir=True,
1653                )
1654            stat = os.stat(absolute_path, follow_symlinks=False)
1655            return FileSystemItem(
1656                filename=os.path.basename(file_path),
1657                relative_path=get_relative_path(self.base_path, file_path),
1658                absolute_path=absolute_path,
1659                is_dir=False,
1660                checksum=str(int(stat.st_mtime)),
1661                file_size=stat.st_size,
1662            )
1663
1664        # run in thread because strictly taken this may be blocking IO
1665        return await asyncio.to_thread(_create_item)
1666
1667    async def exists(self, file_path: str) -> bool:
1668        """Return bool is this FileSystem musicprovider has given file/dir."""
1669        if not file_path:
1670            return False  # guard
1671        abs_path = self.get_absolute_path(file_path)
1672        return bool(await exists(abs_path))
1673
1674    def get_absolute_path(self, file_path: str) -> str:
1675        """Return absolute path for given file path."""
1676        return get_absolute_path(self.base_path, file_path)
1677
1678    async def _get_stream_details_for_track(self, item_id: str) -> StreamDetails:
1679        """Return the streamdetails for a track/song."""
1680        library_item = await self.mass.music.tracks.get_library_item_by_prov_id(
1681            item_id, self.instance_id
1682        )
1683        if library_item is None:
1684            # this could be a file that has just been added, try parsing it
1685            file_item = await self.resolve(item_id)
1686            tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1687            if not (library_item := await self._parse_track(file_item, tags)):
1688                msg = f"Item not found: {item_id}"
1689                raise MediaNotFoundError(msg)
1690
1691        prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
1692        file_item = await self.resolve(item_id)
1693
1694        return StreamDetails(
1695            provider=self.instance_id,
1696            item_id=item_id,
1697            audio_format=prov_mapping.audio_format,
1698            media_type=MediaType.TRACK,
1699            stream_type=StreamType.LOCAL_FILE,
1700            duration=library_item.duration,
1701            size=file_item.file_size,
1702            data=file_item,
1703            path=file_item.absolute_path,
1704            can_seek=True,
1705            allow_seek=True,
1706        )
1707
1708    async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamDetails:
1709        """Return the streamdetails for a podcast episode."""
1710        # podcasts episodes are never stored in the library so we need to parse the file
1711        file_item = await self.resolve(item_id)
1712        tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1713        return StreamDetails(
1714            provider=self.instance_id,
1715            item_id=item_id,
1716            audio_format=AudioFormat(
1717                content_type=ContentType.try_parse(file_item.ext or tags.format),
1718                sample_rate=tags.sample_rate,
1719                bit_depth=tags.bits_per_sample,
1720                channels=tags.channels,
1721                bit_rate=tags.bit_rate,
1722            ),
1723            media_type=MediaType.PODCAST_EPISODE,
1724            stream_type=StreamType.LOCAL_FILE,
1725            duration=try_parse_int(tags.duration or 0),
1726            size=file_item.file_size,
1727            data=file_item,
1728            path=file_item.absolute_path,
1729            allow_seek=True,
1730            can_seek=True,
1731        )
1732
1733    async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails:
1734        """Return the streamdetails for an audiobook."""
1735        library_item = await self.mass.music.audiobooks.get_library_item_by_prov_id(
1736            item_id, self.instance_id
1737        )
1738        if library_item is None:
1739            # this could be a file that has just been added, try parsing it
1740            file_item = await self.resolve(item_id)
1741            tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1742            if not (library_item := await self._parse_audiobook(file_item, tags)):
1743                msg = f"Item not found: {item_id}"
1744                raise MediaNotFoundError(msg)
1745
1746        prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
1747        file_item = await self.resolve(item_id)
1748        duration = library_item.duration
1749        file_based_chapters: list[tuple[str, float]] | None = await self.cache.get(
1750            key=file_item.relative_path,
1751            provider=self.instance_id,
1752            category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
1753        )
1754        if file_based_chapters is None:
1755            # no cache available for this audiobook, we need to parse the chapters
1756            tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
1757            await self._parse_audiobook(file_item, tags)
1758            file_based_chapters = await self.cache.get(
1759                key=file_item.relative_path,
1760                provider=self.instance_id,
1761                category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
1762            )
1763
1764        if file_based_chapters:
1765            # this is a multi-file audiobook
1766            return StreamDetails(
1767                provider=self.instance_id,
1768                item_id=item_id,
1769                audio_format=prov_mapping.audio_format,
1770                media_type=MediaType.AUDIOBOOK,
1771                stream_type=StreamType.LOCAL_FILE,
1772                duration=duration,
1773                path=[
1774                    MultiPartPath(path=self.get_absolute_path(path), duration=duration)
1775                    for path, duration in file_based_chapters
1776                ],
1777                allow_seek=True,
1778            )
1779
1780        # regular single-file streaming, simply let ffmpeg deal with the file directly
1781        return StreamDetails(
1782            provider=self.instance_id,
1783            item_id=item_id,
1784            audio_format=prov_mapping.audio_format,
1785            media_type=MediaType.AUDIOBOOK,
1786            stream_type=StreamType.LOCAL_FILE,
1787            duration=library_item.duration,
1788            size=file_item.file_size,
1789            data=file_item,
1790            path=file_item.absolute_path,
1791            allow_seek=True,
1792            can_seek=True,
1793        )
1794
1795    async def _get_chapters_for_audiobook(
1796        self, audiobook_file_item: FileSystemItem, tags: AudioTags
1797    ) -> tuple[int, list[MediaItemChapter]]:
1798        """Return the chapters for an audiobook."""
1799        chapters: list[MediaItemChapter] = []
1800        all_chapter_files: list[tuple[str, float]] = []
1801        total_duration = 0.0
1802        if tags.chapters:
1803            # The chapters are embedded in the file tags
1804            chapters = [
1805                MediaItemChapter(
1806                    position=chapter.chapter_id,
1807                    name=chapter.title or f"Chapter {chapter.chapter_id}",
1808                    start=chapter.position_start,
1809                    end=chapter.position_end,
1810                )
1811                for chapter in tags.chapters
1812            ]
1813            total_duration = try_parse_int(tags.duration) or 0
1814        else:
1815            # there could be multiple files for this audiobook in the same folder,
1816            # where each file is a portion/chapter of the audiobook
1817            # try to gather the chapters by traversing files in the same folder
1818            chapter_file_tags: list[AudioTags] = []
1819            abs_path = self.get_absolute_path(audiobook_file_item.parent_path)
1820            for item in await asyncio.to_thread(
1821                sorted_scandir, self.base_path, abs_path, sort=True
1822            ):
1823                if "." not in item.relative_path or item.is_dir:
1824                    continue
1825                if item.ext not in AUDIOBOOK_EXTENSIONS:
1826                    continue
1827                item_tags = await async_parse_tags(item.absolute_path, item.file_size)
1828                if not (tags.album == item_tags.album or (item_tags.tags.get("title") is None)):
1829                    continue
1830                if item_tags.track is None:
1831                    continue
1832                chapter_file_tags.append(item_tags)
1833            chapter_file_tags.sort(key=lambda x: (x.disc or 0, x.track or 0))
1834            for chapter_tags in chapter_file_tags:
1835                assert chapter_tags.duration is not None
1836                chapters.append(
1837                    MediaItemChapter(
1838                        position=chapter_tags.track or 0,
1839                        name=chapter_tags.title,
1840                        start=total_duration,
1841                        end=total_duration + chapter_tags.duration,
1842                    )
1843                )
1844                all_chapter_files.append(
1845                    (
1846                        get_relative_path(self.base_path, chapter_tags.filename),
1847                        chapter_tags.duration,
1848                    )
1849                )
1850                total_duration += chapter_tags.duration
1851
1852        # store chapter files in cache
1853        # for easy access from streamdetails
1854        await self.cache.set(
1855            key=audiobook_file_item.relative_path,
1856            data=all_chapter_files,
1857            provider=self.instance_id,
1858            category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS,
1859        )
1860        return (int(total_duration), chapters)
1861
1862    async def _get_podcast_metadata(self, podcast_folder: str) -> dict[str, Any]:
1863        """Return metadata for a podcast."""
1864        if (
1865            cache := await self.cache.get(
1866                key=podcast_folder,
1867                provider=self.instance_id,
1868                category=CACHE_CATEGORY_PODCAST_METADATA,
1869            )
1870        ) is not None:
1871            return cast("dict[str, Any]", cache)
1872        data: dict[str, Any] = {}
1873        metadata_file = os.path.join(podcast_folder, "metadata.json")
1874        if await self.exists(metadata_file):
1875            # found json file with metadata
1876            metadata_file = self.get_absolute_path(metadata_file)
1877            async with aiofiles.open(metadata_file) as _file:
1878                data.update(json_loads(await _file.read()))
1879        await self.cache.set(
1880            key=podcast_folder,
1881            data=data,
1882            provider=self.instance_id,
1883            category=CACHE_CATEGORY_PODCAST_METADATA,
1884        )
1885        return data
1886