From dda4696a9d6cbdebcbbaefe0fd12d690015c3526 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 29 Jul 2022 11:41:39 +0200 Subject: [PATCH] Refactor controllers a bit (#443) refactor controllers vs models --- .../{helpers => controllers}/cache.py | 6 +- .../{helpers => controllers}/database.py | 4 +- music_assistant/controllers/media/__init__.py | 1 + .../controllers/{music => media}/albums.py | 10 +- .../controllers/{music => media}/artists.py | 16 +- .../media/base.py} | 17 +- .../controllers/{music => media}/playlists.py | 5 +- .../controllers/{music => media}/radio.py | 5 +- .../controllers/{music => media}/tracks.py | 5 +- .../controllers/metadata/__init__.py | 268 +----------------- .../controllers/metadata/audiodb.py | 2 +- .../controllers/metadata/fanarttv.py | 2 +- .../controllers/metadata/metadata.py | 267 +++++++++++++++++ .../controllers/metadata/musicbrainz.py | 2 +- .../{music/__init__.py => music.py} | 22 +- music_assistant/helpers/images.py | 2 +- music_assistant/mass.py | 10 +- music_assistant/models/music_provider.py | 2 +- music_assistant/models/player_queue.py | 2 +- music_assistant/music_providers/__init__.py | 2 +- .../music_providers/filesystem/__init__.py | 3 + .../{ => filesystem}/filesystem.py | 0 .../music_providers/qobuz/__init__.py | 3 + .../music_providers/{ => qobuz}/qobuz.py | 0 .../music_providers/spotify/__init__.py | 3 + .../{ => spotify}/librespot/freebsd/librespot | Bin .../librespot/linux/librespot-aarch64 | Bin .../librespot/linux/librespot-arm | Bin .../librespot/linux/librespot-armhf | Bin .../librespot/linux/librespot-armv7 | Bin .../librespot/linux/librespot-x86_64 | Bin .../{ => spotify}/librespot/osx/librespot | Bin .../librespot/windows/librespot.exe | Bin .../music_providers/{ => spotify}/spotify.py | 0 .../music_providers/tunein/__init__.py | 3 + .../music_providers/{ => tunein}/tunein.py | 0 .../music_providers/url/__init__.py | 3 + .../music_providers/{ => url}/url.py | 0 38 files changed, 352 insertions(+), 313 deletions(-) rename music_assistant/{helpers => controllers}/cache.py (97%) rename music_assistant/{helpers => controllers}/database.py (99%) create mode 100644 music_assistant/controllers/media/__init__.py rename music_assistant/controllers/{music => media}/albums.py (98%) rename music_assistant/controllers/{music => media}/artists.py (97%) rename music_assistant/{models/media_controller.py => controllers/media/base.py} (98%) rename music_assistant/controllers/{music => media}/playlists.py (99%) rename music_assistant/controllers/{music => media}/radio.py (97%) rename music_assistant/controllers/{music => media}/tracks.py (99%) mode change 100755 => 100644 music_assistant/controllers/metadata/__init__.py create mode 100755 music_assistant/controllers/metadata/metadata.py rename music_assistant/controllers/{music/__init__.py => music.py} (95%) create mode 100644 music_assistant/music_providers/filesystem/__init__.py rename music_assistant/music_providers/{ => filesystem}/filesystem.py (100%) create mode 100644 music_assistant/music_providers/qobuz/__init__.py rename music_assistant/music_providers/{ => qobuz}/qobuz.py (100%) create mode 100644 music_assistant/music_providers/spotify/__init__.py rename music_assistant/music_providers/{ => spotify}/librespot/freebsd/librespot (100%) rename music_assistant/music_providers/{ => spotify}/librespot/linux/librespot-aarch64 (100%) rename music_assistant/music_providers/{ => spotify}/librespot/linux/librespot-arm (100%) rename music_assistant/music_providers/{ => spotify}/librespot/linux/librespot-armhf (100%) rename music_assistant/music_providers/{ => spotify}/librespot/linux/librespot-armv7 (100%) rename music_assistant/music_providers/{ => spotify}/librespot/linux/librespot-x86_64 (100%) rename music_assistant/music_providers/{ => spotify}/librespot/osx/librespot (100%) rename music_assistant/music_providers/{ => spotify}/librespot/windows/librespot.exe (100%) rename music_assistant/music_providers/{ => spotify}/spotify.py (100%) create mode 100644 music_assistant/music_providers/tunein/__init__.py rename music_assistant/music_providers/{ => tunein}/tunein.py (100%) create mode 100644 music_assistant/music_providers/url/__init__.py rename music_assistant/music_providers/{ => url}/url.py (100%) diff --git a/music_assistant/helpers/cache.py b/music_assistant/controllers/cache.py similarity index 97% rename from music_assistant/helpers/cache.py rename to music_assistant/controllers/cache.py index 79cbb1e3..2ecb5d02 100644 --- a/music_assistant/helpers/cache.py +++ b/music_assistant/controllers/cache.py @@ -9,14 +9,14 @@ from collections import OrderedDict from collections.abc import MutableMapping from typing import TYPE_CHECKING, Any, Iterator, Optional -from music_assistant.helpers.database import TABLE_CACHE +from music_assistant.controllers.database import TABLE_CACHE if TYPE_CHECKING: from music_assistant.mass import MusicAssistant -class Cache: - """Basic cache using both memory and database.""" +class CacheController: + """Basic cache controller using both memory and database.""" def __init__(self, mass: MusicAssistant) -> None: """Initialize our caching class.""" diff --git a/music_assistant/helpers/database.py b/music_assistant/controllers/database.py similarity index 99% rename from music_assistant/helpers/database.py rename to music_assistant/controllers/database.py index 2ad21880..025f6f48 100755 --- a/music_assistant/helpers/database.py +++ b/music_assistant/controllers/database.py @@ -24,8 +24,8 @@ TABLE_SETTINGS = "settings" TABLE_THUMBS = "thumbnails" -class Database: - """Class that holds the (logic to the) database.""" +class DatabaseController: + """Controller that holds the (connection to the) database.""" def __init__(self, mass: MusicAssistant): """Initialize class.""" diff --git a/music_assistant/controllers/media/__init__.py b/music_assistant/controllers/media/__init__.py new file mode 100644 index 00000000..3256b9e0 --- /dev/null +++ b/music_assistant/controllers/media/__init__.py @@ -0,0 +1 @@ +"""Package with Media controllers.""" diff --git a/music_assistant/controllers/music/albums.py b/music_assistant/controllers/media/albums.py similarity index 98% rename from music_assistant/controllers/music/albums.py rename to music_assistant/controllers/media/albums.py index 9aa3cbfd..6716aa90 100644 --- a/music_assistant/controllers/music/albums.py +++ b/music_assistant/controllers/media/albums.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio from random import choice, random -from typing import List, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union from music_assistant.constants import VARIOUS_ARTISTS +from music_assistant.controllers.database import TABLE_ALBUMS, TABLE_TRACKS +from music_assistant.controllers.media.base import MediaControllerBase from music_assistant.helpers.compare import compare_album, loose_compare_strings -from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_TRACKS from music_assistant.helpers.json import json_serializer from music_assistant.models.enums import EventType, MusicProviderFeature, ProviderType from music_assistant.models.errors import ( @@ -15,7 +16,6 @@ from music_assistant.models.errors import ( UnsupportedFeaturedException, ) from music_assistant.models.event import MassEvent -from music_assistant.models.media_controller import MediaControllerBase from music_assistant.models.media_items import ( Album, AlbumType, @@ -24,7 +24,9 @@ from music_assistant.models.media_items import ( MediaType, Track, ) -from music_assistant.models.music_provider import MusicProvider + +if TYPE_CHECKING: + from music_assistant.models.music_provider import MusicProvider class AlbumsController(MediaControllerBase[Album]): diff --git a/music_assistant/controllers/music/artists.py b/music_assistant/controllers/media/artists.py similarity index 97% rename from music_assistant/controllers/music/artists.py rename to music_assistant/controllers/media/artists.py index e38fd4c2..34c6c7d4 100644 --- a/music_assistant/controllers/music/artists.py +++ b/music_assistant/controllers/media/artists.py @@ -4,11 +4,16 @@ import asyncio import itertools from random import choice, random from time import time -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from music_assistant.constants import VARIOUS_ARTISTS, VARIOUS_ARTISTS_ID +from music_assistant.controllers.database import ( + TABLE_ALBUMS, + TABLE_ARTISTS, + TABLE_TRACKS, +) +from music_assistant.controllers.media.base import MediaControllerBase from music_assistant.helpers.compare import compare_strings -from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_ARTISTS, TABLE_TRACKS from music_assistant.helpers.json import json_serializer from music_assistant.models.enums import EventType, MusicProviderFeature, ProviderType from music_assistant.models.errors import ( @@ -16,7 +21,6 @@ from music_assistant.models.errors import ( UnsupportedFeaturedException, ) from music_assistant.models.event import MassEvent -from music_assistant.models.media_controller import MediaControllerBase from music_assistant.models.media_items import ( Album, AlbumType, @@ -26,7 +30,9 @@ from music_assistant.models.media_items import ( PagedItems, Track, ) -from music_assistant.models.music_provider import MusicProvider + +if TYPE_CHECKING: + from music_assistant.models.music_provider import MusicProvider class ArtistsController(MediaControllerBase[Artist]): @@ -395,7 +401,7 @@ class ArtistsController(MediaControllerBase[Artist]): "No Music Provider found that supports requesting similar tracks." ) - async def _match(self, db_artist: Artist, provider: MusicProvider) -> bool: + async def _match(self, db_artist: Artist, provider: "MusicProvider") -> bool: """Try to find matching artists on given provider for the provided (database) artist.""" self.logger.debug( "Trying to match artist %s on provider %s", db_artist.name, provider.name diff --git a/music_assistant/models/media_controller.py b/music_assistant/controllers/media/base.py similarity index 98% rename from music_assistant/models/media_controller.py rename to music_assistant/controllers/media/base.py index 950feca7..833a85e1 100644 --- a/music_assistant/models/media_controller.py +++ b/music_assistant/controllers/media/base.py @@ -1,4 +1,4 @@ -"""Model for a base media_controller.""" +"""Base (ABC) MediaType specific controller.""" from __future__ import annotations import asyncio @@ -16,11 +16,20 @@ from typing import ( ) from music_assistant.helpers.json import json_serializer +from music_assistant.models.enums import ( + EventType, + MediaType, + MusicProviderFeature, + ProviderType, +) from music_assistant.models.errors import MediaNotFoundError from music_assistant.models.event import MassEvent - -from .enums import EventType, MediaType, MusicProviderFeature, ProviderType -from .media_items import MediaItemType, PagedItems, Track, media_from_dict +from music_assistant.models.media_items import ( + MediaItemType, + PagedItems, + Track, + media_from_dict, +) if TYPE_CHECKING: from music_assistant.mass import MusicAssistant diff --git a/music_assistant/controllers/music/playlists.py b/music_assistant/controllers/media/playlists.py similarity index 99% rename from music_assistant/controllers/music/playlists.py rename to music_assistant/controllers/media/playlists.py index a27b6426..69baf852 100644 --- a/music_assistant/controllers/music/playlists.py +++ b/music_assistant/controllers/media/playlists.py @@ -6,7 +6,7 @@ from random import choice, random from time import time from typing import Any, List, Optional, Tuple -from music_assistant.helpers.database import TABLE_PLAYLISTS +from music_assistant.controllers.database import TABLE_PLAYLISTS from music_assistant.helpers.json import json_serializer from music_assistant.helpers.uri import create_uri from music_assistant.models.enums import ( @@ -22,9 +22,10 @@ from music_assistant.models.errors import ( UnsupportedFeaturedException, ) from music_assistant.models.event import MassEvent -from music_assistant.models.media_controller import MediaControllerBase from music_assistant.models.media_items import Playlist, Track +from .base import MediaControllerBase + class PlaylistController(MediaControllerBase[Playlist]): """Controller managing MediaItems of type Playlist.""" diff --git a/music_assistant/controllers/music/radio.py b/music_assistant/controllers/media/radio.py similarity index 97% rename from music_assistant/controllers/music/radio.py rename to music_assistant/controllers/media/radio.py index c476ae47..3ba04d59 100644 --- a/music_assistant/controllers/music/radio.py +++ b/music_assistant/controllers/media/radio.py @@ -5,14 +5,15 @@ import asyncio from time import time from typing import List, Optional +from music_assistant.controllers.database import TABLE_RADIOS from music_assistant.helpers.compare import loose_compare_strings -from music_assistant.helpers.database import TABLE_RADIOS from music_assistant.helpers.json import json_serializer 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, Track +from .base import MediaControllerBase + class RadioController(MediaControllerBase[Radio]): """Controller managing MediaItems of type Radio.""" diff --git a/music_assistant/controllers/music/tracks.py b/music_assistant/controllers/media/tracks.py similarity index 99% rename from music_assistant/controllers/music/tracks.py rename to music_assistant/controllers/media/tracks.py index 11ddfb77..c901fb73 100644 --- a/music_assistant/controllers/music/tracks.py +++ b/music_assistant/controllers/media/tracks.py @@ -4,12 +4,12 @@ from __future__ import annotations import asyncio from typing import List, Optional, Union +from music_assistant.controllers.database import TABLE_TRACKS from music_assistant.helpers.compare import ( compare_artists, compare_track, loose_compare_strings, ) -from music_assistant.helpers.database import TABLE_TRACKS from music_assistant.helpers.json import json_serializer from music_assistant.models.enums import ( EventType, @@ -22,7 +22,6 @@ from music_assistant.models.errors import ( UnsupportedFeaturedException, ) from music_assistant.models.event import MassEvent -from music_assistant.models.media_controller import MediaControllerBase from music_assistant.models.media_items import ( Album, Artist, @@ -31,6 +30,8 @@ from music_assistant.models.media_items import ( TrackAlbumMapping, ) +from .base import MediaControllerBase + class TracksController(MediaControllerBase[Track]): """Controller managing MediaItems of type Track.""" diff --git a/music_assistant/controllers/metadata/__init__.py b/music_assistant/controllers/metadata/__init__.py old mode 100755 new mode 100644 index eeb497e8..d8003cd7 --- a/music_assistant/controllers/metadata/__init__.py +++ b/music_assistant/controllers/metadata/__init__.py @@ -1,267 +1,3 @@ -"""All logic for metadata retrieval.""" -from __future__ import annotations +"""Package with Metadata controller and providers.""" -from base64 import b64encode -from time import time -from typing import TYPE_CHECKING, Optional - -from music_assistant.helpers.database import TABLE_THUMBS -from music_assistant.helpers.images import create_collage, create_thumbnail -from music_assistant.models.enums import ImageType, MediaType -from music_assistant.models.media_items import ( - Album, - Artist, - ItemMapping, - MediaItemImage, - MediaItemType, - Playlist, - Radio, - Track, -) - -from .audiodb import TheAudioDb -from .fanarttv import FanartTv -from .musicbrainz import MusicBrainz - -if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - - -class MetaDataController: - """Several helpers to search and store metadata for mediaitems.""" - - def __init__(self, mass: MusicAssistant) -> None: - """Initialize class.""" - self.mass = mass - self.cache = mass.cache - self.logger = mass.logger.getChild("metadata") - self.fanarttv = FanartTv(mass) - self.musicbrainz = MusicBrainz(mass) - self.audiodb = TheAudioDb(mass) - self._pref_lang: Optional[str] = None - - @property - def preferred_language(self) -> str: - """ - Return preferred language for metadata as 2 letter country code (uppercase). - - Defaults to English (EN). - """ - return self._pref_lang or "EN" - - @preferred_language.setter - def preferred_language(self, lang: str) -> None: - """ - Set preferred language to 2 letter country code. - - Can only be set once. - """ - if self._pref_lang is None: - self._pref_lang = lang.upper() - - async def setup(self): - """Async initialize of module.""" - - 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) - - if artist.musicbrainz_id: - if metadata := await self.fanarttv.get_artist_metadata(artist): - artist.metadata.update(metadata) - if metadata := await self.audiodb.get_artist_metadata(artist): - artist.metadata.update(metadata) - - 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): - album.metadata.update(metadata) - if metadata := await self.fanarttv.get_album_metadata(album): - album.metadata.update(metadata) - - 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) - - 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() - image_urls = set() - for track in await self.mass.music.playlists.tracks( - playlist.item_id, playlist.provider - ): - if not playlist.image and track.image: - image_urls.add(track.image.url) - if track.media_type != MediaType.TRACK: - # filter out radio items - continue - if track.metadata.genres: - playlist.metadata.genres.update(track.metadata.genres) - elif track.album and track.album.metadata.genres: - playlist.metadata.genres.update(track.album.metadata.genres) - # create collage thumb/fanart from playlist tracks - if image_urls: - fake_path = f"playlist_collage.{playlist.provider.value}.{playlist.item_id}" - collage = await create_collage(self.mass, list(image_urls)) - match = {"path": fake_path, "size": 0} - await self.mass.database.insert( - TABLE_THUMBS, {**match, "data": collage}, allow_replace=True - ) - playlist.metadata.images = [ - MediaItemImage(ImageType.THUMB, fake_path, True) - ] - - async def get_radio_metadata(self, radio: Radio) -> None: - """Get/update rich metadata for a radio station.""" - # NOTE: we do not have any metadata for radio so consider this future proofing ;-) - radio.metadata.last_refresh = int(time()) - - async def get_artist_musicbrainz_id(self, artist: Artist) -> str | None: - """Fetch musicbrainz id by performing search using the artist name, albums and tracks.""" - ref_albums = await self.mass.music.artists.albums(artist=artist) - # first try audiodb - if musicbrainz_id := await self.audiodb.get_musicbrainz_id(artist, ref_albums): - return musicbrainz_id - # try again with musicbrainz with albums with upc - for ref_album in ref_albums: - if ref_album.upc: - if musicbrainz_id := await self.musicbrainz.get_mb_artist_id( - artist.name, - album_upc=ref_album.upc, - ): - return musicbrainz_id - if ref_album.musicbrainz_id: - if musicbrainz_id := await self.musicbrainz.search_artist_by_album_mbid( - artist.name, ref_album.musicbrainz_id - ): - return musicbrainz_id - - # try again with matching on track isrc - ref_tracks = await self.mass.music.artists.toptracks(artist=artist) - for ref_track in ref_tracks: - for isrc in ref_track.isrcs: - if musicbrainz_id := await self.musicbrainz.get_mb_artist_id( - artist.name, - track_isrc=isrc, - ): - return musicbrainz_id - - # last restort: track matching by name - for ref_track in ref_tracks: - if musicbrainz_id := await self.musicbrainz.get_mb_artist_id( - artist.name, - trackname=ref_track.name, - ): - return musicbrainz_id - # lookup failed - 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( - self, - media_item: MediaItemType, - img_type: ImageType = ImageType.THUMB, - size: int = 0, - ) -> bytes | None: - """Get image data for given MedaItem.""" - img_path = await self.get_image_url_for_item( - media_item=media_item, - img_type=img_type, - allow_local=True, - local_as_base64=False, - ) - if not img_path: - return None - return await self.get_thumbnail(img_path, size) - - async def get_image_url_for_item( - self, - media_item: MediaItemType, - img_type: ImageType = ImageType.THUMB, - allow_local: bool = True, - local_as_base64: bool = False, - ) -> str | None: - """Get url to image for given media media_item.""" - if not media_item: - return None - if isinstance(media_item, ItemMapping): - media_item = await self.mass.music.get_item_by_uri(media_item.uri) - if media_item and media_item.metadata.images: - for img in media_item.metadata.images: - if img.type != img_type: - continue - if img.is_file and not allow_local: - continue - if img.is_file and local_as_base64: - # return base64 string of the image (compatible with browsers) - return await self.get_thumbnail(img.url, base64=True) - return img.url - - # retry with track's album - if media_item.media_type == MediaType.TRACK and media_item.album: - return await self.get_image_url_for_item( - media_item.album, img_type, allow_local, local_as_base64 - ) - - # try artist instead for albums - if media_item.media_type == MediaType.ALBUM and media_item.artist: - return await self.get_image_url_for_item( - media_item.artist, img_type, allow_local, local_as_base64 - ) - - # last resort: track artist(s) - if media_item.media_type == MediaType.TRACK and media_item.artists: - for artist in media_item.artists: - return await self.get_image_url_for_item( - artist, img_type, allow_local, local_as_base64 - ) - - return None - - async def get_thumbnail( - self, path: str, size: int = 0, base64: bool = False - ) -> bytes | str: - """Get/create thumbnail image for path (image url or local path).""" - # check if we already have this cached in the db - match_path = path.split("?")[0].split("&")[0] - match = {"path": match_path, "size": size} - if result := await self.mass.database.get_row(TABLE_THUMBS, match): - thumbnail = result["data"] - else: - # create thumbnail if it doesn't exist - thumbnail = await create_thumbnail(self.mass, path, size) - await self.mass.database.insert( - TABLE_THUMBS, {**match, "data": thumbnail}, allow_replace=True - ) - if base64: - enc_image = b64encode(thumbnail).decode() - thumbnail = f"data:image/png;base64,{enc_image}" - return thumbnail +from .metadata import MetaDataController # noqa diff --git a/music_assistant/controllers/metadata/audiodb.py b/music_assistant/controllers/metadata/audiodb.py index f41dcf97..4610ac80 100755 --- a/music_assistant/controllers/metadata/audiodb.py +++ b/music_assistant/controllers/metadata/audiodb.py @@ -7,10 +7,10 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional import aiohttp from asyncio_throttle import Throttler +from music_assistant.controllers.cache import use_cache from music_assistant.helpers.app_vars import ( # pylint: disable=no-name-in-module app_var, ) -from music_assistant.helpers.cache import use_cache from music_assistant.helpers.compare import compare_strings from music_assistant.models.media_items import ( Album, diff --git a/music_assistant/controllers/metadata/fanarttv.py b/music_assistant/controllers/metadata/fanarttv.py index 965266c5..2c0bc9ca 100755 --- a/music_assistant/controllers/metadata/fanarttv.py +++ b/music_assistant/controllers/metadata/fanarttv.py @@ -7,10 +7,10 @@ from typing import TYPE_CHECKING, Optional import aiohttp from asyncio_throttle import Throttler +from music_assistant.controllers.cache import use_cache from music_assistant.helpers.app_vars import ( # pylint: disable=no-name-in-module app_var, ) -from music_assistant.helpers.cache import use_cache from music_assistant.models.media_items import ( Album, Artist, diff --git a/music_assistant/controllers/metadata/metadata.py b/music_assistant/controllers/metadata/metadata.py new file mode 100755 index 00000000..e49c1074 --- /dev/null +++ b/music_assistant/controllers/metadata/metadata.py @@ -0,0 +1,267 @@ +"""All logic for metadata retrieval.""" +from __future__ import annotations + +from base64 import b64encode +from time import time +from typing import TYPE_CHECKING, Optional + +from music_assistant.controllers.database import TABLE_THUMBS +from music_assistant.helpers.images import create_collage, create_thumbnail +from music_assistant.models.enums import ImageType, MediaType +from music_assistant.models.media_items import ( + Album, + Artist, + ItemMapping, + MediaItemImage, + MediaItemType, + Playlist, + Radio, + Track, +) + +from .audiodb import TheAudioDb +from .fanarttv import FanartTv +from .musicbrainz import MusicBrainz + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +class MetaDataController: + """Several helpers to search and store metadata for mediaitems.""" + + def __init__(self, mass: MusicAssistant) -> None: + """Initialize class.""" + self.mass = mass + self.cache = mass.cache + self.logger = mass.logger.getChild("metadata") + self.fanarttv = FanartTv(mass) + self.musicbrainz = MusicBrainz(mass) + self.audiodb = TheAudioDb(mass) + self._pref_lang: Optional[str] = None + + @property + def preferred_language(self) -> str: + """ + Return preferred language for metadata as 2 letter country code (uppercase). + + Defaults to English (EN). + """ + return self._pref_lang or "EN" + + @preferred_language.setter + def preferred_language(self, lang: str) -> None: + """ + Set preferred language to 2 letter country code. + + Can only be set once. + """ + if self._pref_lang is None: + self._pref_lang = lang.upper() + + async def setup(self): + """Async initialize of module.""" + + 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) + + if artist.musicbrainz_id: + if metadata := await self.fanarttv.get_artist_metadata(artist): + artist.metadata.update(metadata) + if metadata := await self.audiodb.get_artist_metadata(artist): + artist.metadata.update(metadata) + + 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): + album.metadata.update(metadata) + if metadata := await self.fanarttv.get_album_metadata(album): + album.metadata.update(metadata) + + 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) + + 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() + image_urls = set() + for track in await self.mass.music.playlists.tracks( + playlist.item_id, playlist.provider + ): + if not playlist.image and track.image: + image_urls.add(track.image.url) + if track.media_type != MediaType.TRACK: + # filter out radio items + continue + if track.metadata.genres: + playlist.metadata.genres.update(track.metadata.genres) + elif track.album and track.album.metadata.genres: + playlist.metadata.genres.update(track.album.metadata.genres) + # create collage thumb/fanart from playlist tracks + if image_urls: + fake_path = f"playlist_collage.{playlist.provider.value}.{playlist.item_id}" + collage = await create_collage(self.mass, list(image_urls)) + match = {"path": fake_path, "size": 0} + await self.mass.database.insert( + TABLE_THUMBS, {**match, "data": collage}, allow_replace=True + ) + playlist.metadata.images = [ + MediaItemImage(ImageType.THUMB, fake_path, True) + ] + + async def get_radio_metadata(self, radio: Radio) -> None: + """Get/update rich metadata for a radio station.""" + # NOTE: we do not have any metadata for radio so consider this future proofing ;-) + radio.metadata.last_refresh = int(time()) + + async def get_artist_musicbrainz_id(self, artist: Artist) -> str | None: + """Fetch musicbrainz id by performing search using the artist name, albums and tracks.""" + ref_albums = await self.mass.music.artists.albums(artist=artist) + # first try audiodb + if musicbrainz_id := await self.audiodb.get_musicbrainz_id(artist, ref_albums): + return musicbrainz_id + # try again with musicbrainz with albums with upc + for ref_album in ref_albums: + if ref_album.upc: + if musicbrainz_id := await self.musicbrainz.get_mb_artist_id( + artist.name, + album_upc=ref_album.upc, + ): + return musicbrainz_id + if ref_album.musicbrainz_id: + if musicbrainz_id := await self.musicbrainz.search_artist_by_album_mbid( + artist.name, ref_album.musicbrainz_id + ): + return musicbrainz_id + + # try again with matching on track isrc + ref_tracks = await self.mass.music.artists.toptracks(artist=artist) + for ref_track in ref_tracks: + for isrc in ref_track.isrcs: + if musicbrainz_id := await self.musicbrainz.get_mb_artist_id( + artist.name, + track_isrc=isrc, + ): + return musicbrainz_id + + # last restort: track matching by name + for ref_track in ref_tracks: + if musicbrainz_id := await self.musicbrainz.get_mb_artist_id( + artist.name, + trackname=ref_track.name, + ): + return musicbrainz_id + # lookup failed + 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( + self, + media_item: MediaItemType, + img_type: ImageType = ImageType.THUMB, + size: int = 0, + ) -> bytes | None: + """Get image data for given MedaItem.""" + img_path = await self.get_image_url_for_item( + media_item=media_item, + img_type=img_type, + allow_local=True, + local_as_base64=False, + ) + if not img_path: + return None + return await self.get_thumbnail(img_path, size) + + async def get_image_url_for_item( + self, + media_item: MediaItemType, + img_type: ImageType = ImageType.THUMB, + allow_local: bool = True, + local_as_base64: bool = False, + ) -> str | None: + """Get url to image for given media media_item.""" + if not media_item: + return None + if isinstance(media_item, ItemMapping): + media_item = await self.mass.music.get_item_by_uri(media_item.uri) + if media_item and media_item.metadata.images: + for img in media_item.metadata.images: + if img.type != img_type: + continue + if img.is_file and not allow_local: + continue + if img.is_file and local_as_base64: + # return base64 string of the image (compatible with browsers) + return await self.get_thumbnail(img.url, base64=True) + return img.url + + # retry with track's album + if media_item.media_type == MediaType.TRACK and media_item.album: + return await self.get_image_url_for_item( + media_item.album, img_type, allow_local, local_as_base64 + ) + + # try artist instead for albums + if media_item.media_type == MediaType.ALBUM and media_item.artist: + return await self.get_image_url_for_item( + media_item.artist, img_type, allow_local, local_as_base64 + ) + + # last resort: track artist(s) + if media_item.media_type == MediaType.TRACK and media_item.artists: + for artist in media_item.artists: + return await self.get_image_url_for_item( + artist, img_type, allow_local, local_as_base64 + ) + + return None + + async def get_thumbnail( + self, path: str, size: int = 0, base64: bool = False + ) -> bytes | str: + """Get/create thumbnail image for path (image url or local path).""" + # check if we already have this cached in the db + match_path = path.split("?")[0].split("&")[0] + match = {"path": match_path, "size": size} + if result := await self.mass.database.get_row(TABLE_THUMBS, match): + thumbnail = result["data"] + else: + # create thumbnail if it doesn't exist + thumbnail = await create_thumbnail(self.mass, path, size) + await self.mass.database.insert( + TABLE_THUMBS, {**match, "data": thumbnail}, allow_replace=True + ) + if base64: + enc_image = b64encode(thumbnail).decode() + thumbnail = f"data:image/png;base64,{enc_image}" + return thumbnail diff --git a/music_assistant/controllers/metadata/musicbrainz.py b/music_assistant/controllers/metadata/musicbrainz.py index 9eccfc01..75882834 100644 --- a/music_assistant/controllers/metadata/musicbrainz.py +++ b/music_assistant/controllers/metadata/musicbrainz.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING import aiohttp from asyncio_throttle import Throttler -from music_assistant.helpers.cache import use_cache +from music_assistant.controllers.cache import use_cache from music_assistant.helpers.compare import compare_strings from music_assistant.helpers.util import create_sort_name diff --git a/music_assistant/controllers/music/__init__.py b/music_assistant/controllers/music.py similarity index 95% rename from music_assistant/controllers/music/__init__.py rename to music_assistant/controllers/music.py index ce0f30a4..ccf56983 100755 --- a/music_assistant/controllers/music/__init__.py +++ b/music_assistant/controllers/music.py @@ -6,12 +6,12 @@ import itertools import statistics from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union -from music_assistant.controllers.music.albums import AlbumsController -from music_assistant.controllers.music.artists import ArtistsController -from music_assistant.controllers.music.playlists import PlaylistController -from music_assistant.controllers.music.radio import RadioController -from music_assistant.controllers.music.tracks import TracksController -from music_assistant.helpers.database import TABLE_PLAYLOG, TABLE_TRACK_LOUDNESS +from music_assistant.controllers.database import TABLE_PLAYLOG, TABLE_TRACK_LOUDNESS +from music_assistant.controllers.media.albums import AlbumsController +from music_assistant.controllers.media.artists import ArtistsController +from music_assistant.controllers.media.playlists import PlaylistController +from music_assistant.controllers.media.radio import RadioController +from music_assistant.controllers.media.tracks import TracksController from music_assistant.helpers.datetime import utc_timestamp from music_assistant.helpers.uri import parse_uri from music_assistant.models.config import MusicProviderConfig @@ -28,12 +28,12 @@ from music_assistant.models.media_items import ( media_from_dict, ) from music_assistant.models.music_provider import MusicProvider -from music_assistant.music_providers.filesystem import FileSystemProvider -from music_assistant.music_providers.qobuz import QobuzProvider +from music_assistant.music_providers.filesystem.filesystem import FileSystemProvider +from music_assistant.music_providers.qobuz.qobuz import QobuzProvider from music_assistant.music_providers.spotify import SpotifyProvider -from music_assistant.music_providers.tunein import TuneInProvider -from music_assistant.music_providers.url import PROVIDER_CONFIG as URL_CONFIG -from music_assistant.music_providers.url import URLProvider +from music_assistant.music_providers.tunein.tunein import TuneInProvider +from music_assistant.music_providers.url.url import PROVIDER_CONFIG as URL_CONFIG +from music_assistant.music_providers.url.url import URLProvider from music_assistant.music_providers.ytmusic import YoutubeMusicProvider if TYPE_CHECKING: diff --git a/music_assistant/helpers/images.py b/music_assistant/helpers/images.py index 8034ba88..dfa98173 100644 --- a/music_assistant/helpers/images.py +++ b/music_assistant/helpers/images.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, List, Optional from PIL import Image -from music_assistant.helpers.database import TABLE_THUMBS +from music_assistant.controllers.database import TABLE_THUMBS from music_assistant.helpers.tags import get_embedded_image if TYPE_CHECKING: diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 901237d7..e4144820 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -13,12 +13,12 @@ from uuid import uuid4 import aiohttp from music_assistant.constants import ROOT_LOGGER_NAME -from music_assistant.controllers.metadata import MetaDataController +from music_assistant.controllers.cache import CacheController +from music_assistant.controllers.database import DatabaseController +from music_assistant.controllers.metadata.metadata import MetaDataController from music_assistant.controllers.music import MusicController from music_assistant.controllers.players import PlayerController from music_assistant.controllers.streams import StreamsController -from music_assistant.helpers.cache import Cache -from music_assistant.helpers.database import Database from music_assistant.models.background_job import BackgroundJob from music_assistant.models.config import MassConfig from music_assistant.models.enums import EventType, JobStatus @@ -57,8 +57,8 @@ class MusicAssistant: self._jobs_event = asyncio.Event() # init core controllers - self.database = Database(self) - self.cache = Cache(self) + self.database = DatabaseController(self) + self.cache = CacheController(self) self.metadata = MetaDataController(self) self.music = MusicController(self) self.players = PlayerController(self) diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 61b863d0..b4a6e789 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -1,4 +1,4 @@ -"""Model for a Music Providers.""" +"""Model/base for a Music Provider implementation.""" from __future__ import annotations from abc import abstractmethod diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 8662d667..43f1640b 100644 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -1,4 +1,4 @@ -"""Model and helpders for a PlayerQueue.""" +"""Model for a PlayerQueue.""" from __future__ import annotations import asyncio diff --git a/music_assistant/music_providers/__init__.py b/music_assistant/music_providers/__init__.py index 01895ef6..3a28df8a 100644 --- a/music_assistant/music_providers/__init__.py +++ b/music_assistant/music_providers/__init__.py @@ -1 +1 @@ -"""Package with Music Providers.""" +"""Package with Music Provider controllers.""" diff --git a/music_assistant/music_providers/filesystem/__init__.py b/music_assistant/music_providers/filesystem/__init__.py new file mode 100644 index 00000000..363dc120 --- /dev/null +++ b/music_assistant/music_providers/filesystem/__init__.py @@ -0,0 +1,3 @@ +"""Package with FileSystem Music provider(s).""" + +from .filesystem import FileSystemProvider # noqa diff --git a/music_assistant/music_providers/filesystem.py b/music_assistant/music_providers/filesystem/filesystem.py similarity index 100% rename from music_assistant/music_providers/filesystem.py rename to music_assistant/music_providers/filesystem/filesystem.py diff --git a/music_assistant/music_providers/qobuz/__init__.py b/music_assistant/music_providers/qobuz/__init__.py new file mode 100644 index 00000000..d5f655ff --- /dev/null +++ b/music_assistant/music_providers/qobuz/__init__.py @@ -0,0 +1,3 @@ +"""Package with Qobuz Music provider.""" + +from .qobuz import QobuzProvider # noqa diff --git a/music_assistant/music_providers/qobuz.py b/music_assistant/music_providers/qobuz/qobuz.py similarity index 100% rename from music_assistant/music_providers/qobuz.py rename to music_assistant/music_providers/qobuz/qobuz.py diff --git a/music_assistant/music_providers/spotify/__init__.py b/music_assistant/music_providers/spotify/__init__.py new file mode 100644 index 00000000..ab248afb --- /dev/null +++ b/music_assistant/music_providers/spotify/__init__.py @@ -0,0 +1,3 @@ +"""Package with Spotify Music provider.""" + +from .spotify import SpotifyProvider # noqa diff --git a/music_assistant/music_providers/librespot/freebsd/librespot b/music_assistant/music_providers/spotify/librespot/freebsd/librespot similarity index 100% rename from music_assistant/music_providers/librespot/freebsd/librespot rename to music_assistant/music_providers/spotify/librespot/freebsd/librespot diff --git a/music_assistant/music_providers/librespot/linux/librespot-aarch64 b/music_assistant/music_providers/spotify/librespot/linux/librespot-aarch64 similarity index 100% rename from music_assistant/music_providers/librespot/linux/librespot-aarch64 rename to music_assistant/music_providers/spotify/librespot/linux/librespot-aarch64 diff --git a/music_assistant/music_providers/librespot/linux/librespot-arm b/music_assistant/music_providers/spotify/librespot/linux/librespot-arm similarity index 100% rename from music_assistant/music_providers/librespot/linux/librespot-arm rename to music_assistant/music_providers/spotify/librespot/linux/librespot-arm diff --git a/music_assistant/music_providers/librespot/linux/librespot-armhf b/music_assistant/music_providers/spotify/librespot/linux/librespot-armhf similarity index 100% rename from music_assistant/music_providers/librespot/linux/librespot-armhf rename to music_assistant/music_providers/spotify/librespot/linux/librespot-armhf diff --git a/music_assistant/music_providers/librespot/linux/librespot-armv7 b/music_assistant/music_providers/spotify/librespot/linux/librespot-armv7 similarity index 100% rename from music_assistant/music_providers/librespot/linux/librespot-armv7 rename to music_assistant/music_providers/spotify/librespot/linux/librespot-armv7 diff --git a/music_assistant/music_providers/librespot/linux/librespot-x86_64 b/music_assistant/music_providers/spotify/librespot/linux/librespot-x86_64 similarity index 100% rename from music_assistant/music_providers/librespot/linux/librespot-x86_64 rename to music_assistant/music_providers/spotify/librespot/linux/librespot-x86_64 diff --git a/music_assistant/music_providers/librespot/osx/librespot b/music_assistant/music_providers/spotify/librespot/osx/librespot similarity index 100% rename from music_assistant/music_providers/librespot/osx/librespot rename to music_assistant/music_providers/spotify/librespot/osx/librespot diff --git a/music_assistant/music_providers/librespot/windows/librespot.exe b/music_assistant/music_providers/spotify/librespot/windows/librespot.exe similarity index 100% rename from music_assistant/music_providers/librespot/windows/librespot.exe rename to music_assistant/music_providers/spotify/librespot/windows/librespot.exe diff --git a/music_assistant/music_providers/spotify.py b/music_assistant/music_providers/spotify/spotify.py similarity index 100% rename from music_assistant/music_providers/spotify.py rename to music_assistant/music_providers/spotify/spotify.py diff --git a/music_assistant/music_providers/tunein/__init__.py b/music_assistant/music_providers/tunein/__init__.py new file mode 100644 index 00000000..a62872e1 --- /dev/null +++ b/music_assistant/music_providers/tunein/__init__.py @@ -0,0 +1,3 @@ +"""Package with Tune-In Music provider.""" + +from .tunein import TuneInProvider # noqa diff --git a/music_assistant/music_providers/tunein.py b/music_assistant/music_providers/tunein/tunein.py similarity index 100% rename from music_assistant/music_providers/tunein.py rename to music_assistant/music_providers/tunein/tunein.py diff --git a/music_assistant/music_providers/url/__init__.py b/music_assistant/music_providers/url/__init__.py new file mode 100644 index 00000000..e3aea64e --- /dev/null +++ b/music_assistant/music_providers/url/__init__.py @@ -0,0 +1,3 @@ +"""Package with URL Music provider.""" + +from .url import URLProvider # noqa diff --git a/music_assistant/music_providers/url.py b/music_assistant/music_providers/url/url.py similarity index 100% rename from music_assistant/music_providers/url.py rename to music_assistant/music_providers/url/url.py -- 2.34.1