Various optimizations to filesystem metadata retrieval (#1569)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 15 Aug 2024 18:30:32 +0000 (20:30 +0200)
committerGitHub <noreply@github.com>
Thu, 15 Aug 2024 18:30:32 +0000 (20:30 +0200)
music_assistant/common/helpers/util.py
music_assistant/common/models/media_items.py
music_assistant/constants.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/music.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/qobuz/__init__.py

index 260dd82131fdd85278c82edcf55af2a8275ecd87..0591c5acc87fe289516da1ffe8e78c044ac31248 100644 (file)
@@ -70,11 +70,11 @@ def try_parse_duration(duration_str: str) -> float:
 
 
 def create_sort_name(input_str: str) -> str:
-    """Create sort name/title from string."""
+    """Create (basic/simple) sort name/title from string."""
     input_str = input_str.lower().strip()
-    for item in ["the ", "de ", "les ", "dj ", ".", "-", "'", "`"]:
+    for item in ["the ", "de ", "les ", "dj ", "las ", "los ", "le ", "la ", "el ", "a ", "an "]:
         if input_str.startswith(item):
-            input_str = input_str.replace(item, "")
+            input_str = input_str.replace(item, "") + f", {item}"
     return input_str.strip()
 
 
index e8742bda423f55acf5cb8c2e87cdec0ab7db873e..85f1cf93cb2bd2f57c34b7d625a6fea5acf7f566 100644 (file)
@@ -116,10 +116,22 @@ class ProviderMapping(DataClassDictMixin):
     def quality(self) -> int:
         """Return quality score."""
         quality = self.audio_format.quality
-        if "filesystem" in self.provider_domain:
-            # always prefer local file over online media
-            quality += 1
-        return quality
+        # append provider score so filebased providers are scored higher
+        return quality + self.priority
+
+    @property
+    def priority(self) -> int:
+        """Return priority score to sort local providers before online."""
+        if not (local_provs := get_global_cache_value("non_streaming_providers")):
+            # this is probably the client
+            return 0
+        if TYPE_CHECKING:
+            local_provs = cast(set[str], local_provs)
+        if self.provider_domain in ("filesystem_local", "filesystem_smb"):
+            return 2
+        if self.provider_instance in local_provs:
+            return 1
+        return 0
 
     def __hash__(self) -> int:
         """Return custom hash."""
@@ -251,8 +263,9 @@ class _MediaItemBase(DataClassDictMixin):
     provider: str  # provider instance id or provider domain
     name: str
     version: str = ""
-    # sort_name and uri are auto generated, do not override unless really needed
+    # sort_name will be auto generated if omitted
     sort_name: str | None = None
+    # uri is auto generated, do not override unless really needed
     uri: str | None = None
     external_ids: set[tuple[ExternalID, str]] = field(default_factory=set)
     media_type: MediaType = MediaType.UNKNOWN
index c949429e658efe8ed7fe08a8a7bce14b602fc8fb..9c2708d1f4e3b810ebbe67d441557c1d2c0b5c71 100644 (file)
@@ -12,7 +12,7 @@ MASS_LOGGER_NAME: Final[str] = "music_assistant"
 UNKNOWN_ARTIST: Final[str] = "[unknown]"
 UNKNOWN_ARTIST_ID_MBID: Final[str] = "125ec42a-7229-4250-afc5-e057484327fe"
 VARIOUS_ARTISTS_NAME: Final[str] = "Various Artists"
-VARIOUS_ARTISTS_ID_MBID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377"
+VARIOUS_ARTISTS_MBID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377"
 
 
 RESOURCES_DIR: Final[pathlib.Path] = (
index 32be36f97bcb14f54060cf62f3debe900d99f05e..706789c0f5914f5eab71dd9e685ae507989e4d45 100644 (file)
@@ -27,7 +27,7 @@ from music_assistant.constants import (
     DB_TABLE_ALBUM_ARTISTS,
     DB_TABLE_ARTISTS,
     DB_TABLE_TRACK_ARTISTS,
-    VARIOUS_ARTISTS_ID_MBID,
+    VARIOUS_ARTISTS_MBID,
     VARIOUS_ARTISTS_NAME,
 )
 from music_assistant.server.controllers.media.base import MediaControllerBase
@@ -311,8 +311,8 @@ class ArtistsController(MediaControllerBase[Artist]):
             item = self._artist_from_item_mapping(item)
         # enforce various artists name + id
         if compare_strings(item.name, VARIOUS_ARTISTS_NAME):
-            item.mbid = VARIOUS_ARTISTS_ID_MBID
-        if item.mbid == VARIOUS_ARTISTS_ID_MBID:
+            item.mbid = VARIOUS_ARTISTS_MBID
+        if item.mbid == VARIOUS_ARTISTS_MBID:
             item.name = VARIOUS_ARTISTS_NAME
         # no existing item matched: insert item
         new_item = await self.mass.music.database.insert(
@@ -349,8 +349,8 @@ class ArtistsController(MediaControllerBase[Artist]):
         mbid = cur_item.mbid
         if (not mbid or overwrite) and getattr(update, "mbid", None):
             if compare_strings(update.name, VARIOUS_ARTISTS_NAME):
-                update.mbid = VARIOUS_ARTISTS_ID_MBID
-            if update.mbid == VARIOUS_ARTISTS_ID_MBID:
+                update.mbid = VARIOUS_ARTISTS_MBID
+            if update.mbid == VARIOUS_ARTISTS_MBID:
                 update.name = VARIOUS_ARTISTS_NAME
 
         await self.mass.music.database.update(
index 31a9e3d0530579bd8b8807dde235e01b1b0fa739..43a5e1f5f8beaff143b35219679f3e1f80dc13ca 100644 (file)
@@ -113,7 +113,7 @@ class TracksController(MediaControllerBase[Track]):
                 )
         except MusicAssistantError as err:
             # edge case where playlist track has invalid albumdetails
-            self.logger.warning("Unable to fetch album details %s - %s", track.album.uri, str(err))
+            self.logger.warning("Unable to fetch album details for %s - %s", track.uri, str(err))
 
         if not recursive:
             return track
index eb25ad337b1688a33baf978d499d631559e4f7cc..aea233379c8d2f9e182813109386c0b08ffc65fe 100644 (file)
@@ -46,7 +46,7 @@ from music_assistant.constants import (
     DB_TABLE_ARTISTS,
     DB_TABLE_PLAYLISTS,
     DB_TABLE_TRACKS,
-    VARIOUS_ARTISTS_ID_MBID,
+    VARIOUS_ARTISTS_MBID,
     VARIOUS_ARTISTS_NAME,
     VERBOSE_LOG_LEVEL,
 )
@@ -417,9 +417,13 @@ class MetaDataController(CoreController):
         """Get/update rich metadata for an artist."""
         # ensure the item is matched to all providers
         await self.mass.music.artists.match_providers(artist)
-        # collect metadata from all music providers first
         unique_keys: set[str] = set()
-        for prov_mapping in artist.provider_mappings:
+        # collect metadata from all music providers first
+        # note that we sort the providers by priority so that we always
+        # prefer local providers over online providers
+        for prov_mapping in sorted(
+            artist.provider_mappings, key=lambda x: x.priority, reverse=True
+        ):
             if (prov := self.mass.get_provider(prov_mapping.provider_instance)) is None:
                 continue
             if prov.lookup_key in unique_keys:
@@ -471,9 +475,11 @@ class MetaDataController(CoreController):
         """Get/update rich metadata for an album."""
         # ensure the item is matched to all providers (will also get other quality versions)
         await self.mass.music.albums.match_providers(album)
-        # collect metadata from all music providers first
         unique_keys: set[str] = set()
-        for prov_mapping in album.provider_mappings:
+        # collect metadata from all music providers first
+        # note that we sort the providers by priority so that we always
+        # prefer local providers over online providers
+        for prov_mapping in sorted(album.provider_mappings, key=lambda x: x.priority, reverse=True):
             if (prov := self.mass.get_provider(prov_mapping.provider_instance)) is None:
                 continue
             if prov.lookup_key in unique_keys:
@@ -523,9 +529,11 @@ class MetaDataController(CoreController):
         """Get/update rich metadata for a track."""
         # ensure the item is matched to all providers (will also get other quality versions)
         await self.mass.music.tracks.match_providers(track)
-        # collect metadata from all music providers first
         unique_keys: set[str] = set()
-        for prov_mapping in track.provider_mappings:
+        # collect metadata from all music providers first
+        # note that we sort the providers by priority so that we always
+        # prefer local providers over online providers
+        for prov_mapping in sorted(track.provider_mappings, key=lambda x: x.priority, reverse=True):
             if (prov := self.mass.get_provider(prov_mapping.provider_instance)) is None:
                 continue
             if prov.lookup_key in unique_keys:
@@ -648,7 +656,7 @@ class MetaDataController(CoreController):
     async def _get_artist_mbid(self, artist: Artist) -> str | None:
         """Fetch musicbrainz id by performing search using the artist name, albums and tracks."""
         if compare_strings(artist.name, VARIOUS_ARTISTS_NAME):
-            return VARIOUS_ARTISTS_ID_MBID
+            return VARIOUS_ARTISTS_MBID
         ref_albums = await self.mass.music.artists.albums(
             artist.item_id, artist.provider, in_library_only=False
         )
index d50c0aa314960056be39b5648a34181db772ffb0..4c8d30f43f81e20e6600d89c8b448c1c3b0a10a2 100644 (file)
@@ -569,15 +569,19 @@ class MusicController(CoreController):
             available_providers = cast(set[str], available_providers)
 
         # fetch the first (available) provider item
-        for prov_mapping in media_item.provider_mappings:
-            if self.mass.get_provider(prov_mapping.provider_instance):
-                with suppress(MediaNotFoundError):
-                    media_item = await ctrl.get_provider_item(
-                        prov_mapping.item_id, prov_mapping.provider_instance, force_refresh=True
-                    )
-                    provider = media_item.provider
-                    item_id = media_item.item_id
-                    break
+        for prov_mapping in sorted(
+            media_item.provider_mappings, key=lambda x: x.priority, reverse=True
+        ):
+            if not self.mass.get_provider(prov_mapping.provider_instance):
+                # ignore unavailable providers
+                continue
+            with suppress(MediaNotFoundError):
+                media_item = await ctrl.get_provider_item(
+                    prov_mapping.item_id, prov_mapping.provider_instance, force_refresh=True
+                )
+                provider = media_item.provider
+                item_id = media_item.item_id
+                break
         else:
             # try to find a substitute using search
             searchresult = await self.search(media_item.name, [media_item.media_type], 20)
index 21b111e1b0cddec9bca0d38188dcc7184c2be125..ba22b5ad1f8cddb15d1562c20c526d7858b931b0 100644 (file)
@@ -47,11 +47,13 @@ from music_assistant.constants import (
     DB_TABLE_ARTISTS,
     DB_TABLE_PROVIDER_MAPPINGS,
     DB_TABLE_TRACK_ARTISTS,
+    VARIOUS_ARTISTS_MBID,
     VARIOUS_ARTISTS_NAME,
 )
+from music_assistant.server.controllers.cache import use_cache
 from music_assistant.server.helpers.compare import compare_strings, create_safe_string
 from music_assistant.server.helpers.playlists import parse_m3u, parse_pls
-from music_assistant.server.helpers.tags import parse_tags, split_items
+from music_assistant.server.helpers.tags import AudioTags, parse_tags, split_items
 from music_assistant.server.models.music_provider import MusicProvider
 
 from .helpers import get_album_dir, get_artist_dir, get_disc_dir
@@ -444,20 +446,74 @@ class FileSystemProviderBase(MusicProvider):
         db_artist = await self.mass.music.artists.get_library_item_by_prov_id(
             prov_artist_id, self.instance_id
         )
-        if db_artist is None:
+        if not db_artist:
+            # this should not be possible, but just in case
             msg = f"Artist not found: {prov_artist_id}"
             raise MediaNotFoundError(msg)
+        # prov_artist_id is either an actual (relative) path or a name (as fallback)
+        safe_artist_name = create_safe_string(prov_artist_id, lowercase=False, replace_space=False)
         if await self.exists(prov_artist_id):
-            # if path exists on disk allow parsing full details to allow refresh of metadata
-            return await self._parse_artist(db_artist.name, artist_path=prov_artist_id)
-        return db_artist
+            artist_path = prov_artist_id
+        elif await self.exists(safe_artist_name):
+            artist_path = safe_artist_name
+        else:
+            for prov_mapping in db_artist.provider_mappings:
+                if prov_mapping.provider_instance != self.instance_id:
+                    continue
+                if prov_mapping.url:
+                    artist_path = prov_mapping.url
+                    break
+            else:
+                # this is an artist without an actual path on disk
+                # return the info we already have in the db
+                return db_artist
+
+        artist = Artist(
+            item_id=prov_artist_id,
+            provider=self.instance_id,
+            name=db_artist.name,
+            sort_name=db_artist.sort_name,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=prov_artist_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=artist_path,
+                )
+            },
+        )
+        # grab additional metadata within the Artist's folder
+        nfo_file = os.path.join(artist_path, "artist.nfo")
+        if await self.exists(nfo_file):
+            # found NFO file with metadata
+            # https://kodi.wiki/view/NFO_files/Artists
+            data = b""
+            async for chunk in self.read_file_content(nfo_file):
+                data += chunk
+            info = await asyncio.to_thread(xmltodict.parse, data)
+            info = info["artist"]
+            artist.name = info.get("title", info.get("name", db_artist.name))
+            if sort_name := info.get("sortname"):
+                artist.sort_name = sort_name
+            if mbid := info.get("musicbrainzartistid"):
+                artist.mbid = mbid
+            if description := info.get("biography"):
+                artist.metadata.description = description
+            if genre := info.get("genre"):
+                artist.metadata.genres = set(split_items(genre))
+        # find local images
+        if images := await self._get_local_images(artist_path):
+            artist.metadata.images = UniqueList(images)
+
+        return artist
 
     async def get_album(self, prov_album_id: str) -> Album:
         """Get full album details by id."""
         for track in await self.get_album_tracks(prov_album_id):
             for prov_mapping in track.provider_mappings:
                 if prov_mapping.provider_instance == self.instance_id:
-                    full_track = await self.get_track(prov_mapping.item_id)
+                    file_item = await self.resolve(prov_mapping.item_id)
+                    full_track = await self._parse_track(file_item, full_album_metadata=True)
                     assert isinstance(full_track.album, Album)
                     return full_track.album
         msg = f"Album not found: {prov_album_id}"
@@ -471,7 +527,7 @@ class FileSystemProviderBase(MusicProvider):
             raise MediaNotFoundError(msg)
 
         file_item = await self.resolve(prov_track_id)
-        return await self._parse_track(file_item)
+        return await self._parse_track(file_item, full_album_metadata=True)
 
     async def get_playlist(self, prov_playlist_id: str) -> Playlist:
         """Get full playlist details by id."""
@@ -693,7 +749,9 @@ class FileSystemProviderBase(MusicProvider):
             return file_item.local_path
         return file_item.absolute_path
 
-    async def _parse_track(self, file_item: FileSystemItem) -> Track:
+    async def _parse_track(
+        self, file_item: FileSystemItem, full_album_metadata: bool = False
+    ) -> Track:
         """Get full track details by id."""
         # ruff: noqa: PLR0915, PLR0912
 
@@ -733,87 +791,28 @@ class FileSystemProviderBase(MusicProvider):
         if acoustid := tags.get("acoustidid"):
             track.external_ids.add((ExternalID.ACOUSTID, acoustid))
 
-        album: Album | None = None
-        album_artists: list[Artist] = []
-
         # album
-        if tags.album:
-            # work out if we have an album and/or disc folder
-            # disc_dir is the folder level where the tracks are located
-            # this may be a separate disc folder (Disc 1, Disc 2 etc) underneath the album folder
-            # or this is an album folder with the disc attached
-            disc_dir = get_disc_dir(file_item.path, tags.album, tags.disc)
-            album_dir = get_album_dir(file_item.path, tags.album, disc_dir)
-
-            # album artist(s)
-            if tags.album_artists:
-                for index, album_artist_str in enumerate(tags.album_artists):
-                    artist = await self._parse_artist(
-                        album_artist_str,
-                        album_path=album_dir,
-                        sort_name=(
-                            tags.album_artist_sort_names[index]
-                            if index < len(tags.album_artist_sort_names)
-                            else None
-                        ),
-                    )
-                    if not artist.mbid:
-                        with contextlib.suppress(IndexError):
-                            artist.mbid = tags.musicbrainz_albumartistids[index]
-                    album_artists.append(artist)
-            else:
-                # album artist tag is missing, determine fallback
-                fallback_action = self.config.get_value(CONF_MISSING_ALBUM_ARTIST_ACTION)
-                if fallback_action == "folder_name" and album_dir:
-                    possible_artist_folder = os.path.dirname(album_dir)
-                    self.logger.warning(
-                        "%s is missing ID3 tag [albumartist], using foldername %s as fallback",
-                        file_item.path,
-                        possible_artist_folder,
-                    )
-                    album_artist_str = possible_artist_folder.rsplit(os.sep)[-1]
-                    album_artists = [await self._parse_artist(name=album_artist_str)]
-                # fallback to track artists (if defined by user)
-                elif fallback_action == "track_artist":
-                    self.logger.warning(
-                        "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
-                        file_item.path,
-                    )
-                    album_artists = [
-                        await self._parse_artist(name=track_artist_str)
-                        for track_artist_str in tags.artists
-                    ]
-                # all other: fallback to various artists
-                else:
-                    self.logger.warning(
-                        "%s is missing ID3 tag [albumartist], using %s as fallback",
-                        file_item.path,
-                        VARIOUS_ARTISTS_NAME,
-                    )
-                    album_artists = [await self._parse_artist(name=VARIOUS_ARTISTS_NAME)]
-
-            album = track.album = await self._parse_album(
-                tags.album,
-                track_path=file_item.path,
-                album_path=album_dir,
-                disc_path=disc_dir,
-                artists=album_artists,
-                barcode=tags.barcode,
+        album = track.album = (
+            await self._parse_album(
+                track_path=file_item.path, track_tags=tags, full_metadata=full_album_metadata
             )
+            if tags.album
+            else None
+        )
 
         # track artist(s)
         for index, track_artist_str in enumerate(tags.artists):
-            # reuse album artist details if possible
-            if album_artist := next((x for x in album_artists if x.name == track_artist_str), None):
-                artist = album_artist
-            else:
-                artist = await self._parse_artist(track_artist_str)
-            if not artist.mbid:
-                with contextlib.suppress(IndexError):
-                    artist.mbid = tags.musicbrainz_artistids[index]
-            # artist sort name
-            with contextlib.suppress(IndexError):
-                artist.sort_name = tags.artist_sort_names[index]
+            artist = await self._create_artist_itemmapping(
+                track_artist_str,
+                sort_name=(
+                    tags.artist_sort_names[index] if index < len(tags.artist_sort_names) else None
+                ),
+                mbid=(
+                    tags.musicbrainz_artistids[index]
+                    if index < len(tags.musicbrainz_artistids)
+                    else None
+                ),
+            )
             track.artists.append(artist)
 
         # handle embedded cover image
@@ -835,8 +834,7 @@ class FileSystemProviderBase(MusicProvider):
         if album and not album.metadata.images:
             # set embedded cover on album if it does not have one yet
             album.metadata.images = track.metadata.images
-        # copy album image from track (only if the album itself doesn't have an image)
-        # this deals with embedded images from filesystem providers
+        # copy (embedded) album image from track (if the album itself doesn't have an image)
         if album and not album.image and track.image:
             album.metadata.images = UniqueList([track.image])
 
@@ -855,150 +853,163 @@ class FileSystemProviderBase(MusicProvider):
         if tags.musicbrainz_recordingid:
             track.mbid = tags.musicbrainz_recordingid
         track.metadata.chapters = UniqueList(tags.chapters)
-        if album:
-            if not album.mbid and tags.musicbrainz_albumid:
-                album.mbid = tags.musicbrainz_albumid
-            if tags.musicbrainz_releasegroupid:
-                album.add_external_id(ExternalID.MB_RELEASEGROUP, tags.musicbrainz_releasegroupid)
-            if not album.year:
-                album.year = tags.year
-            album.album_type = tags.album_type
-            album.metadata.explicit = track.metadata.explicit
         return track
 
-    async def _parse_artist(
+    @use_cache(300)
+    async def _create_artist_itemmapping(
         self,
         name: str,
-        artist_path: str | None = None,
         album_path: str | None = None,
         sort_name: str | None = None,
-    ) -> Artist:
-        """Parse Artist metadata into an Artist object."""
-        cache_key = f"{self.instance_id}-artistdata-{name}-{artist_path}"
-        if cache := await self.mass.cache.get(cache_key):
-            assert isinstance(cache, Artist)
-            return cache
-        if not artist_path and album_path:
+        mbid: str | None = None,
+    ) -> ItemMapping:
+        """Create ItemMapping for a track/album artist."""
+        artist_path = None
+        if album_path:
             # try to find (album)artist folder based on album path
             artist_path = get_artist_dir(album_path=album_path, artist_name=name)
+        if not artist_path:
+            # check if we have an artist folder for this artist at root level
+            safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
+            if await self.exists(name):
+                artist_path = name
+            elif await self.exists(safe_artist_name):
+                artist_path = safe_artist_name
         if not artist_path:
             # check if we have an existing item to retrieve the artist path
             async for item in self.mass.music.artists.iter_library_items(search=name):
                 if not compare_strings(name, item.name):
                     continue
                 for prov_mapping in item.provider_mappings:
-                    if prov_mapping.provider_instance == self.instance_id:
+                    if prov_mapping.provider_instance != self.instance_id:
+                        continue
+                    if prov_mapping.url:
                         artist_path = prov_mapping.url
                         break
-                if artist_path:
-                    break
-            else:
-                # check if we have an artist folder for this artist at root level
-                safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
-                if await self.exists(name):
-                    artist_path = name
-                elif await self.exists(safe_artist_name):
-                    artist_path = safe_artist_name
-
-        if artist_path:  # noqa: SIM108
-            # prefer the path as id
-            item_id = artist_path
-        else:
-            # simply use the album name as item id
-            item_id = name
 
-        artist = Artist(
-            item_id=item_id,
+        return ItemMapping(
+            media_type=MediaType.ARTIST,
+            # simply use the artist name as item id as fallback
+            item_id=artist_path or name,
             provider=self.instance_id,
             name=name,
             sort_name=sort_name,
-            provider_mappings={
-                ProviderMapping(
-                    item_id=item_id,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                    url=artist_path,
-                )
-            },
+            external_ids={(ExternalID.MB_ARTIST, mbid)} if mbid else set(),
         )
 
-        if artist_path is None or not await self.exists(artist_path):
-            # return basic object if there is no dedicated artist folder
-            await self.mass.cache.set(cache_key, artist, expiration=120)
-            return artist
-
-        nfo_file = os.path.join(artist_path, "artist.nfo")
-        if await self.exists(nfo_file):
-            # found NFO file with metadata
-            # https://kodi.wiki/view/NFO_files/Artists
-            data = b""
-            async for chunk in self.read_file_content(nfo_file):
-                data += chunk
-            info = await asyncio.to_thread(xmltodict.parse, data)
-            info = info["artist"]
-            artist.name = info.get("title", info.get("name", name))
-            if sort_name := info.get("sortname"):
-                artist.sort_name = sort_name
-            if mbid := info.get("musicbrainzartistid"):
-                artist.mbid = mbid
-            if description := info.get("biography"):
-                artist.metadata.description = description
-            if genre := info.get("genre"):
-                artist.metadata.genres = set(split_items(genre))
-        # find local images
-        if images := await self._get_local_images(artist_path):
-            artist.metadata.images = UniqueList(images)
-
-        await self.mass.cache.set(cache_key, artist, expiration=120)
-        return artist
-
     async def _parse_album(
-        self,
-        name: str,
-        track_path: str,
-        album_path: str | None,
-        disc_path: str | None,
-        artists: list[Artist],
-        barcode: str | None = None,
-        sort_name: str | None = None,
+        self, track_path: str, track_tags: AudioTags, full_metadata: bool = False
     ) -> Album:
-        """Parse Album metadata into an Album object."""
-        cache_key = f"{self.instance_id}-albumdata-{name}-{album_path}"
-        if cache := await self.mass.cache.get(cache_key):
-            assert isinstance(cache, Album)
-            return cache
+        """Parse Album metadata from Track tags."""
+        assert track_tags.album
+        # work out if we have an album and/or disc folder
+        # disc_dir is the folder level where the tracks are located
+        # this may be a separate disc folder (Disc 1, Disc 2 etc) underneath the album folder
+        # or this is an album folder with the disc attached
+        disc_dir = get_disc_dir(track_path, track_tags.album, track_tags.disc)
+        album_dir = get_album_dir(track_path, track_tags.album, disc_dir)
+
+        # album artist(s)
+        album_artists: UniqueList[Artist | ItemMapping] = UniqueList()
+        if track_tags.album_artists:
+            for index, album_artist_str in enumerate(track_tags.album_artists):
+                artist = await self._create_artist_itemmapping(
+                    album_artist_str,
+                    album_path=album_dir,
+                    sort_name=(
+                        track_tags.album_artist_sort_names[index]
+                        if index < len(track_tags.album_artist_sort_names)
+                        else None
+                    ),
+                    mbid=(
+                        track_tags.musicbrainz_albumartistids[index]
+                        if index < len(track_tags.musicbrainz_albumartistids)
+                        else None
+                    ),
+                )
+                album_artists.append(artist)
+        else:
+            # album artist tag is missing, determine fallback
+            fallback_action = self.config.get_value(CONF_MISSING_ALBUM_ARTIST_ACTION)
+            if fallback_action == "folder_name" and album_dir:
+                possible_artist_folder = os.path.dirname(album_dir)
+                self.logger.warning(
+                    "%s is missing ID3 tag [albumartist], using foldername %s as fallback",
+                    track_path,
+                    possible_artist_folder,
+                )
+                album_artist_str = possible_artist_folder.rsplit(os.sep)[-1]
+                album_artists = UniqueList(
+                    [await self._create_artist_itemmapping(name=album_artist_str)]
+                )
+            # fallback to track artists (if defined by user)
+            elif fallback_action == "track_artist":
+                self.logger.warning(
+                    "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
+                    track_path,
+                )
+                album_artists = UniqueList(
+                    [
+                        await self._create_artist_itemmapping(name=track_artist_str)
+                        for track_artist_str in track_tags.artists
+                    ]
+                )
+            # all other: fallback to various artists
+            else:
+                self.logger.warning(
+                    "%s is missing ID3 tag [albumartist], using %s as fallback",
+                    track_path,
+                    VARIOUS_ARTISTS_NAME,
+                )
+                album_artists = UniqueList(
+                    [
+                        await self._create_artist_itemmapping(
+                            name=VARIOUS_ARTISTS_NAME, mbid=VARIOUS_ARTISTS_MBID
+                        )
+                    ]
+                )
 
-        if album_path:
+        if album_dir:  # noqa: SIM108
             # prefer the path as id
-            item_id = album_path
-        elif artists:
-            # create fake item_id based on artist + album
-            item_id = artists[0].name + os.sep + name
+            item_id = album_dir
         else:
-            # simply use the album name as item id
-            album_path = name
+            # create fake item_id based on artist + album
+            item_id = album_artists[0].name + os.sep + track_tags.album
 
+        name, version = parse_title_and_version(track_tags.album)
         album = Album(
             item_id=item_id,
             provider=self.instance_id,
             name=name,
-            sort_name=sort_name,
-            artists=UniqueList(artists),
+            version=version,
+            sort_name=track_tags.album_sort,
+            artists=album_artists,
             provider_mappings={
                 ProviderMapping(
                     item_id=item_id,
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
-                    url=album_path,
+                    url=album_dir,
                 )
             },
         )
-        if barcode:
-            album.external_ids.add((ExternalID.BARCODE, barcode))
+        if track_tags.barcode:
+            album.external_ids.add((ExternalID.BARCODE, track_tags.barcode))
+
+        if track_tags.musicbrainz_albumid:
+            album.mbid = track_tags.musicbrainz_albumid
+        if track_tags.musicbrainz_releasegroupid:
+            album.add_external_id(ExternalID.MB_RELEASEGROUP, track_tags.musicbrainz_releasegroupid)
+        if track_tags.year:
+            album.year = track_tags.year
+        album.album_type = track_tags.album_type
 
         # hunt for additional metadata and images in the folder structure
-        extra_path = os.path.dirname(track_path) if (track_path and not album_path) else None
-        for folder_path in (disc_path, album_path, extra_path):
+        if not full_metadata:
+            return album
+
+        extra_path = os.path.dirname(track_path) if (track_path and not album_dir) else None
+        for folder_path in (disc_dir, album_dir, extra_path):
             if not folder_path or not await self.exists(folder_path):
                 continue
             nfo_file = os.path.join(folder_path, "album.nfo")
@@ -1035,7 +1046,6 @@ class FileSystemProviderBase(MusicProvider):
                 else:
                     album.metadata.images += images
 
-        await self.mass.cache.set(cache_key, album, expiration=120)
         return album
 
     async def _get_local_images(self, folder: str) -> list[MediaItemImage]:
index 10f5cfaf6f9d230577715114905ef4bfa2fd87a4..c4d5dcd950fa9c189b8aca4da3ff0100f7fdf767 100644 (file)
@@ -44,7 +44,7 @@ from music_assistant.common.models.streamdetails import StreamDetails
 from music_assistant.constants import (
     CONF_PASSWORD,
     CONF_USERNAME,
-    VARIOUS_ARTISTS_ID_MBID,
+    VARIOUS_ARTISTS_MBID,
     VARIOUS_ARTISTS_NAME,
 )
 
@@ -501,7 +501,7 @@ class QobuzProvider(MusicProvider):
             },
         )
         if artist.item_id == VARIOUS_ARTISTS_ID:
-            artist.mbid = VARIOUS_ARTISTS_ID_MBID
+            artist.mbid = VARIOUS_ARTISTS_MBID
             artist.name = VARIOUS_ARTISTS_NAME
         if img := self.__get_image(artist_obj):
             artist.metadata.images = [