From 6d5176f6b2a205cabe64331b481807ca95f7ed01 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 14 Jul 2022 21:16:09 +0200 Subject: [PATCH] Various fixes for filesystem provider (#415) fix coverart on tracks if ID3 tags are changed by user, force overwrite of info in db optimize file deletions fix musicbrainz albumartistid parse from id3 tags make platlist parsing more robust require track duration --- .../controllers/metadata/__init__.py | 11 +- music_assistant/controllers/music/__init__.py | 12 +- music_assistant/controllers/music/albums.py | 23 ++- music_assistant/controllers/music/artists.py | 22 ++- music_assistant/controllers/music/tracks.py | 11 +- music_assistant/helpers/uri.py | 6 + music_assistant/models/media_controller.py | 35 +++- music_assistant/models/music_provider.py | 51 +++--- music_assistant/music_providers/filesystem.py | 171 ++++++++++-------- 9 files changed, 217 insertions(+), 125 deletions(-) diff --git a/music_assistant/controllers/metadata/__init__.py b/music_assistant/controllers/metadata/__init__.py index d38a3de7..cb867c6d 100755 --- a/music_assistant/controllers/metadata/__init__.py +++ b/music_assistant/controllers/metadata/__init__.py @@ -155,7 +155,16 @@ class MetaDataController: ): return musicbrainz_id # lookup failed - self.logger.warning("Unable to get musicbrainz ID for artist %s !", artist.name) + ref_albums_str = "/".join(x.name for x in ref_albums) or "none" + ref_tracks_str = "/".join(x.name for x in ref_tracks) or "none" + self.logger.info( + "Unable to get musicbrainz ID for artist %s\n" + " - using lookup-album(s): %s\n" + " - using lookup-track(s): %s\n", + artist.name, + ref_albums_str, + ref_tracks_str, + ) return None async def get_image_data_for_item( diff --git a/music_assistant/controllers/music/__init__.py b/music_assistant/controllers/music/__init__.py index 4c25de40..092c29f0 100755 --- a/music_assistant/controllers/music/__init__.py +++ b/music_assistant/controllers/music/__init__.py @@ -73,6 +73,12 @@ class MusicController: await self._register_provider(prov_cls(self.mass, prov_conf), prov_conf) # always register url provider await self._register_provider(URLProvider(self.mass, URL_CONFIG), URL_CONFIG) + # add job to cleanup old records from db + self.mass.add_job( + self._cleanup_library(), + "Cleanup removed items from database", + allow_duplicate=False, + ) async def start_sync( self, @@ -103,12 +109,6 @@ class MusicController: await asyncio.sleep(3600 * schedule) self.mass.create_task(do_sync()) - # add job to cleanup old records from db - self.mass.add_job( - self._cleanup_library(), - "Cleanup removed items from database", - allow_duplicate=False, - ) @property def provider_count(self) -> int: diff --git a/music_assistant/controllers/music/albums.py b/music_assistant/controllers/music/albums.py index dc420cca..58997f46 100644 --- a/music_assistant/controllers/music/albums.py +++ b/music_assistant/controllers/music/albums.py @@ -186,7 +186,7 @@ class AlbumsController(MediaControllerBase[Album]): metadata = item.metadata metadata.last_refresh = None provider_ids = item.provider_ids - album_artists = await self._get_album_artists(cur_item) + album_artists = await self._get_album_artists(item, overwrite=True) else: metadata = cur_item.metadata.update(item.metadata) provider_ids = {*cur_item.provider_ids, *item.provider_ids} @@ -223,13 +223,17 @@ class AlbumsController(MediaControllerBase[Album]): db_item = await self.get_db_item(item_id) return db_item - async def delete_db_item(self, item_id: int) -> None: + async def delete_db_item(self, item_id: int, recursive: bool = False) -> None: """Delete record from the database.""" - # delete tracks connected to this album - await self.mass.database.delete_where_query( - TABLE_TRACKS, f"albums LIKE '%\"{item_id}\"%'" + # check album tracks + db_rows = await self.mass.music.tracks.get_db_items_by_query( + f"SELECT item_id FROM {TABLE_TRACKS} WHERE albums LIKE '%\"{item_id}\"%'" ) + assert not (db_rows and not recursive), "Tracks attached to album" + for db_row in db_rows: + await self.mass.music.albums.delete_db_item(db_row["item_id"], recursive) + # delete the album itself from db await super().delete_db_item(item_id) @@ -292,6 +296,7 @@ class AlbumsController(MediaControllerBase[Album]): self, db_album: Album, updated_album: Optional[Album] = None, + overwrite: bool = False, ) -> List[ItemMapping]: """Extract (database) album artist(s) as ItemMapping.""" album_artists = set() @@ -299,7 +304,7 @@ class AlbumsController(MediaControllerBase[Album]): if not album: continue for artist in album.artists: - album_artists.add(await self._get_artist_mapping(artist)) + album_artists.add(await self._get_artist_mapping(artist, overwrite)) # use intermediate set to prevent duplicates # filter various artists if multiple artists if len(album_artists) > 1: @@ -307,9 +312,13 @@ class AlbumsController(MediaControllerBase[Album]): return list(album_artists) async def _get_artist_mapping( - self, artist: Union[Artist, ItemMapping] + self, artist: Union[Artist, ItemMapping], overwrite: bool = False ) -> ItemMapping: """Extract (database) track artist as ItemMapping.""" + if overwrite: + artist = await self.mass.music.artists.add_db_item( + artist, overwrite_existing=True + ) if artist.provider == ProviderType.DATABASE: if isinstance(artist, ItemMapping): return artist diff --git a/music_assistant/controllers/music/artists.py b/music_assistant/controllers/music/artists.py index be390e02..0646b807 100644 --- a/music_assistant/controllers/music/artists.py +++ b/music_assistant/controllers/music/artists.py @@ -191,7 +191,6 @@ class ArtistsController(MediaControllerBase[Artist]): for row in await self.mass.database.get_rows(self.db_table, match): row_artist = Artist.from_db_row(row) if row_artist.sort_name == item.sort_name: - # just to be sure ?! cur_item = row_artist break if cur_item: @@ -240,16 +239,25 @@ class ArtistsController(MediaControllerBase[Artist]): db_item = await self.get_db_item(item_id) return db_item - async def delete_db_item(self, item_id: int) -> None: + async def delete_db_item(self, item_id: int, recursive: bool = False) -> None: """Delete record from the database.""" - # delete tracks/albums connected to this artist - await self.mass.database.delete_where_query( - TABLE_TRACKS, f"artists LIKE '%\"{item_id}\"%'" + # check artist albums + db_rows = await self.mass.music.albums.get_db_items_by_query( + f"SELECT item_id FROM {TABLE_ALBUMS} WHERE artists LIKE '%\"{item_id}\"%'" ) - await self.mass.database.delete_where_query( - TABLE_ALBUMS, f"artists LIKE '%\"{item_id}\"%'" + assert not (db_rows and not recursive), "Albums attached to artist" + for db_row in db_rows: + await self.mass.music.albums.delete_db_item(db_row["item_id"], recursive) + + # check artist tracks + db_rows = await self.mass.music.tracks.get_db_items_by_query( + f"SELECT item_id FROM {TABLE_TRACKS} WHERE artists LIKE '%\"{item_id}\"%'" ) + assert not (db_rows and not recursive), "Tracks attached to artist" + for db_row in db_rows: + await self.mass.music.albums.delete_db_item(db_row["item_id"], recursive) + # delete the artist itself from db await super().delete_db_item(item_id) diff --git a/music_assistant/controllers/music/tracks.py b/music_assistant/controllers/music/tracks.py index be0ae9b2..ecf31984 100644 --- a/music_assistant/controllers/music/tracks.py +++ b/music_assistant/controllers/music/tracks.py @@ -270,6 +270,11 @@ class TracksController(MediaControllerBase[Track]): overwrite: bool = False, ) -> ItemMapping: """Extract (database) album as ItemMapping.""" + if overwrite: + db_album = await self.mass.music.albums.add_db_item( + album, overwrite_existing=True + ) + if album.provider == ProviderType.DATABASE: if isinstance(album, ItemMapping): return album @@ -286,9 +291,13 @@ class TracksController(MediaControllerBase[Track]): return ItemMapping.from_item(db_album) async def _get_artist_mapping( - self, artist: Union[Artist, ItemMapping] + self, artist: Union[Artist, ItemMapping], overwrite: bool = False ) -> ItemMapping: """Extract (database) track artist as ItemMapping.""" + if overwrite: + artist = await self.mass.music.artists.add_db_item( + artist, overwrite_existing=True + ) if artist.provider == ProviderType.DATABASE: if isinstance(artist, ItemMapping): return artist diff --git a/music_assistant/helpers/uri.py b/music_assistant/helpers/uri.py index 2eb0079c..b7c43a19 100644 --- a/music_assistant/helpers/uri.py +++ b/music_assistant/helpers/uri.py @@ -1,5 +1,6 @@ """Helpers for creating/parsing URI's.""" +import os from typing import Tuple from music_assistant.models.enums import MediaType, ProviderType @@ -37,6 +38,11 @@ def parse_uri(uri: str) -> Tuple[MediaType, ProviderType, str]: provider, media_type_str, item_id = uri.split(":") provider = ProviderType.parse(provider) media_type = MediaType(media_type_str) + elif os.path.isfile(uri): + # Translate a local file (which is not from file provider) to the URL provider + provider = ProviderType.URL + media_type = MediaType.TRACK + item_id = uri else: raise KeyError except (TypeError, AttributeError, ValueError, KeyError) as err: diff --git a/music_assistant/models/media_controller.py b/music_assistant/models/media_controller.py index 7d5a4979..902b07dd 100644 --- a/music_assistant/models/media_controller.py +++ b/music_assistant/models/media_controller.py @@ -3,7 +3,15 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod from time import time -from typing import TYPE_CHECKING, Generic, List, Optional, Tuple, TypeVar +from typing import ( + TYPE_CHECKING, + AsyncGenerator, + Generic, + List, + Optional, + Tuple, + TypeVar, +) from music_assistant.models.errors import MediaNotFoundError @@ -77,6 +85,29 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): sql_query, params, limit=limit, offset=offset ) + async def iter_db_items( + self, + in_library: Optional[bool] = None, + search: Optional[str] = None, + order_by: str = "sort_name", + ) -> AsyncGenerator[ItemCls, None]: + """Iterate all in-database items.""" + limit: int = 500 + offset: int = 0 + while True: + next_items = await self.db_items( + in_library=in_library, + search=search, + limit=limit, + offset=offset, + order_by=order_by, + ) + for item in next_items: + yield item + if len(next_items) < limit: + break + offset += limit + async def count(self, in_library: Optional[bool] = None) -> int: """Return number of in-library items for this MediaType.""" if in_library is not None: @@ -360,7 +391,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): self.logger.debug("removed provider %s from item id %s", prov_id, item_id) - async def delete_db_item(self, item_id: int) -> None: + async def delete_db_item(self, item_id: int, recursive: bool = False) -> None: """Delete record from the database.""" # delete item await self.mass.database.delete( diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index d5043ce1..c802d7b8 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -112,33 +112,31 @@ class MusicProvider: async def get_artist(self, prov_artist_id: str) -> Artist: """Get full artist details by id.""" - if MediaType.ARTIST in self.supported_mediatypes: - raise NotImplementedError + raise NotImplementedError async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: """Get a list of all albums for the given artist.""" if MediaType.ALBUM in self.supported_mediatypes: raise NotImplementedError + return [] async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: """Get a list of most popular tracks for the given artist.""" if MediaType.TRACK in self.supported_mediatypes: raise NotImplementedError + return [] async def get_album(self, prov_album_id: str) -> Album: """Get full album details by id.""" - if MediaType.ALBUM in self.supported_mediatypes: - raise NotImplementedError + raise NotImplementedError async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" - if MediaType.TRACK in self.supported_mediatypes: - raise NotImplementedError + raise NotImplementedError async def get_playlist(self, prov_playlist_id: str) -> Playlist: """Get full playlist details by id.""" - if MediaType.PLAYLIST in self.supported_mediatypes: - raise NotImplementedError + raise NotImplementedError async def get_radio(self, prov_radio_id: str) -> Radio: """Get full radio details by id.""" @@ -149,11 +147,13 @@ class MusicProvider: """Get album tracks for given album id.""" if MediaType.ALBUM in self.supported_mediatypes: raise NotImplementedError + return [] async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: """Get all playlist tracks for given playlist id.""" if MediaType.PLAYLIST in self.supported_mediatypes: raise NotImplementedError + return [] async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool: """Add item to provider's library. Return true on succes.""" @@ -294,23 +294,7 @@ class MusicProvider: continue self.logger.debug("Start sync of %s items.", media_type.value) controller = self.mass.music.get_controller(media_type) - - # create a set of all previous and current db id's - # note we only store the items in the prev_ids list that are - # unique to this provider to avoid getting into a mess where - # for example an item still exists on disk (in case of file provider) - # and no longer favorite on streaming provider. - # Bottomline this means that we don't do a full 2 way sync if multiple - # providers are attached to the same media item. - prev_ids = set() - for db_item in await controller.db_items(True): - prov_types = {x.prov_type for x in db_item.provider_ids} - if len(prov_types) > 1: - continue - for prov_id in db_item.provider_ids: - if prov_id.prov_id == self.id: - prev_ids.add(db_item.item_id) - cur_ids = set() + cur_db_ids = set() async for prov_item in self._get_library_gen(media_type)(): prov_item: MediaItemType = prov_item @@ -328,15 +312,22 @@ class MusicProvider: db_item = await controller.update_db_item( db_item.item_id, prov_item ) - cur_ids.add(db_item.item_id) + cur_db_ids.add(db_item.item_id) if not db_item.in_library: await controller.set_db_library(db_item.item_id, True) - # process deletions - for item_id in prev_ids: - if item_id not in cur_ids: + # process deletions (= no longer in library) + async for db_item in controller.iter_db_items(True): + if db_item.item_id in cur_db_ids: + continue + for prov_id in db_item.provider_ids: + prov_types = {x.prov_type for x in db_item.provider_ids} + if len(prov_types) > 1: + continue + if prov_id.prov_id != self.id: + continue # only mark the item as not in library and leave the metadata in db - await controller.set_db_library(item_id, False) + await controller.set_db_library(db_item.item_id, False) # DO NOT OVERRIDE BELOW diff --git a/music_assistant/music_providers/filesystem.py b/music_assistant/music_providers/filesystem.py index 00d59a4b..3b5e00ba 100644 --- a/music_assistant/music_providers/filesystem.py +++ b/music_assistant/music_providers/filesystem.py @@ -39,7 +39,9 @@ from music_assistant.models.media_items import ( ) from music_assistant.models.music_provider import MusicProvider -VALID_EXTENSIONS = ("mp3", "m4a", "mp4", "flac", "wav", "ogg", "aiff", "wma", "dsf") +TRACK_EXTENSIONS = ("mp3", "m4a", "mp4", "flac", "wav", "ogg", "aiff", "wma", "dsf") +PLAYLIST_EXTENSIONS = ("m3u",) +SUPPORTED_EXTENSIONS = TRACK_EXTENSIONS + PLAYLIST_EXTENSIONS SCHEMA_VERSION = 17 LOGGER = logging.getLogger(__name__) @@ -161,6 +163,8 @@ class FileSystemProvider(MusicProvider): ) elif track := await self._parse_track(full_path): result.append(track) + elif playlist := await self._parse_playlist(full_path): + result.append(playlist) return result async def sync_library( @@ -172,10 +176,21 @@ class FileSystemProvider(MusicProvider): save_checksum_interval = 0 if prev_checksums is None: prev_checksums = {} + # find all music files in the music directory and all subfolders # we work bottom up, as-in we derive all info from the tracks cur_checksums = {} async for entry in scantree(self.config.path): + + if "." not in entry.path or entry.path.startswith("."): + # skip system files and files without extension + continue + + _, ext = entry.path.rsplit(".", 1) + if ext not in SUPPORTED_EXTENSIONS: + # unsupported file extension + continue + try: # mtime is used as file checksum stat = await asyncio.get_running_loop().run_in_executor( @@ -186,10 +201,16 @@ class FileSystemProvider(MusicProvider): if checksum == prev_checksums.get(entry.path): continue - if track := await self._parse_track(entry.path): + if ext in TRACK_EXTENSIONS: # add/update track to db - await self.mass.music.tracks.add_db_item(track) - elif playlist := await self._parse_playlist(entry.path): + track = await self._parse_track(entry.path) + # if the track was edited on disk, always overwrite existing db details + overwrite_existing = entry.path in prev_checksums + await self.mass.music.tracks.add_db_item( + track, overwrite_existing=overwrite_existing + ) + elif ext in PLAYLIST_EXTENSIONS: + playlist = await self._parse_playlist(entry.path) # add/update] playlist to db playlist.metadata.checksum = checksum await self.mass.music.playlists.add_db_item(playlist) @@ -197,44 +218,59 @@ class FileSystemProvider(MusicProvider): # we don't want the whole sync to crash on one file so we catch all exceptions here self.logger.exception("Error processing %s - %s", entry.path, str(err)) - # save checksums every 50 processed items + # save checksums every 100 processed items # this allows us to pickup where we leftoff when initial scan gets intterrupted - if save_checksum_interval == 50: + if save_checksum_interval == 100: await self.mass.cache.set(cache_key, cur_checksums, SCHEMA_VERSION) save_checksum_interval = 0 else: save_checksum_interval += 1 + # store (final) checksums in cache await self.mass.cache.set(cache_key, cur_checksums, SCHEMA_VERSION) # work out deletions deleted_files = set(prev_checksums.keys()) - set(cur_checksums.keys()) + await self._process_deletions(deleted_files) + + async def _process_deletions(self, deleted_files: set) -> None: + """Process all deletions.""" artists: Set[ItemMapping] = set() albums: Set[ItemMapping] = set() # process deleted tracks/playlists for file_path in deleted_files: + + if "." not in file_path.path or file_path.path.startswith("."): + # skip system files and files without extension + continue + + _, ext = file_path.path.rsplit(".", 1) + if ext not in SUPPORTED_EXTENSIONS: + # unsupported file extension + continue + item_id = self._get_item_id(file_path) - # try track first - if db_item := await self.mass.music.tracks.get_db_item_by_prov_id( - item_id, self.type - ): - await self.mass.music.tracks.remove_prov_mapping( - db_item.item_id, self.id - ) - # gather artists(s) attached to this track - for artist in db_item.artists: - artists.add(artist.item_id) - # gather album and albumartist(s) attached to this track - if db_item.album: - albums.add(db_item.album.item_id) - for artist in db_item.album.artists: + if ext in TRACK_EXTENSIONS: + if db_item := await self.mass.music.tracks.get_db_item_by_prov_id( + item_id, self.type + ): + await self.mass.music.tracks.remove_prov_mapping( + db_item.item_id, self.id + ) + # gather artists(s) attached to this track + for artist in db_item.artists: artists.add(artist.item_id) - # fallback to playlist - elif db_item := await self.mass.music.playlists.get_db_item_by_prov_id( - item_id, self.type - ): - await self.mass.music.playlists.remove_prov_mapping( - db_item.item_id, self.id - ) + # gather album and albumartist(s) attached to this track + if db_item.album: + albums.add(db_item.album.item_id) + for artist in db_item.album.artists: + artists.add(artist.item_id) + elif ext in PLAYLIST_EXTENSIONS: + if db_item := await self.mass.music.playlists.get_db_item_by_prov_id( + item_id, self.type + ): + await self.mass.music.playlists.remove_prov_mapping( + db_item.item_id, self.id + ) # check if albums are deleted for album_id in albums: album = await self.mass.music.albums.get_db_item(album_id) @@ -265,13 +301,16 @@ class FileSystemProvider(MusicProvider): async def get_artist(self, prov_artist_id: str) -> Artist: """Get full artist details by id.""" + db_artist = await self.mass.music.artists.get_db_item_by_prov_id( + provider_item_id=prov_artist_id, provider_id=self.id + ) + if db_artist is None: + raise MediaNotFoundError(f"Artist not found: {prov_artist_id}") itempath = await self._get_filepath(MediaType.ARTIST, prov_artist_id) if await self.exists(itempath): # if path exists on disk allow parsing full details to allow refresh of metadata - return await self._parse_artist(artist_path=itempath) - return await self.mass.music.artists.get_db_item_by_prov_id( - provider_item_id=prov_artist_id, provider_id=self.id - ) + return await self._parse_artist(db_artist.name, artist_path=itempath) + return db_artist async def get_album(self, prov_album_id: str) -> Album: """Get full album details by id.""" @@ -283,7 +322,7 @@ class FileSystemProvider(MusicProvider): itempath = await self._get_filepath(MediaType.ALBUM, prov_album_id) if await self.exists(itempath): # if path exists on disk allow parsing full details to allow refresh of metadata - return await self._parse_album(None, itempath, db_album.artists) + return await self._parse_album(db_album.name, itempath, db_album.artists) return db_album async def get_track(self, prov_track_id: str) -> Track: @@ -310,12 +349,12 @@ class FileSystemProvider(MusicProvider): result = [] for track in await self.mass.music.tracks.get_db_items_by_query(query): track.album = db_album - album_mapping = next( + if album_mapping := next( (x for x in track.albums if x.item_id == db_album.item_id), None - ) - track.disc_number = album_mapping.disc_number - track.track_number = album_mapping.track_number - result.append(track) + ): + track.disc_number = album_mapping.disc_number + track.track_number = album_mapping.track_number + result.append(track) return sorted(result, key=lambda x: (x.disc_number, x.track_number)) async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: @@ -346,22 +385,18 @@ class FileSystemProvider(MusicProvider): async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None: """Try to parse a track from a playlist line.""" - if "://" in line: - # track is uri from external provider? - try: - return await self.mass.music.get_item_by_uri(line) - except MusicAssistantError as err: - self.logger.warning( - "Could not parse uri %s to track: %s", line, str(err) - ) - return None - # try to treat uri as filename - if await self.exists(line): - return await self._parse_track(line) - rel_path = os.path.join(playlist_path, line) - if await self.exists(rel_path): - return await self._parse_track(rel_path) - return None + try: + # try to treat uri as filename first + if await self.exists(line): + file_path = await self.resolve(line) + return await self._parse_track(file_path) + # fallback to generic uri parsing + return await self.mass.music.get_item_by_uri(line) + except MusicAssistantError as err: + self.logger.warning( + "Could not parse uri/file %s to track: %s", line, str(err) + ) + return None async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: """Get a list of albums for the given artist.""" @@ -450,21 +485,12 @@ class FileSystemProvider(MusicProvider): direct=itempath, ) - async def _parse_track(self, track_path: str) -> Track | None: + async def _parse_track(self, track_path: str) -> Track: """Try to parse a track from a filename by reading its tags.""" if not await self.exists(track_path): raise MediaNotFoundError(f"Track path does not exist: {track_path}") - if "." not in track_path or track_path.startswith("."): - # skip system files and files without extension - return None - - _, ext = track_path.rsplit(".", 1) - if ext not in VALID_EXTENSIONS: - # unsupported file extension - return None - track_item_id = self._get_item_id(track_path) # parse tags @@ -494,7 +520,9 @@ class FileSystemProvider(MusicProvider): ) if not artist.musicbrainz_id: try: - artist.musicbrainz_id = tags.musicbrainz_artistids[index] + artist.musicbrainz_id = tags.musicbrainz_albumartistids[ + index + ] except IndexError: pass album_artists.append(artist) @@ -535,7 +563,7 @@ class FileSystemProvider(MusicProvider): # cover image - prefer album image, fallback to embedded if track.album and track.album.image: - track.album.metadata.images = [ + track.metadata.images = [ MediaItemImage(ImageType.THUMB, track.album.image, True) ] elif tags.has_cover_image: @@ -548,6 +576,7 @@ class FileSystemProvider(MusicProvider): track.album.metadata.images = track.metadata.images # parse other info + assert tags.duration, "Invalid duration" track.duration = tags.duration track.metadata.genres = tags.genres track.disc_number = tags.disc @@ -759,13 +788,10 @@ class FileSystemProvider(MusicProvider): return album - async def _parse_playlist(self, playlist_path: str) -> Playlist | None: + async def _parse_playlist(self, playlist_path: str) -> Playlist: """Parse playlist from file.""" playlist_item_id = self._get_item_id(playlist_path) - if not playlist_path.endswith(".m3u"): - return None - if not await self.exists(playlist_path): raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}") @@ -794,9 +820,10 @@ class FileSystemProvider(MusicProvider): """Return bool is this FileSystem musicprovider has given file/dir.""" if not file_path: return False # guard - # ensure we have a full path and not relative + file_path = await self.resolve(file_path) if self.config.path not in file_path: - file_path = os.path.join(self.config.path, file_path) + # additional guard (needed for files within m3u files) + return False _exists = wrap(os.path.exists) return await _exists(file_path) @@ -813,6 +840,8 @@ class FileSystemProvider(MusicProvider): async def resolve(self, file_path: str) -> str: """Resolve local accessible file.""" # remote file locations should return a tempfile here so this is future proofing + if self.config.path not in file_path: + file_path = os.path.join(self.config.path, file_path) return file_path async def _get_filepath( -- 2.34.1