From 2961811155c6c4be03f358d8c1af549f41aa2437 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 22 Jul 2022 00:04:38 +0200 Subject: [PATCH] Fix version listings for albums and tracks (#430) --- .../controllers/metadata/__init__.py | 18 ++-- music_assistant/controllers/music/albums.py | 89 +++++++++++-------- music_assistant/controllers/music/radio.py | 41 ++++++++- music_assistant/controllers/music/tracks.py | 29 ++++-- music_assistant/helpers/compare.py | 3 + music_assistant/models/media_controller.py | 7 +- music_assistant/models/media_items.py | 12 +++ .../music_providers/ytmusic/ytmusic.py | 7 +- 8 files changed, 151 insertions(+), 55 deletions(-) diff --git a/music_assistant/controllers/metadata/__init__.py b/music_assistant/controllers/metadata/__init__.py index cb867c6d..ae736c50 100755 --- a/music_assistant/controllers/metadata/__init__.py +++ b/music_assistant/controllers/metadata/__init__.py @@ -63,6 +63,9 @@ class MetaDataController: async def get_artist_metadata(self, artist: Artist) -> None: """Get/update rich metadata for an artist.""" + # set timestamp, used to determine when this function was last called + artist.metadata.last_refresh = int(time()) + if not artist.musicbrainz_id: artist.musicbrainz_id = await self.get_artist_musicbrainz_id(artist) @@ -72,10 +75,11 @@ class MetaDataController: if metadata := await self.audiodb.get_artist_metadata(artist): artist.metadata.update(metadata) - artist.metadata.last_refresh = int(time()) - async def get_album_metadata(self, album: Album) -> None: """Get/update rich metadata for an album.""" + # set timestamp, used to determine when this function was last called + album.metadata.last_refresh = int(time()) + if not (album.musicbrainz_id or album.artist): return if metadata := await self.audiodb.get_album_metadata(album): @@ -83,19 +87,20 @@ class MetaDataController: if metadata := await self.fanarttv.get_album_metadata(album): album.metadata.update(metadata) - album.metadata.last_refresh = int(time()) - async def get_track_metadata(self, track: Track) -> None: """Get/update rich metadata for a track.""" + # set timestamp, used to determine when this function was last called + track.metadata.last_refresh = int(time()) + if not (track.album and track.artists): return if metadata := await self.audiodb.get_track_metadata(track): track.metadata.update(metadata) - track.metadata.last_refresh = int(time()) - async def get_playlist_metadata(self, playlist: Playlist) -> None: """Get/update rich metadata for a playlist.""" + # set timestamp, used to determine when this function was last called + playlist.metadata.last_refresh = int(time()) # retrieve genres from tracks # TODO: retrieve style/mood ? playlist.metadata.genres = set() @@ -110,7 +115,6 @@ class MetaDataController: elif track.album and track.album.metadata.genres: playlist.metadata.genres.update(track.album.metadata.genres) # TODO: create mosaic thumb/fanart from playlist tracks - playlist.metadata.last_refresh = int(time()) async def get_radio_metadata(self, radio: Radio) -> None: """Get/update rich metadata for a radio station.""" diff --git a/music_assistant/controllers/music/albums.py b/music_assistant/controllers/music/albums.py index 2df86c6b..c969b11b 100644 --- a/music_assistant/controllers/music/albums.py +++ b/music_assistant/controllers/music/albums.py @@ -61,17 +61,35 @@ class AlbumsController(MediaControllerBase[Album]): provider_id: Optional[str] = None, ) -> List[Album]: """Return all versions of an album we can find on all providers.""" + assert provider or provider_id, "Provider type or ID must be specified" album = await self.get(item_id, provider, provider_id) + # perform a search on all provider(types) to collect all versions/variants prov_types = {item.type for item in self.mass.music.providers} - return [ - prov_item + search_query = f"{album.artist.name} - {album.name}" + all_versions = { + prov_item.item_id: prov_item for prov_items in await asyncio.gather( - *[self.search(album.name, prov_type) for prov_type in prov_types] + *[self.search(search_query, prov_type) for prov_type in prov_types] ) for prov_item in prov_items - if prov_item.sort_name == album.sort_name + if ( + (prov_item.sort_name in album.sort_name) + or (album.sort_name in prov_item.sort_name) + ) and compare_artist(prov_item.artist, album.artist) - ] + } + # make sure that the 'base' version is included + for prov_version in album.provider_ids: + if prov_version.item_id in all_versions: + continue + album_copy = Album.from_dict(album.to_dict()) + album_copy.item_id = prov_version.item_id + album_copy.provider = prov_version.prov_type + album_copy.provider_ids = {prov_version} + all_versions[prov_version.item_id] = album_copy + + # return the aggregated result + return all_versions.values() async def add(self, item: Album, overwrite_existing: bool = False) -> Album: """Add album to local db and return the database item.""" @@ -81,6 +99,12 @@ class AlbumsController(MediaControllerBase[Album]): # also fetch same album on all providers await self._match(db_item) db_item = await self.get_db_item(db_item.item_id) + # add the album's tracks to the db + for prov in item.provider_ids: + for track in await self._get_provider_album_tracks( + prov.item_id, prov.prov_type, prov.prov_id + ): + await self.mass.music.tracks.add_db_item(track) return db_item async def _get_provider_album_tracks( @@ -93,7 +117,7 @@ class AlbumsController(MediaControllerBase[Album]): prov = self.mass.music.get_provider(provider_id or provider) if not prov: return [] - full_album = await self.get(item_id, provider, provider_id) + full_album = await self.get_provider_item(item_id, provider_id or provider) # prefer cache items (if any) cache_key = f"{prov.type.value}.albumtracks.{item_id}" cache_checksum = full_album.metadata.checksum @@ -120,38 +144,27 @@ class AlbumsController(MediaControllerBase[Album]): item_id: str, ) -> List[Track]: """Return in-database album tracks for the given database album.""" - album_tracks = [] db_album = await self.get_db_item(item_id) - # combine the info we have in the db with the full listing from a streaming provider - for prov in db_album.provider_ids: - for prov_track in await self._get_provider_album_tracks( - prov.item_id, prov.prov_type, prov.prov_id + # simply grab all tracks in the db that are linked to this album + # TODO: adjust to json query instead of text search? + query = f"SELECT * FROM tracks WHERE albums LIKE '%\"{item_id}\"%'" + result = [] + for track in await self.mass.music.tracks.get_db_items_by_query(query): + if album_mapping := next( + (x for x in track.albums if x.item_id == db_album.item_id), None ): - if db_track := await self.mass.music.tracks.get_db_item_by_prov_id( - prov_track.item_id, prov_track.provider - ): - if album_mapping := next( - (x for x in db_track.albums if x.item_id == db_album.item_id), - None, - ): - db_track.disc_number = album_mapping.disc_number - db_track.track_number = album_mapping.track_number - prov_track = db_track - # make sure that the (db) album is stored on the tracks - prov_track.album = db_album - prov_track.metadata.images = db_album.metadata.images - album_tracks.append(prov_track) - # once we have the details from one streaming provider, - # there is no need to iterate them all (if there are multiple) - # for the same album - if not prov.prov_type.is_file(): - break - - return album_tracks + # make sure that the full album is set on the track and prefer the album's images + track.album = db_album + if db_album.metadata.images: + track.metadata.images = db_album.metadata.images + # apply the disc and track number from the mapping + 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 or 0, x.track_number or 0)) async def add_db_item(self, item: Album, overwrite_existing: bool = False) -> Album: """Add a new record to the database.""" - assert isinstance(item, Album), "Not a full Album object" assert item.provider_ids, f"Album {item.name} is missing provider id(s)" assert item.artist, f"Album {item.name} is missing artist" async with self._db_add_lock: @@ -344,16 +357,14 @@ class AlbumsController(MediaControllerBase[Album]): self, artist: Union[Artist, ItemMapping], overwrite: bool = False ) -> ItemMapping: """Extract (database) track artist as ItemMapping.""" - - if artist.provider == ProviderType.DATABASE: - if isinstance(artist, ItemMapping): - return artist - return ItemMapping.from_item(artist) - 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 + return ItemMapping.from_item(artist) if db_artist := await self.mass.music.artists.get_db_item_by_prov_id( artist.item_id, provider=artist.provider diff --git a/music_assistant/controllers/music/radio.py b/music_assistant/controllers/music/radio.py index 93054bd4..31cf36d3 100644 --- a/music_assistant/controllers/music/radio.py +++ b/music_assistant/controllers/music/radio.py @@ -1,11 +1,13 @@ """Manage MediaItems of type Radio.""" from __future__ import annotations +import asyncio from time import time +from typing import List, Optional from music_assistant.helpers.database import TABLE_RADIOS from music_assistant.helpers.json import json_serializer -from music_assistant.models.enums import EventType, MediaType +from music_assistant.models.enums import EventType, MediaType, ProviderType from music_assistant.models.event import MassEvent from music_assistant.models.media_controller import MediaControllerBase from music_assistant.models.media_items import Radio @@ -22,6 +24,43 @@ class RadioController(MediaControllerBase[Radio]): """Get in-library radio by name.""" return await self.mass.database.get_row(self.db_table, {"name": name}) + async def versions( + self, + item_id: str, + provider: Optional[ProviderType] = None, + provider_id: Optional[str] = None, + ) -> List[Radio]: + """Return all versions of a radio station we can find on all providers.""" + assert provider or provider_id, "Provider type or ID must be specified" + radio = await self.get(item_id, provider, provider_id) + # perform a search on all provider(types) to collect all versions/variants + prov_types = {item.type for item in self.mass.music.providers} + all_versions = { + prov_item.item_id: prov_item + for prov_items in await asyncio.gather( + *[self.search(radio.name, prov_type) for prov_type in prov_types] + ) + for prov_item in prov_items + if ( + (prov_item.name in radio.name) + or (radio.name in prov_item.name) + or (prov_item.sort_name in radio.sort_name) + or (radio.sort_name in prov_item.sort_name) + ) + } + # make sure that the 'base' version is included + for prov_version in radio.provider_ids: + if prov_version.item_id in all_versions: + continue + radio_copy = Radio.from_dict(radio.to_dict()) + radio_copy.item_id = prov_version.item_id + radio_copy.provider = prov_version.prov_type + radio_copy.provider_ids = {prov_version} + all_versions[prov_version.item_id] = radio_copy + + # return the aggregated result + return all_versions.values() + async def add(self, item: Radio, overwrite_existing: bool = False) -> Radio: """Add radio to local db and return the new database item.""" item.metadata.last_refresh = int(time()) diff --git a/music_assistant/controllers/music/tracks.py b/music_assistant/controllers/music/tracks.py index 5b37324a..efd65798 100644 --- a/music_assistant/controllers/music/tracks.py +++ b/music_assistant/controllers/music/tracks.py @@ -66,16 +66,35 @@ class TracksController(MediaControllerBase[Track]): provider_id: Optional[str] = None, ) -> List[Track]: """Return all versions of a track we can find on all providers.""" + assert provider or provider_id, "Provider type or ID must be specified" track = await self.get(item_id, provider, provider_id) + # perform a search on all provider(types) to collect all versions/variants prov_types = {item.type for item in self.mass.music.providers} - return [ - prov_item + search_query = f"{track.artist.name} - {track.name}" + all_versions = { + prov_item.item_id: prov_item for prov_items in await asyncio.gather( - *[self.search(track.name, prov_type) for prov_type in prov_types] + *[self.search(search_query, prov_type) for prov_type in prov_types] ) for prov_item in prov_items - if compare_artists(prov_item.artists, track.artists) - ] + if ( + (prov_item.sort_name in track.sort_name) + or (track.sort_name in prov_item.sort_name) + ) + and compare_artists(prov_item.artists, track.artists, any_match=True) + } + # make sure that the 'base' version is included + for prov_version in track.provider_ids: + if prov_version.item_id in all_versions: + continue + track_copy = Track.from_dict(track.to_dict()) + track_copy.item_id = prov_version.item_id + track_copy.provider = prov_version.prov_type + track_copy.provider_ids = {prov_version} + all_versions[prov_version.item_id] = track_copy + + # return the aggregated result + return all_versions.values() async def _match(self, db_track: Track) -> None: """ diff --git a/music_assistant/helpers/compare.py b/music_assistant/helpers/compare.py index d5e5679c..639d9e7d 100644 --- a/music_assistant/helpers/compare.py +++ b/music_assistant/helpers/compare.py @@ -77,12 +77,15 @@ def compare_artist( def compare_artists( left_artists: List[Union[Artist, ItemMapping]], right_artists: List[Union[Artist, ItemMapping]], + any_match: bool = False, ) -> bool: """Compare two lists of artist and return True if both lists match (exactly).""" matches = 0 for left_artist in left_artists: for right_artist in right_artists: if compare_artist(left_artist, right_artist): + if any_match: + return True matches += 1 return len(left_artists) == matches diff --git a/music_assistant/models/media_controller.py b/music_assistant/models/media_controller.py index fe189fbc..5dce7f52 100644 --- a/music_assistant/models/media_controller.py +++ b/music_assistant/models/media_controller.py @@ -205,6 +205,9 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): prov = self.mass.music.get_provider(provider_id or provider) if not prov or MusicProviderFeature.SEARCH not in prov.supported_features: return [] + if not prov.library_supported(self.media_type): + # assume library supported also means that this mediatype is supported + return [] # prefer cache items (if any) cache_key = ( @@ -279,7 +282,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): await self.set_db_library(prov_item.item_id, False) async def get_provider_id(self, item: ItemCls) -> Tuple[str, str]: - """Return provider and item id.""" + """Return (first) provider and item id.""" if item.provider == ProviderType.DATABASE: # make sure we have a full object item = await self.get_db_item(item.item_id) @@ -311,7 +314,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): match = {"item_id": int(item_id)} if db_row := await self.mass.database.get_row(self.db_table, match): return self.item_cls.from_db_row(db_row) - return None + raise MediaNotFoundError(f"Album not found in database: {item_id}") async def get_db_item_by_prov_id( self, diff --git a/music_assistant/models/media_items.py b/music_assistant/models/media_items.py index c89b5935..8d36fbab 100755 --- a/music_assistant/models/media_items.py +++ b/music_assistant/models/media_items.py @@ -319,6 +319,18 @@ class Track(MediaItem): return tuple() return tuple(self.isrc.split(";")) + @property + def artist(self) -> Artist | ItemMapping | None: + """Return (first) artist of track.""" + if self.artists: + return self.artists[0] + return None + + @artist.setter + def artist(self, artist: Union[Artist, ItemMapping]) -> None: + """Set (first/only) artist of track.""" + self.artists = [artist] + @dataclass class Playlist(MediaItem): diff --git a/music_assistant/music_providers/ytmusic/ytmusic.py b/music_assistant/music_providers/ytmusic/ytmusic.py index f07e4da9..04e0028a 100644 --- a/music_assistant/music_providers/ytmusic/ytmusic.py +++ b/music_assistant/music_providers/ytmusic/ytmusic.py @@ -8,7 +8,11 @@ from urllib.parse import unquote import pytube import ytmusicapi -from music_assistant.models.enums import MusicProviderFeature, ProviderType +from music_assistant.models.enums import ( + MediaQuality, + MusicProviderFeature, + ProviderType, +) from music_assistant.models.errors import ( InvalidDataError, LoginFailed, @@ -575,6 +579,7 @@ class YoutubeMusicProvider(MusicProvider): prov_type=self.type, prov_id=self.id, available=available, + quality=MediaQuality.LOSSY_M4A, ) ) return track -- 2.34.1