music-assistant-server

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