From: Marcel van der Veldt Date: Wed, 15 Jun 2022 13:40:43 +0000 (+0200) Subject: move music providers into top level folder X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f592fba858b4c48f0fe08783974a8a688e59e42f;p=music-assistant-server.git move music providers into top level folder --- diff --git a/music_assistant/controllers/music/__init__.py b/music_assistant/controllers/music/__init__.py index abd47961..8f6a4d88 100755 --- a/music_assistant/controllers/music/__init__.py +++ b/music_assistant/controllers/music/__init__.py @@ -21,14 +21,13 @@ from music_assistant.models.config import MusicProviderConfig from music_assistant.models.enums import MediaType, ProviderType from music_assistant.models.errors import MusicAssistantError, SetupFailedError from music_assistant.models.media_items import MediaItem, MediaItemType -from music_assistant.models.provider import MusicProvider - -from .providers.filesystem import FileSystemProvider -from .providers.qobuz import QobuzProvider -from .providers.spotify import SpotifyProvider -from .providers.tunein import TuneInProvider -from .providers.url import PROVIDER_CONFIG as URL_CONFIG -from .providers.url import URLProvider +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.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 if TYPE_CHECKING: from music_assistant.mass import MusicAssistant diff --git a/music_assistant/controllers/music/albums.py b/music_assistant/controllers/music/albums.py index 7b5df973..043f4445 100644 --- a/music_assistant/controllers/music/albums.py +++ b/music_assistant/controllers/music/albums.py @@ -21,7 +21,7 @@ from music_assistant.models.media_items import ( MediaType, Track, ) -from music_assistant.models.provider import MusicProvider +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/music/artists.py index 56fd477d..1476153d 100644 --- a/music_assistant/controllers/music/artists.py +++ b/music_assistant/controllers/music/artists.py @@ -19,7 +19,7 @@ from music_assistant.models.media_items import ( MediaType, Track, ) -from music_assistant.models.provider import MusicProvider +from music_assistant.models.music_provider import MusicProvider class ArtistsController(MediaControllerBase[Artist]): diff --git a/music_assistant/controllers/music/providers/__init__.py b/music_assistant/controllers/music/providers/__init__.py deleted file mode 100644 index 01895ef6..00000000 --- a/music_assistant/controllers/music/providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package with Music Providers.""" diff --git a/music_assistant/controllers/music/providers/filesystem.py b/music_assistant/controllers/music/providers/filesystem.py deleted file mode 100644 index e5ad97db..00000000 --- a/music_assistant/controllers/music/providers/filesystem.py +++ /dev/null @@ -1,861 +0,0 @@ -"""Filesystem musicprovider support for MusicAssistant.""" -from __future__ import annotations - -import asyncio -import os -import urllib.parse -from contextlib import asynccontextmanager -from pathlib import Path -from typing import AsyncGenerator, List, Optional, Set, Tuple - -import aiofiles -import xmltodict -from aiofiles.os import wrap -from aiofiles.threadpool.binary import AsyncFileIO -from tinytag.tinytag import TinyTag - -from music_assistant.helpers.audio import get_file_stream -from music_assistant.helpers.compare import compare_strings -from music_assistant.helpers.database import SCHEMA_VERSION -from music_assistant.helpers.util import ( - create_clean_string, - parse_title_and_version, - try_parse_int, -) -from music_assistant.models.enums import ProviderType -from music_assistant.models.errors import MediaNotFoundError, MusicAssistantError -from music_assistant.models.media_items import ( - Album, - AlbumType, - Artist, - ContentType, - ImageType, - ItemMapping, - MediaItemImage, - MediaItemProviderId, - MediaItemType, - MediaQuality, - MediaType, - Playlist, - StreamDetails, - Track, -) -from music_assistant.models.provider import MusicProvider - -FALLBACK_ARTIST = "Various Artists" -SPLITTERS = (";", ",", "Featuring", " Feat. ", " Feat ", "feat.", " & ", " / ") -CONTENT_TYPE_EXT = { - # map of supported file extensions (mapped to ContentType) - "mp3": ContentType.MP3, - "m4a": ContentType.M4A, - "flac": ContentType.FLAC, - "wav": ContentType.WAV, - "ogg": ContentType.OGG, - "wma": ContentType.WMA, -} - - -async def scantree(path: str) -> AsyncGenerator[os.DirEntry, None]: - """Recursively yield DirEntry objects for given directory.""" - - def is_dir(entry: os.DirEntry) -> bool: - return entry.is_dir(follow_symlinks=False) - - loop = asyncio.get_running_loop() - for entry in await loop.run_in_executor(None, os.scandir, path): - if await loop.run_in_executor(None, is_dir, entry): - async for subitem in scantree(entry.path): - yield subitem - else: - yield entry - - -def split_items(org_str: str) -> Tuple[str]: - """Split up a tags string by common splitter.""" - if isinstance(org_str, list): - return org_str - if org_str is None: - return tuple() - for splitter in SPLITTERS: - if splitter in org_str: - return tuple((x.strip() for x in org_str.split(splitter))) - return (org_str,) - - -class FileSystemProvider(MusicProvider): - """ - Implementation of a musicprovider for local files. - - Reads ID3 tags from file and falls back to parsing filename. - Optionally reads metadata from nfo files and images in folder structure /. - Supports m3u files only for playlists. - Supports having URI's from streaming providers within m3u playlist. - """ - - _attr_name = "Filesystem" - _attr_type = ProviderType.FILESYSTEM_LOCAL - _attr_supported_mediatypes = [ - MediaType.TRACK, - MediaType.PLAYLIST, - MediaType.ARTIST, - MediaType.ALBUM, - ] - - async def setup(self) -> bool: - """Handle async initialization of the provider.""" - - isdir = wrap(os.path.exists) - - if not await isdir(self.config.path): - raise MediaNotFoundError( - f"Music Directory {self.config.path} does not exist" - ) - - return True - - async def search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> List[MediaItemType]: - """Perform search on musicprovider.""" - result = [] - # searching the filesystem is slow and unreliable, - # instead we make some (slow) freaking queries to the db ;-) - params = {"name": f"%{search_query}%", "prov_type": f"%{self.type.value}%"} - if media_types is None or MediaType.TRACK in media_types: - query = "SELECT * FROM tracks WHERE name LIKE :name AND provider_ids LIKE :prov_type" - tracks = await self.mass.music.tracks.get_db_items(query, params) - result += tracks - if media_types is None or MediaType.ALBUM in media_types: - query = "SELECT * FROM albums WHERE name LIKE :name AND provider_ids LIKE :prov_type" - albums = await self.mass.music.albums.get_db_items(query, params) - result += albums - if media_types is None or MediaType.ARTIST in media_types: - query = "SELECT * FROM artists WHERE name LIKE :name AND provider_ids LIKE :prov_type" - artists = await self.mass.music.artists.get_db_items(query, params) - result += artists - if media_types is None or MediaType.PLAYLIST in media_types: - query = "SELECT * FROM playlists WHERE name LIKE :name AND provider_ids LIKE :prov_type" - playlists = await self.mass.music.playlists.get_db_items(query, params) - result += playlists - return result - - async def sync_library( - self, media_types: Optional[Tuple[MediaType]] = None - ) -> None: - """Run library sync for this provider.""" - cache_key = f"{self.id}.checksums" - prev_checksums = await self.mass.cache.get(cache_key, SCHEMA_VERSION) - if prev_checksums is None: - prev_checksums = {} - # find all music files in the music directory and all subfolders - # we work bottom up, as-in we derive all info from the tracks - cur_checksums = {} - async with self.mass.database.get_db() as db: - async for entry in scantree(self.config.path): - - # mtime is used as file checksum - stat = await asyncio.get_running_loop().run_in_executor( - None, entry.stat - ) - checksum = int(stat.st_mtime) - cur_checksums[entry.path] = checksum - if checksum == prev_checksums.get(entry.path): - continue - try: - if track := await self._parse_track(entry.path): - # process album - if track.album: - db_album = await self.mass.music.albums.add_db_item( - track.album, db=db - ) - if not db_album.in_library: - await self.mass.music.albums.set_db_library( - db_album.item_id, True, db=db - ) - # process (album)artist - if track.album.artist: - db_artist = await self.mass.music.artists.add_db_item( - track.album.artist, db=db - ) - if not db_artist.in_library: - await self.mass.music.artists.set_db_library( - db_artist.item_id, True, db=db - ) - # add/update track to db - db_track = await self.mass.music.tracks.add_db_item( - track, db=db - ) - if not db_track.in_library: - await self.mass.music.tracks.set_db_library( - db_track.item_id, True, db=db - ) - elif playlist := await self._parse_playlist(entry.path): - # add/update] playlist to db - playlist.metadata.checksum = checksum - await self.mass.music.playlists.add_db_item(playlist, db=db) - except Exception: # pylint: disable=broad-except - # we don't want the whole sync to crash on one file so we catch all exceptions here - self.logger.exception("Error processing %s", entry.path) - - # save checksums for next sync - await self.mass.cache.set(cache_key, cur_checksums, SCHEMA_VERSION) - - # work out deletions - deleted_files = set(prev_checksums.keys()) - set(cur_checksums.keys()) - artists: Set[ItemMapping] = set() - albums: Set[ItemMapping] = set() - # process deleted tracks - for file_path in deleted_files: - item_id = self._get_item_id(file_path) - if db_item := await self.mass.music.tracks.get_db_item_by_prov_id( - item_id, self.type - ): - await self.mass.music.tracks.remove_prov_mapping( - db_item.item_id, self.id - ) - # gather artists(s) attached to this track - for artist in db_item.artists: - artists.add(artist.item_id) - # gather album and albumartist(s) attached to this track - if db_item.album: - albums.add(db_item.album.item_id) - for artist in db_item.album.artists: - artists.add(artist.item_id) - # check if albums are deleted - for album_id in albums: - album = await self.mass.music.albums.get_db_item(album_id) - if not album: - continue - prov_album_id = next( - x.item_id for x in album.provider_ids if x.prov_id == self.id - ) - album_tracks = await self.get_album_tracks(prov_album_id) - if album_tracks: - continue - # album has no more tracks attached, delete prov mapping - await self.mass.music.albums.remove_prov_mapping(album_id) - # check if artists are deleted - for artist_id in artists: - artist = await self.mass.music.artists.get_db_item(artist_id) - prov_artist_id = next( - x.item_id for x in artist.provider_ids if x.prov_id == self.id - ) - artist_tracks = await self.get_artist_toptracks(prov_artist_id) - if artist_tracks: - continue - artist_albums = await self.get_artist_albums(prov_artist_id) - if artist_albums: - continue - # artist has no more tracks attached, delete prov mapping - await self.mass.music.artists.remove_prov_mapping(artist_id) - - async def get_artist(self, prov_artist_id: str) -> Artist: - """Get full artist details by id.""" - itempath = await self.get_filepath(MediaType.ARTIST, prov_artist_id) - if await self.exists(itempath): - # if path exists on disk allow parsing full details to allow refresh of metadata - return await self._parse_artist(artist_path=itempath) - return await self.mass.music.artists.get_db_item_by_prov_id( - provider_item_id=prov_artist_id, provider_id=self.id - ) - - async def get_album(self, prov_album_id: str) -> Album: - """Get full album details by id.""" - db_album = await self.mass.music.albums.get_db_item_by_prov_id( - provider_item_id=prov_album_id, provider_id=self.id - ) - if db_album is None: - raise MediaNotFoundError(f"Album not found: {prov_album_id}") - itempath = await self.get_filepath(MediaType.ALBUM, prov_album_id) - if await self.exists(itempath): - # if path exists on disk allow parsing full details to allow refresh of metadata - return await self._parse_album(None, itempath, db_album.artists) - return db_album - - async def get_track(self, prov_track_id: str) -> Track: - """Get full track details by id.""" - itempath = await self.get_filepath(MediaType.TRACK, prov_track_id) - return await self._parse_track(itempath) - - async def get_playlist(self, prov_playlist_id: str) -> Playlist: - """Get full playlist details by id.""" - itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) - return await self._parse_playlist(itempath) - - async def get_album_tracks(self, prov_album_id: str) -> List[Track]: - """Get album tracks for given album id.""" - # filesystem items are always stored in db so we can query the database - db_album = await self.mass.music.albums.get_db_item_by_prov_id( - prov_album_id, provider_id=self.id - ) - if db_album is None: - raise MediaNotFoundError(f"Album not found: {prov_album_id}") - # TODO: adjust to json query instead of text search - query = f"SELECT * FROM tracks WHERE albums LIKE '%\"{db_album.item_id}\"%'" - query += f" AND provider_ids LIKE '%\"{self.type.value}\"%'" - result = [] - for track in await self.mass.music.tracks.get_db_items(query): - track.album = db_album - album_mapping = next( - (x for x in track.albums if x.item_id == db_album.item_id), None - ) - track.disc_number = album_mapping.disc_number - track.track_number = album_mapping.track_number - result.append(track) - return result - - async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: - """Get playlist tracks for given playlist id.""" - result = [] - playlist_path = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) - if not await self.exists(playlist_path): - raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}") - getmtime = wrap(os.path.getmtime) - mtime = await getmtime(playlist_path) - checksum = f"{SCHEMA_VERSION}.{int(mtime)}" - cache_key = f"playlist_{self.id}_tracks_{prov_playlist_id}" - if cache := await self.mass.cache.get(cache_key, checksum): - return [Track.from_dict(x) for x in cache] - playlist_base_path = Path(playlist_path).parent - index = 0 - try: - async with self.open_file(playlist_path, "r") as _file: - for line in await _file.readlines(): - line = urllib.parse.unquote(line.strip()) - if line and not line.startswith("#"): - # TODO: add support for .pls playlist files - if track := await self._parse_playlist_line( - line, playlist_base_path - ): - track.position = index - result.append(track) - index += 1 - except Exception as err: # pylint: disable=broad-except - self.logger.warning( - "Error while parsing playlist %s", playlist_path, exc_info=err - ) - await self.mass.cache.set(cache_key, [x.to_dict() for x in result], checksum) - return result - - async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None: - """Try to parse a track from a playlist line.""" - if "://" in line: - # track is uri from external provider? - try: - return await self.mass.music.get_item_by_uri(line) - except MusicAssistantError as err: - self.logger.warning( - "Could not parse uri %s to track: %s", line, str(err) - ) - return None - # try to treat uri as filename - if await self.exists(line): - return await self._parse_track(line) - rel_path = os.path.join(playlist_path, line) - if await self.exists(rel_path): - return await self._parse_track(rel_path) - return None - - async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: - """Get a list of albums for the given artist.""" - # filesystem items are always stored in db so we can query the database - db_artist = await self.mass.music.artists.get_db_item_by_prov_id( - prov_artist_id, provider_id=self.id - ) - if db_artist is None: - raise MediaNotFoundError(f"Artist not found: {prov_artist_id}") - # TODO: adjust to json query instead of text search - query = f"SELECT * FROM albums WHERE artists LIKE '%\"{db_artist.item_id}\"%'" - query += f" AND provider_ids LIKE '%\"{self.type.value}\"%'" - return await self.mass.music.albums.get_db_items(query) - - async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: - """Get a list of all tracks as we have no clue about preference.""" - # filesystem items are always stored in db so we can query the database - db_artist = await self.mass.music.artists.get_db_item_by_prov_id( - prov_artist_id, provider_id=self.id - ) - if db_artist is None: - raise MediaNotFoundError(f"Artist not found: {prov_artist_id}") - # TODO: adjust to json query instead of text search - query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{db_artist.item_id}\"%'" - query += f" AND provider_ids LIKE '%\"{self.type.value}\"%'" - return await self.mass.music.tracks.get_db_items(query) - - async def library_add(self, *args, **kwargs) -> bool: - """Add item to provider's library. Return true on succes.""" - # already handled by database - - async def library_remove(self, *args, **kwargs) -> bool: - """Remove item from provider's library. Return true on succes.""" - # already handled by database - # TODO: do we want to process/offer deletions here ? - - async def add_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> None: - """Add track(s) to playlist.""" - itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) - if not await self.exists(itempath): - raise MediaNotFoundError(f"Playlist path does not exist: {itempath}") - async with self.open_file(itempath, "r") as _file: - cur_data = await _file.read() - async with self.open_file(itempath, "w") as _file: - await _file.write(cur_data) - for uri in prov_track_ids: - await _file.write(f"\n{uri}") - - async def remove_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> None: - """Remove track(s) from playlist.""" - itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) - if not await self.exists(itempath): - raise MediaNotFoundError(f"Playlist path does not exist: {itempath}") - cur_lines = [] - async with self.open_file(itempath, "r") as _file: - for line in await _file.readlines(): - line = urllib.parse.unquote(line.strip()) - if line not in prov_track_ids: - cur_lines.append(line) - async with self.open_file(itempath, "w") as _file: - for uri in cur_lines: - await _file.write(f"{uri}\n") - - async def get_stream_details(self, item_id: str) -> StreamDetails: - """Return the content details for the given track when it will be streamed.""" - itempath = await self.get_filepath(MediaType.TRACK, item_id) - if not await self.exists(itempath): - raise MediaNotFoundError(f"Track path does not exist: {itempath}") - - def parse_tag(): - return TinyTag.get(itempath) - - tags = await self.mass.loop.run_in_executor(None, parse_tag) - _, ext = Path(itempath).name.rsplit(".", 1) - content_type = CONTENT_TYPE_EXT.get(ext.lower()) - - stat = await self.mass.loop.run_in_executor(None, os.stat, itempath) - - return StreamDetails( - provider=self.type, - item_id=item_id, - content_type=content_type, - media_type=MediaType.TRACK, - duration=tags.duration, - size=stat.st_size, - sample_rate=tags.samplerate or 44100, - bit_depth=16, # TODO: parse bitdepth - data=itempath, - ) - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item.""" - async for chunk in get_file_stream( - self.mass, streamdetails.data, streamdetails, seek_position - ): - yield chunk - - async def _parse_track(self, track_path: str) -> Track | None: - """Try to parse a track from a filename by reading its tags.""" - - if not await self.exists(track_path): - raise MediaNotFoundError(f"Track path does not exist: {track_path}") - - if "." not in track_path or track_path.startswith("."): - # skip system files and files without extension - return None - - filename_base, ext = Path(track_path).name.rsplit(".", 1) - content_type = CONTENT_TYPE_EXT.get(ext.lower()) - if content_type is None: - # unsupported file extension - return None - - track_item_id = self._get_item_id(track_path) - - # parse ID3 tags with TinyTag - def parse_tags(): - return TinyTag.get(track_path, image=True, ignore_errors=True) - - tags = await self.mass.loop.run_in_executor(None, parse_tags) - - # prefer title from tags, fallback to filename - if not tags.title or not tags.artist: - self.logger.warning( - "%s is missing ID3 tags, using filename as fallback", track_path - ) - filename_parts = filename_base.split(" - ", 1) - if len(filename_parts) == 2: - tags.artist = tags.artist or filename_parts[0] - tags.title = tags.title or filename_parts[1] - else: - tags.artist = tags.artist or FALLBACK_ARTIST - tags.title = tags.title or filename_base - - name, version = parse_title_and_version(tags.title) - track = Track( - item_id=track_item_id, - provider=self.type, - name=name, - version=version, - # a track on disk is always in library - in_library=True, - ) - - # album - # work out if we have an artist/album/track.ext structure - if tags.album: - track_parts = track_path.rsplit(os.sep) - album_folder = None - artist_folder = None - parentdir = os.path.dirname(track_path) - for _ in range(len(track_parts)): - dirname = parentdir.rsplit(os.sep)[-1] - if compare_strings(dirname, tags.albumartist): - artist_folder = parentdir - if compare_strings(dirname, tags.album): - album_folder = parentdir - parentdir = os.path.dirname(parentdir) - - # album artist - if artist_folder: - album_artists = [ - await self._parse_artist( - name=tags.albumartist, - artist_path=artist_folder, - in_library=True, - ) - ] - elif tags.albumartist: - album_artists = [ - await self._parse_artist(name=item, in_library=True) - for item in split_items(tags.albumartist) - ] - - else: - # always fallback to various artists as album artist if user did not tag album artist - # ID3 tag properly because we must have an album artist - album_artists = [await self._parse_artist(name=FALLBACK_ARTIST)] - self.logger.warning( - "%s is missing ID3 tag [albumartist], using %s as fallback", - track_path, - FALLBACK_ARTIST, - ) - - track.album = await self._parse_album( - tags.album, - album_folder, - artists=album_artists, - in_library=True, - ) - else: - self.logger.warning("%s is missing ID3 tag [album]", track_path) - - # track artist(s) - if tags.artist == tags.albumartist and track.album: - track.artists = track.album.artists - else: - # Parse track artist(s) from artist string using common splitters used in ID3 tags - # NOTE: do not use a '/' or '&' to prevent artists like AC/DC become messed up - track_artists_str = tags.artist or FALLBACK_ARTIST - track.artists = [ - await self._parse_artist(item, in_library=False) - for item in split_items(track_artists_str) - ] - - # Check if track has embedded metadata - img = await self.mass.loop.run_in_executor(None, tags.get_image) - if not track.metadata.images and img: - # we do not actually embed the image in the metadata because that would consume too - # much space and bandwidth. Instead we set the filename as value so the image can - # be retrieved later in realtime. - track.metadata.images = [MediaItemImage(ImageType.THUMB, track_path, True)] - if track.album and not track.album.metadata.images: - track.album.metadata.images = track.metadata.images - - # parse other info - track.duration = tags.duration - track.metadata.genres = set(split_items(tags.genre)) - track.disc_number = try_parse_int(tags.disc) - track.track_number = try_parse_int(tags.track) - track.isrc = tags.extra.get("isrc", "") - if "copyright" in tags.extra: - track.metadata.copyright = tags.extra["copyright"] - if "lyrics" in tags.extra: - track.metadata.lyrics = tags.extra["lyrics"] - - quality_details = "" - if content_type == ContentType.FLAC: - # TODO: get bit depth - quality = MediaQuality.FLAC_LOSSLESS - if tags.samplerate > 192000: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 - elif tags.samplerate > 96000: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 - elif tags.samplerate > 48000: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 - quality_details = f"{tags.samplerate / 1000} Khz" - elif track_path.endswith(".ogg"): - quality = MediaQuality.LOSSY_OGG - quality_details = f"{tags.bitrate} kbps" - elif track_path.endswith(".m4a"): - quality = MediaQuality.LOSSY_AAC - quality_details = f"{tags.bitrate} kbps" - else: - quality = MediaQuality.LOSSY_MP3 - quality_details = f"{tags.bitrate} kbps" - track.add_provider_id( - MediaItemProviderId( - item_id=track_item_id, - prov_type=self.type, - prov_id=self.id, - quality=quality, - details=quality_details, - url=track_path, - ) - ) - return track - - async def _parse_artist( - self, - name: Optional[str] = None, - artist_path: Optional[str] = None, - in_library: bool = True, - ) -> Artist | None: - """Lookup metadata in Artist folder.""" - assert name or artist_path - if not artist_path: - # create fake path - artist_path = os.path.join(self.config.path, name) - - artist_item_id = self._get_item_id(artist_path) - if not name: - name = artist_path.split(os.sep)[-1] - - artist = Artist( - artist_item_id, - self.type, - name, - provider_ids={ - MediaItemProviderId(artist_item_id, self.type, self.id, url=artist_path) - }, - in_library=in_library, - ) - - if not await self.exists(artist_path): - # return basic object if there is no dedicated artist folder - return artist - - # always mark artist as in-library when it exists as folder on disk - artist.in_library = True - - 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 - async with self.open_file(nfo_file, "r") as _file: - data = await _file.read() - info = await self.mass.loop.run_in_executor(None, 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 musicbrainz_id := info.get("musicbrainzartistid"): - artist.musicbrainz_id = musicbrainz_id - if descripton := info.get("biography"): - artist.metadata.description = descripton - if genre := info.get("genre"): - artist.metadata.genres = set(split_items(genre)) - # find local images - images = [] - async for _path in scantree(artist_path): - _filename = _path.path - ext = _filename.split(".")[-1] - if ext not in ("jpg", "png"): - continue - _filepath = os.path.join(artist_path, _filename) - for img_type in ImageType: - if img_type.value in _filepath: - images.append(MediaItemImage(img_type, _filepath, True)) - elif _filename == "folder.jpg": - images.append(MediaItemImage(ImageType.THUMB, _filepath, True)) - if images: - artist.metadata.images = images - - return artist - - async def _parse_album( - self, - name: Optional[str], - album_path: Optional[str], - artists: List[Artist], - in_library: bool = True, - ) -> Album | None: - """Lookup metadata in Album folder.""" - assert (name or album_path) and artists - if not album_path: - # create fake path - album_path = os.path.join(self.config.path, artists[0].name, name) - - album_item_id = self._get_item_id(album_path) - if not name: - name = album_path.split(os.sep)[-1] - - album = Album( - album_item_id, - self.type, - name, - artists=artists, - provider_ids={ - MediaItemProviderId(album_item_id, self.type, self.id, url=album_path) - }, - in_library=in_library, - ) - - if not await self.exists(album_path): - # return basic object if there is no dedicated album folder - return album - - # always mark as in-library when it exists as folder on disk - album.in_library = True - - nfo_file = os.path.join(album_path, "album.nfo") - if await self.exists(nfo_file): - # found NFO file with metadata - # https://kodi.wiki/view/NFO_files/Artists - async with self.open_file(nfo_file) as _file: - data = await _file.read() - info = await self.mass.loop.run_in_executor(None, xmltodict.parse, data) - info = info["album"] - album.name = info.get("title", info.get("name", name)) - if sort_name := info.get("sortname"): - album.sort_name = sort_name - if musicbrainz_id := info.get("musicbrainzreleasegroupid"): - album.musicbrainz_id = musicbrainz_id - if mb_artist_id := info.get("musicbrainzalbumartistid"): - if album.artist and not album.artist.musicbrainz_id: - album.artist.musicbrainz_id = mb_artist_id - if description := info.get("review"): - album.metadata.description = description - if year := info.get("label"): - album.year = int(year) - if genre := info.get("genre"): - album.metadata.genres = set(split_items(genre)) - # parse name/version - album.name, album.version = parse_title_and_version(album.name) - - # try to guess the album type - album_tracks = [ - x async for x in scantree(album_path) if TinyTag.is_supported(x.path) - ] - if album.artist.sort_name == "variousartists": - album.album_type = AlbumType.COMPILATION - elif len(album_tracks) <= 5: - album.album_type = AlbumType.SINGLE - else: - album.album_type = AlbumType.ALBUM - - # find local images - images = [] - async for _path in scantree(album_path): - _filename = _path.path - ext = _filename.split(".")[-1] - if ext not in ("jpg", "png"): - continue - _filepath = os.path.join(album_path, _filename) - for img_type in ImageType: - if img_type.value in _filepath: - images.append(MediaItemImage(img_type, _filepath, True)) - elif _filename == "folder.jpg": - images.append(MediaItemImage(ImageType.THUMB, _filepath, True)) - if images: - album.metadata.images = images - - return album - - async def _parse_playlist(self, playlist_path: str) -> Playlist | None: - """Parse playlist from file.""" - playlist_item_id = self._get_item_id(playlist_path) - - if not playlist_path.endswith(".m3u"): - return None - - if not await self.exists(playlist_path): - raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}") - - name = playlist_path.split(os.sep)[-1].replace(".m3u", "") - - playlist = Playlist(playlist_item_id, provider=self.type, name=name) - playlist.is_editable = True - playlist.in_library = True - playlist.add_provider_id( - MediaItemProviderId( - item_id=playlist_item_id, - prov_type=self.type, - prov_id=self.id, - url=playlist_path, - ) - ) - playlist.owner = self._attr_name - return playlist - - async def exists(self, file_path: str) -> bool: - """Return bool is this FileSystem musicprovider has given file/dir.""" - if not file_path: - return False # guard - # ensure we have a full path and not relative - if self.config.path not in file_path: - file_path = os.path.join(self.config.path, file_path) - _exists = wrap(os.path.exists) - return await _exists(file_path) - - @asynccontextmanager - async def open_file(self, file_path: str, mode="rb") -> AsyncFileIO: - """Return (async) handle to given file.""" - # ensure we have a full path and not relative - if self.config.path not in file_path: - file_path = os.path.join(self.config.path, file_path) - # remote file locations should return a tempfile here ? - async with aiofiles.open(file_path, mode) as _file: - yield _file - - async def get_embedded_image(self, file_path) -> bytes | None: - """Return embedded image data.""" - if not TinyTag.is_supported(file_path): - return None - - # embedded image in music file - def _get_data(): - tags = TinyTag.get(file_path, image=True) - return tags.get_image() - - return await self.mass.loop.run_in_executor(None, _get_data) - - async def get_filepath( - self, media_type: MediaType, prov_item_id: str - ) -> str | None: - """Get full filepath on disk for item_id.""" - if prov_item_id is None: - return None # guard - # funky sql queries go here ;-) - table = f"{media_type.value}s" - query = ( - f"SELECT json_extract(json_each.value, '$.url') as url FROM {table}" - " ,json_each(provider_ids) WHERE" - f" json_extract(json_each.value, '$.prov_id') = '{self.id}'" - f" AND json_extract(json_each.value, '$.item_id') = '{prov_item_id}'" - ) - for db_row in await self.mass.database.get_rows_from_query(query): - file_path = db_row["url"] - # ensure we have a full path and not relative - if self.config.path not in file_path: - file_path = os.path.join(self.config.path, file_path) - return file_path - return None - - def _get_item_id(self, file_path: str) -> str: - """Create item id from filename.""" - return create_clean_string(file_path.replace(self.config.path, "")) diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-aarch64 b/music_assistant/controllers/music/providers/librespot/linux/librespot-aarch64 deleted file mode 100755 index 5359098f..00000000 Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-aarch64 and /dev/null differ diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-arm b/music_assistant/controllers/music/providers/librespot/linux/librespot-arm deleted file mode 100755 index 5cd38c75..00000000 Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-arm and /dev/null differ diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-armhf b/music_assistant/controllers/music/providers/librespot/linux/librespot-armhf deleted file mode 100755 index 18c2e05b..00000000 Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-armhf and /dev/null differ diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-armv7 b/music_assistant/controllers/music/providers/librespot/linux/librespot-armv7 deleted file mode 100755 index 0a792b2e..00000000 Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-armv7 and /dev/null differ diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-x86_64 b/music_assistant/controllers/music/providers/librespot/linux/librespot-x86_64 deleted file mode 100755 index e025abdb..00000000 Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-x86_64 and /dev/null differ diff --git a/music_assistant/controllers/music/providers/librespot/osx/librespot b/music_assistant/controllers/music/providers/librespot/osx/librespot deleted file mode 100755 index c1b37543..00000000 Binary files a/music_assistant/controllers/music/providers/librespot/osx/librespot and /dev/null differ diff --git a/music_assistant/controllers/music/providers/librespot/windows/librespot.exe b/music_assistant/controllers/music/providers/librespot/windows/librespot.exe deleted file mode 100755 index a973f4e1..00000000 Binary files a/music_assistant/controllers/music/providers/librespot/windows/librespot.exe and /dev/null differ diff --git a/music_assistant/controllers/music/providers/qobuz.py b/music_assistant/controllers/music/providers/qobuz.py deleted file mode 100644 index 65bbc48a..00000000 --- a/music_assistant/controllers/music/providers/qobuz.py +++ /dev/null @@ -1,733 +0,0 @@ -"""Qobuz musicprovider support for MusicAssistant.""" -from __future__ import annotations - -import datetime -import hashlib -import time -from json import JSONDecodeError -from typing import AsyncGenerator, List, Optional - -import aiohttp -from asyncio_throttle import Throttler - -from music_assistant.helpers.app_vars import ( # pylint: disable=no-name-in-module - app_var, -) -from music_assistant.helpers.audio import get_http_stream -from music_assistant.helpers.cache import use_cache -from music_assistant.helpers.util import parse_title_and_version, try_parse_int -from music_assistant.models.enums import ProviderType -from music_assistant.models.errors import LoginFailed, MediaNotFoundError -from music_assistant.models.media_items import ( - Album, - AlbumType, - Artist, - ContentType, - ImageType, - MediaItemImage, - MediaItemProviderId, - MediaItemType, - MediaQuality, - MediaType, - Playlist, - StreamDetails, - Track, -) -from music_assistant.models.provider import MusicProvider - - -class QobuzProvider(MusicProvider): - """Provider for the Qobux music service.""" - - _attr_type = ProviderType.QOBUZ - _attr_name = "Qobuz" - _attr_supported_mediatypes = [ - MediaType.ARTIST, - MediaType.ALBUM, - MediaType.TRACK, - MediaType.PLAYLIST, - ] - _user_auth_info = None - _throttler = Throttler(rate_limit=4, period=1) - - async def setup(self) -> bool: - """Handle async initialization of the provider.""" - if not self.config.enabled: - return False - if not self.config.username or not self.config.password: - raise LoginFailed("Invalid login credentials") - # try to get a token, raise if that fails - token = await self._auth_token() - if not token: - raise LoginFailed(f"Login failed for user {self.config.username}") - return True - - async def search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> List[MediaItemType]: - """ - Perform search on musicprovider. - - :param search_query: Search query. - :param media_types: A list of media_types to include. All types if None. - :param limit: Number of items to return in the search (per type). - """ - result = [] - params = {"query": search_query, "limit": limit} - if len(media_types) == 1: - # qobuz does not support multiple searchtypes, falls back to all if no type given - if media_types[0] == MediaType.ARTIST: - params["type"] = "artists" - if media_types[0] == MediaType.ALBUM: - params["type"] = "albums" - if media_types[0] == MediaType.TRACK: - params["type"] = "tracks" - if media_types[0] == MediaType.PLAYLIST: - params["type"] = "playlists" - if searchresult := await self._get_data("catalog/search", **params): - if "artists" in searchresult: - result += [ - await self._parse_artist(item) - for item in searchresult["artists"]["items"] - if (item and item["id"]) - ] - if "albums" in searchresult: - result += [ - await self._parse_album(item) - for item in searchresult["albums"]["items"] - if (item and item["id"]) - ] - if "tracks" in searchresult: - result += [ - await self._parse_track(item) - for item in searchresult["tracks"]["items"] - if (item and item["id"]) - ] - if "playlists" in searchresult: - result += [ - await self._parse_playlist(item) - for item in searchresult["playlists"]["items"] - if (item and item["id"]) - ] - return result - - async def get_library_artists(self) -> AsyncGenerator[Artist, None]: - """Retrieve all library artists from Qobuz.""" - endpoint = "favorite/getUserFavorites" - for item in await self._get_all_items(endpoint, key="artists", type="artists"): - if item and item["id"]: - yield await self._parse_artist(item) - - async def get_library_albums(self) -> AsyncGenerator[Album, None]: - """Retrieve all library albums from Qobuz.""" - endpoint = "favorite/getUserFavorites" - for item in await self._get_all_items(endpoint, key="albums", type="albums"): - if item and item["id"]: - yield await self._parse_album(item) - - async def get_library_tracks(self) -> AsyncGenerator[Track, None]: - """Retrieve library tracks from Qobuz.""" - endpoint = "favorite/getUserFavorites" - for item in await self._get_all_items(endpoint, key="tracks", type="tracks"): - if item and item["id"]: - yield await self._parse_track(item) - - async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: - """Retrieve all library playlists from the provider.""" - endpoint = "playlist/getUserPlaylists" - for item in await self._get_all_items(endpoint, key="playlists"): - if item and item["id"]: - yield await self._parse_playlist(item) - - async def get_artist(self, prov_artist_id) -> Artist: - """Get full artist details by id.""" - params = {"artist_id": prov_artist_id} - artist_obj = await self._get_data("artist/get", **params) - return ( - await self._parse_artist(artist_obj) - if artist_obj and artist_obj["id"] - else None - ) - - async def get_album(self, prov_album_id) -> Album: - """Get full album details by id.""" - params = {"album_id": prov_album_id} - album_obj = await self._get_data("album/get", **params) - return ( - await self._parse_album(album_obj) - if album_obj and album_obj["id"] - else None - ) - - async def get_track(self, prov_track_id) -> Track: - """Get full track details by id.""" - params = {"track_id": prov_track_id} - track_obj = await self._get_data("track/get", **params) - return ( - await self._parse_track(track_obj) - if track_obj and track_obj["id"] - else None - ) - - async def get_playlist(self, prov_playlist_id) -> Playlist: - """Get full playlist details by id.""" - params = {"playlist_id": prov_playlist_id} - playlist_obj = await self._get_data("playlist/get", **params) - return ( - await self._parse_playlist(playlist_obj) - if playlist_obj and playlist_obj["id"] - else None - ) - - async def get_album_tracks(self, prov_album_id) -> List[Track]: - """Get all album tracks for given album id.""" - params = {"album_id": prov_album_id} - return [ - await self._parse_track(item) - for item in await self._get_all_items("album/get", **params, key="tracks") - if (item and item["id"]) - ] - - async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: - """Get all playlist tracks for given playlist id.""" - playlist = await self.get_playlist(prov_playlist_id) - endpoint = "playlist/get" - return [ - await self._parse_track(item) - for item in await self._get_all_items( - endpoint, - key="tracks", - playlist_id=prov_playlist_id, - extra="tracks", - cache_checksum=playlist.metadata.checksum, - ) - if (item and item["id"]) - ] - - async def get_artist_albums(self, prov_artist_id) -> List[Album]: - """Get a list of albums for the given artist.""" - endpoint = "artist/get" - return [ - await self._parse_album(item) - for item in await self._get_all_items( - endpoint, key="albums", artist_id=prov_artist_id, extra="albums" - ) - if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id) - ] - - async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - """Get a list of most popular tracks for the given artist.""" - result = await self._get_data( - "artist/get", - artist_id=prov_artist_id, - extra="playlists", - offset=0, - limit=25, - ) - if result and result["playlists"]: - return [ - await self._parse_track(item) - for item in result["playlists"][0]["tracks"]["items"] - if (item and item["id"]) - ] - # fallback to search - artist = await self.get_artist(prov_artist_id) - searchresult = await self._get_data( - "catalog/search", query=artist.name, limit=25, type="tracks" - ) - return [ - await self._parse_track(item) - for item in searchresult["tracks"]["items"] - if ( - item - and item["id"] - and "performer" in item - and str(item["performer"]["id"]) == str(prov_artist_id) - ) - ] - - async def get_similar_artists(self, prov_artist_id): - """Get similar artists for given artist.""" - # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3 - - async def library_add(self, prov_item_id, media_type: MediaType): - """Add item to library.""" - result = None - if media_type == MediaType.ARTIST: - result = await self._get_data("favorite/create", artist_id=prov_item_id) - elif media_type == MediaType.ALBUM: - result = await self._get_data("favorite/create", album_ids=prov_item_id) - elif media_type == MediaType.TRACK: - result = await self._get_data("favorite/create", track_ids=prov_item_id) - elif media_type == MediaType.PLAYLIST: - result = await self._get_data( - "playlist/subscribe", playlist_id=prov_item_id - ) - return result - - async def library_remove(self, prov_item_id, media_type: MediaType): - """Remove item from library.""" - result = None - if media_type == MediaType.ARTIST: - result = await self._get_data("favorite/delete", artist_ids=prov_item_id) - elif media_type == MediaType.ALBUM: - result = await self._get_data("favorite/delete", album_ids=prov_item_id) - elif media_type == MediaType.TRACK: - result = await self._get_data("favorite/delete", track_ids=prov_item_id) - elif media_type == MediaType.PLAYLIST: - playlist = await self.get_playlist(prov_item_id) - if playlist.is_editable: - result = await self._get_data( - "playlist/delete", playlist_id=prov_item_id - ) - else: - result = await self._get_data( - "playlist/unsubscribe", playlist_id=prov_item_id - ) - return result - - async def add_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> None: - """Add track(s) to playlist.""" - return await self._get_data( - "playlist/addTracks", - playlist_id=prov_playlist_id, - track_ids=",".join(prov_track_ids), - playlist_track_ids=",".join(prov_track_ids), - ) - - async def remove_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> None: - """Remove track(s) from playlist.""" - playlist_track_ids = set() - for track in await self._get_all_items( - "playlist/get", key="tracks", playlist_id=prov_playlist_id, extra="tracks" - ): - if str(track["id"]) in prov_track_ids: - playlist_track_ids.add(str(track["playlist_track_id"])) - return await self._get_data( - "playlist/deleteTracks", - playlist_id=prov_playlist_id, - playlist_track_ids=",".join(playlist_track_ids), - ) - - async def get_stream_details(self, item_id: str) -> StreamDetails: - """Return the content details for the given track when it will be streamed.""" - streamdata = None - for format_id in [27, 7, 6, 5]: - # it seems that simply requesting for highest available quality does not work - # from time to time the api response is empty for this request ?! - result = await self._get_data( - "track/getFileUrl", - sign_request=True, - format_id=format_id, - track_id=item_id, - intent="stream", - skip_cache=True, - ) - if result and result.get("url"): - streamdata = result - break - if not streamdata: - raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}") - if streamdata["mime_type"] == "audio/mpeg": - content_type = ContentType.MPEG - elif streamdata["mime_type"] == "audio/flac": - content_type = ContentType.FLAC - else: - raise MediaNotFoundError(f"Unsupported mime type for {item_id}") - return StreamDetails( - item_id=str(item_id), - provider=self.type, - content_type=content_type, - duration=streamdata["duration"], - sample_rate=int(streamdata["sampling_rate"] * 1000), - bit_depth=streamdata["bit_depth"], - data=streamdata, # we need these details for reporting playback - expires=time.time() + 1800, # not sure about the real allowed value - ) - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item.""" - self.mass.create_task(self._report_playback_started(streamdetails)) - bytes_sent = 0 - try: - url = streamdetails.data["url"] - async for chunk in get_http_stream( - self.mass, url, streamdetails, seek_position - ): - yield chunk - bytes_sent += len(chunk) - finally: - if bytes_sent: - self.mass.create_task( - self._report_playback_stopped(streamdetails, bytes_sent) - ) - - async def _report_playback_started(self, streamdetails: StreamDetails) -> None: - """Report playback start to qobuz.""" - # TODO: need to figure out if the streamed track is purchased by user - # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx - # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}} - device_id = self._user_auth_info["user"]["device"]["id"] - credential_id = self._user_auth_info["user"]["credential"]["id"] - user_id = self._user_auth_info["user"]["id"] - format_id = streamdetails.data["format_id"] - timestamp = int(time.time()) - events = [ - { - "online": True, - "sample": False, - "intent": "stream", - "device_id": device_id, - "track_id": str(streamdetails.item_id), - "purchase": False, - "date": timestamp, - "credential_id": credential_id, - "user_id": user_id, - "local": False, - "format_id": format_id, - } - ] - await self._post_data("track/reportStreamingStart", data=events) - - async def _report_playback_stopped( - self, streamdetails: StreamDetails, bytes_sent: int - ) -> None: - """Report playback stop to qobuz.""" - user_id = self._user_auth_info["user"]["id"] - await self._get_data( - "/track/reportStreamingEnd", - user_id=user_id, - track_id=str(streamdetails.item_id), - duration=try_parse_int(streamdetails.seconds_streamed), - ) - - async def _parse_artist(self, artist_obj: dict): - """Parse qobuz artist object to generic layout.""" - artist = Artist( - item_id=str(artist_obj["id"]), provider=self.type, name=artist_obj["name"] - ) - artist.add_provider_id( - MediaItemProviderId( - item_id=str(artist_obj["id"]), - prov_type=self.type, - prov_id=self.id, - url=artist_obj.get( - "url", f'https://open.qobuz.com/artist/{artist_obj["id"]}' - ), - ) - ) - if img := self.__get_image(artist_obj): - artist.metadata.images = [MediaItemImage(ImageType.THUMB, img)] - if artist_obj.get("biography"): - artist.metadata.description = artist_obj["biography"].get("content") - return artist - - async def _parse_album(self, album_obj: dict, artist_obj: dict = None): - """Parse qobuz album object to generic layout.""" - if not artist_obj and "artist" not in album_obj: - # artist missing in album info, return full abum instead - return await self.get_album(album_obj["id"]) - name, version = parse_title_and_version( - album_obj["title"], album_obj.get("version") - ) - album = Album( - item_id=str(album_obj["id"]), provider=self.type, name=name, version=version - ) - if album_obj["maximum_sampling_rate"] > 192: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 - elif album_obj["maximum_sampling_rate"] > 96: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 - elif album_obj["maximum_sampling_rate"] > 48: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 - elif album_obj["maximum_bit_depth"] > 16: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 - elif album_obj.get("format_id", 0) == 5: - quality = MediaQuality.LOSSY_AAC - else: - quality = MediaQuality.FLAC_LOSSLESS - album.add_provider_id( - MediaItemProviderId( - item_id=str(album_obj["id"]), - prov_type=self.type, - prov_id=self.id, - quality=quality, - url=album_obj.get( - "url", f'https://open.qobuz.com/album/{album_obj["id"]}' - ), - details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit', - available=album_obj["streamable"] and album_obj["displayable"], - ) - ) - - album.artist = await self._parse_artist(artist_obj or album_obj["artist"]) - if ( - album_obj.get("product_type", "") == "single" - or album_obj.get("release_type", "") == "single" - ): - album.album_type = AlbumType.SINGLE - elif ( - album_obj.get("product_type", "") == "compilation" - or "Various" in album.artist.name - ): - album.album_type = AlbumType.COMPILATION - elif ( - album_obj.get("product_type", "") == "album" - or album_obj.get("release_type", "") == "album" - ): - album.album_type = AlbumType.ALBUM - if "genre" in album_obj: - album.metadata.genres = {album_obj["genre"]["name"]} - if img := self.__get_image(album_obj): - album.metadata.images = [MediaItemImage(ImageType.THUMB, img)] - if len(album_obj["upc"]) == 13: - # qobuz writes ean as upc ?! - album.upc = album_obj["upc"][1:] - else: - album.upc = album_obj["upc"] - if "label" in album_obj: - album.metadata.label = album_obj["label"]["name"] - if album_obj.get("released_at"): - album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year - if album_obj.get("copyright"): - album.metadata.copyright = album_obj["copyright"] - if album_obj.get("description"): - album.metadata.description = album_obj["description"] - return album - - async def _parse_track(self, track_obj: dict): - """Parse qobuz track object to generic layout.""" - name, version = parse_title_and_version( - track_obj["title"], track_obj.get("version") - ) - track = Track( - item_id=str(track_obj["id"]), - provider=self.type, - name=name, - version=version, - disc_number=track_obj["media_number"], - track_number=track_obj["track_number"], - duration=track_obj["duration"], - position=track_obj.get("position"), - ) - if track_obj.get("performer") and "Various " not in track_obj["performer"]: - artist = await self._parse_artist(track_obj["performer"]) - if artist: - track.artists.append(artist) - if not track.artists: - # try to grab artist from album - if ( - track_obj.get("album") - and track_obj["album"].get("artist") - and "Various " not in track_obj["album"]["artist"] - ): - artist = await self._parse_artist(track_obj["album"]["artist"]) - if artist: - track.artists.append(artist) - if not track.artists: - # last resort: parse from performers string - for performer_str in track_obj["performers"].split(" - "): - role = performer_str.split(", ")[1] - name = performer_str.split(", ")[0] - if "artist" in role.lower(): - artist = Artist(name, self.type, name) - track.artists.append(artist) - # TODO: fix grabbing composer from details - - if "album" in track_obj: - album = await self._parse_album(track_obj["album"]) - if album: - track.album = album - if track_obj.get("isrc"): - track.isrc = track_obj["isrc"] - if track_obj.get("performers"): - track.metadata.performers = { - x.strip() for x in track_obj["performers"].split("-") - } - if track_obj.get("copyright"): - track.metadata.copyright = track_obj["copyright"] - if track_obj.get("audio_info"): - track.metadata.replaygain = track_obj["audio_info"]["replaygain_track_gain"] - if track_obj.get("parental_warning"): - track.metadata.explicit = True - if img := self.__get_image(track_obj): - track.metadata.images = [MediaItemImage(ImageType.THUMB, img)] - # get track quality - if track_obj["maximum_sampling_rate"] > 192: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 - elif track_obj["maximum_sampling_rate"] > 96: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 - elif track_obj["maximum_sampling_rate"] > 48: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 - elif track_obj["maximum_bit_depth"] > 16: - quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 - elif track_obj.get("format_id", 0) == 5: - quality = MediaQuality.LOSSY_AAC - else: - quality = MediaQuality.FLAC_LOSSLESS - track.add_provider_id( - MediaItemProviderId( - item_id=str(track_obj["id"]), - prov_type=self.type, - prov_id=self.id, - quality=quality, - url=track_obj.get( - "url", f'https://open.qobuz.com/track/{track_obj["id"]}' - ), - details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit', - available=track_obj["streamable"] and track_obj["displayable"], - ) - ) - return track - - async def _parse_playlist(self, playlist_obj): - """Parse qobuz playlist object to generic layout.""" - playlist = Playlist( - item_id=str(playlist_obj["id"]), - provider=self.type, - name=playlist_obj["name"], - owner=playlist_obj["owner"]["name"], - ) - playlist.add_provider_id( - MediaItemProviderId( - item_id=str(playlist_obj["id"]), - prov_type=self.type, - prov_id=self.id, - url=playlist_obj.get( - "url", f'https://open.qobuz.com/playlist/{playlist_obj["id"]}' - ), - ) - ) - playlist.is_editable = ( - playlist_obj["owner"]["id"] == self._user_auth_info["user"]["id"] - or playlist_obj["is_collaborative"] - ) - if img := self.__get_image(playlist_obj): - playlist.metadata.images = [MediaItemImage(ImageType.THUMB, img)] - playlist.metadata.checksum = str(playlist_obj["updated_at"]) - return playlist - - async def _auth_token(self): - """Login to qobuz and store the token.""" - if self._user_auth_info: - return self._user_auth_info["user_auth_token"] - params = { - "username": self.config.username, - "password": self.config.password, - "device_manufacturer_id": "music_assistant", - } - details = await self._get_data("user/login", **params) - if details and "user" in details: - self._user_auth_info = details - self.logger.info( - "Succesfully logged in to Qobuz as %s", details["user"]["display_name"] - ) - self.mass.metadata.preferred_language = details["user"]["country_code"] - return details["user_auth_token"] - - @use_cache(3600 * 24) - async def _get_all_items(self, endpoint, key="tracks", **kwargs): - """Get all items from a paged list.""" - limit = 50 - offset = 0 - all_items = [] - while True: - kwargs["limit"] = limit - kwargs["offset"] = offset - result = await self._get_data(endpoint, skip_cache=True, **kwargs) - offset += limit - if not result: - break - if not result.get(key) or not result[key].get("items"): - break - for item in result[key]["items"]: - item["position"] = len(all_items) + 1 - all_items.append(item) - if len(result[key]["items"]) < limit: - break - return all_items - - @use_cache(3600 * 2) - async def _get_data(self, endpoint, sign_request=False, **kwargs): - """Get data from api.""" - url = f"http://www.qobuz.com/api.json/0.2/{endpoint}" - headers = {"X-App-Id": app_var(0)} - if endpoint != "user/login": - auth_token = await self._auth_token() - if not auth_token: - self.logger.debug("Not logged in") - return None - headers["X-User-Auth-Token"] = auth_token - if sign_request: - signing_data = "".join(endpoint.split("/")) - keys = list(kwargs.keys()) - keys.sort() - for key in keys: - signing_data += f"{key}{kwargs[key]}" - request_ts = str(time.time()) - request_sig = signing_data + request_ts + app_var(1) - request_sig = str(hashlib.md5(request_sig.encode()).hexdigest()) - kwargs["request_ts"] = request_ts - kwargs["request_sig"] = request_sig - kwargs["app_id"] = app_var(0) - kwargs["user_auth_token"] = await self._auth_token() - async with self._throttler: - async with self.mass.http_session.get( - url, headers=headers, params=kwargs, verify_ssl=False - ) as response: - try: - result = await response.json() - if "error" in result or ( - "status" in result and "error" in result["status"] - ): - self.logger.error("%s - %s", endpoint, result) - return None - except ( - aiohttp.ContentTypeError, - JSONDecodeError, - ) as err: - self.logger.error("%s - %s", endpoint, str(err)) - return None - return result - - async def _post_data(self, endpoint, params=None, data=None): - """Post data to api.""" - if not params: - params = {} - if not data: - data = {} - url = f"http://www.qobuz.com/api.json/0.2/{endpoint}" - params["app_id"] = app_var(0) - params["user_auth_token"] = await self._auth_token() - async with self.mass.http_session.post( - url, params=params, json=data, verify_ssl=False - ) as response: - result = await response.json() - if "error" in result or ( - "status" in result and "error" in result["status"] - ): - self.logger.error("%s - %s", endpoint, result) - return None - return result - - def __get_image(self, obj: dict) -> Optional[str]: - """Try to parse image from Qobuz media object.""" - if obj.get("image"): - for key in ["extralarge", "large", "medium", "small"]: - if obj["image"].get(key): - if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]: - continue - return obj["image"][key] - if obj.get("images300"): - # playlists seem to use this strange format - return obj["images300"][0] - if obj.get("album"): - return self.__get_image(obj["album"]) - if obj.get("artist"): - return self.__get_image(obj["artist"]) - return None diff --git a/music_assistant/controllers/music/providers/spotify.py b/music_assistant/controllers/music/providers/spotify.py deleted file mode 100644 index 83d1f85f..00000000 --- a/music_assistant/controllers/music/providers/spotify.py +++ /dev/null @@ -1,694 +0,0 @@ -"""Spotify musicprovider support for MusicAssistant.""" -from __future__ import annotations - -import asyncio -import json -import os -import platform -import time -from json.decoder import JSONDecodeError -from tempfile import gettempdir -from typing import AsyncGenerator, List, Optional - -import aiohttp -from asyncio_throttle import Throttler - -from music_assistant.helpers.app_vars import ( # noqa # pylint: disable=no-name-in-module - app_var, -) -from music_assistant.helpers.cache import use_cache -from music_assistant.helpers.process import AsyncProcess -from music_assistant.helpers.util import parse_title_and_version -from music_assistant.models.enums import ProviderType -from music_assistant.models.errors import LoginFailed, MediaNotFoundError -from music_assistant.models.media_items import ( - Album, - AlbumType, - Artist, - ContentType, - ImageType, - MediaItemImage, - MediaItemProviderId, - MediaItemType, - MediaQuality, - MediaType, - Playlist, - StreamDetails, - Track, -) -from music_assistant.models.provider import MusicProvider - -CACHE_DIR = gettempdir() - - -class SpotifyProvider(MusicProvider): - """Implementation of a Spotify MusicProvider.""" - - _attr_type = ProviderType.SPOTIFY - _attr_name = "Spotify" - _attr_supported_mediatypes = [ - MediaType.ARTIST, - MediaType.ALBUM, - MediaType.TRACK, - MediaType.PLAYLIST - # TODO: Return spotify radio - ] - _auth_token = None - _sp_user = None - _librespot_bin = None - _throttler = Throttler(rate_limit=4, period=1) - _cache_dir = CACHE_DIR - - async def setup(self) -> bool: - """Handle async initialization of the provider.""" - if not self.config.enabled: - return False - if not self.config.username or not self.config.password: - raise LoginFailed("Invalid login credentials") - # try to get a token, raise if that fails - self._cache_dir = os.path.join(CACHE_DIR, self.id) - token = await self.get_token() - if not token: - raise LoginFailed(f"Login failed for user {self.config.username}") - return True - - async def search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> List[MediaItemType]: - """ - Perform search on musicprovider. - - :param search_query: Search query. - :param media_types: A list of media_types to include. All types if None. - :param limit: Number of items to return in the search (per type). - """ - result = [] - searchtypes = [] - if MediaType.ARTIST in media_types: - searchtypes.append("artist") - if MediaType.ALBUM in media_types: - searchtypes.append("album") - if MediaType.TRACK in media_types: - searchtypes.append("track") - if MediaType.PLAYLIST in media_types: - searchtypes.append("playlist") - searchtype = ",".join(searchtypes) - if searchresult := await self._get_data( - "search", q=search_query, type=searchtype, limit=limit - ): - if "artists" in searchresult: - result += [ - await self._parse_artist(item) - for item in searchresult["artists"]["items"] - if (item and item["id"]) - ] - if "albums" in searchresult: - result += [ - await self._parse_album(item) - for item in searchresult["albums"]["items"] - if (item and item["id"]) - ] - if "tracks" in searchresult: - result += [ - await self._parse_track(item) - for item in searchresult["tracks"]["items"] - if (item and item["id"]) - ] - if "playlists" in searchresult: - result += [ - await self._parse_playlist(item) - for item in searchresult["playlists"]["items"] - if (item and item["id"]) - ] - return result - - async def get_library_artists(self) -> AsyncGenerator[Artist, None]: - """Retrieve library artists from spotify.""" - endpoint = "me/following" - while True: - spotify_artists = await self._get_data( - endpoint, type="artist", limit=50, skip_cache=True - ) - for item in spotify_artists["artists"]["items"]: - if item and item["id"]: - yield await self._parse_artist(item) - if spotify_artists["artists"]["next"]: - endpoint = spotify_artists["artists"]["next"] - endpoint = endpoint.replace("https://api.spotify.com/v1/", "") - else: - break - - async def get_library_albums(self) -> AsyncGenerator[Album, None]: - """Retrieve library albums from the provider.""" - for item in await self._get_all_items("me/albums", skip_cache=True): - if item["album"] and item["album"]["id"]: - yield await self._parse_album(item["album"]) - - async def get_library_tracks(self) -> AsyncGenerator[Track, None]: - """Retrieve library tracks from the provider.""" - for item in await self._get_all_items("me/tracks", skip_cache=True): - if item and item["track"]["id"]: - yield await self._parse_track(item["track"]) - - async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: - """Retrieve playlists from the provider.""" - for item in await self._get_all_items("me/playlists", skip_cache=True): - if item and item["id"]: - yield await self._parse_playlist(item) - - async def get_artist(self, prov_artist_id) -> Artist: - """Get full artist details by id.""" - artist_obj = await self._get_data(f"artists/{prov_artist_id}") - return await self._parse_artist(artist_obj) if artist_obj else None - - async def get_album(self, prov_album_id) -> Album: - """Get full album details by id.""" - album_obj = await self._get_data(f"albums/{prov_album_id}") - return await self._parse_album(album_obj) if album_obj else None - - async def get_track(self, prov_track_id) -> Track: - """Get full track details by id.""" - track_obj = await self._get_data(f"tracks/{prov_track_id}") - return await self._parse_track(track_obj) if track_obj else None - - async def get_playlist(self, prov_playlist_id) -> Playlist: - """Get full playlist details by id.""" - playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}") - return await self._parse_playlist(playlist_obj) if playlist_obj else None - - async def get_album_tracks(self, prov_album_id) -> List[Track]: - """Get all album tracks for given album id.""" - return [ - await self._parse_track(item) - for item in await self._get_all_items(f"albums/{prov_album_id}/tracks") - if (item and item["id"]) - ] - - async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: - """Get all playlist tracks for given playlist id.""" - playlist = await self.get_playlist(prov_playlist_id) - return [ - await self._parse_track(item["track"]) - for item in await self._get_all_items( - f"playlists/{prov_playlist_id}/tracks", - cache_checksum=playlist.metadata.checksum, - ) - if (item and item["track"] and item["track"]["id"]) - ] - - async def get_artist_albums(self, prov_artist_id) -> List[Album]: - """Get a list of all albums for the given artist.""" - return [ - await self._parse_album(item) - for item in await self._get_all_items( - f"artists/{prov_artist_id}/albums?include_groups=album,single,compilation" - ) - if (item and item["id"]) - ] - - async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - """Get a list of 10 most popular tracks for the given artist.""" - artist = await self.get_artist(prov_artist_id) - endpoint = f"artists/{prov_artist_id}/top-tracks" - items = await self._get_data(endpoint) - return [ - await self._parse_track(item, artist=artist) - for item in items["tracks"] - if (item and item["id"]) - ] - - async def library_add(self, prov_item_id, media_type: MediaType): - """Add item to library.""" - result = False - if media_type == MediaType.ARTIST: - result = await self._put_data( - "me/following", {"ids": prov_item_id, "type": "artist"} - ) - elif media_type == MediaType.ALBUM: - result = await self._put_data("me/albums", {"ids": prov_item_id}) - elif media_type == MediaType.TRACK: - result = await self._put_data("me/tracks", {"ids": prov_item_id}) - elif media_type == MediaType.PLAYLIST: - result = await self._put_data( - f"playlists/{prov_item_id}/followers", data={"public": False} - ) - return result - - async def library_remove(self, prov_item_id, media_type: MediaType): - """Remove item from library.""" - result = False - if media_type == MediaType.ARTIST: - result = await self._delete_data( - "me/following", {"ids": prov_item_id, "type": "artist"} - ) - elif media_type == MediaType.ALBUM: - result = await self._delete_data("me/albums", {"ids": prov_item_id}) - elif media_type == MediaType.TRACK: - result = await self._delete_data("me/tracks", {"ids": prov_item_id}) - elif media_type == MediaType.PLAYLIST: - result = await self._delete_data(f"playlists/{prov_item_id}/followers") - return result - - async def add_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ): - """Add track(s) to playlist.""" - track_uris = [] - for track_id in prov_track_ids: - track_uris.append(f"spotify:track:{track_id}") - data = {"uris": track_uris} - return await self._post_data(f"playlists/{prov_playlist_id}/tracks", data=data) - - async def remove_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> None: - """Remove track(s) from playlist.""" - track_uris = [] - for track_id in prov_track_ids: - track_uris.append({"uri": f"spotify:track:{track_id}"}) - data = {"tracks": track_uris} - return await self._delete_data( - f"playlists/{prov_playlist_id}/tracks", data=data - ) - - async def get_stream_details(self, item_id: str) -> StreamDetails: - """Return the content details for the given track when it will be streamed.""" - # make sure a valid track is requested. - track = await self.get_track(item_id) - if not track: - raise MediaNotFoundError(f"track {item_id} not found") - # make sure that the token is still valid by just requesting it - await self.get_token() - return StreamDetails( - item_id=track.item_id, - provider=self.type, - content_type=ContentType.OGG, - duration=track.duration, - ) - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item.""" - # make sure that the token is still valid by just requesting it - await self.get_token() - librespot = await self.get_librespot_binary() - args = [ - librespot, - "-c", - self._cache_dir, - "--pass-through", - "-b", - "320", - "--single-track", - f"spotify://track:{streamdetails.item_id}", - ] - if seek_position: - args += ["--start-position", str(int(seek_position))] - async with AsyncProcess(args) as librespot_proc: - async for chunk in librespot_proc.iterate_chunks(): - yield chunk - - async def _parse_artist(self, artist_obj): - """Parse spotify artist object to generic layout.""" - artist = Artist( - item_id=artist_obj["id"], provider=self.type, name=artist_obj["name"] - ) - artist.add_provider_id( - MediaItemProviderId( - item_id=artist_obj["id"], - prov_type=self.type, - prov_id=self.id, - url=artist_obj["external_urls"]["spotify"], - ) - ) - if "genres" in artist_obj: - artist.metadata.genres = set(artist_obj["genres"]) - if artist_obj.get("images"): - for img in artist_obj["images"]: - img_url = img["url"] - if "2a96cbd8b46e442fc41c2b86b821562f" not in img_url: - artist.metadata.images = [MediaItemImage(ImageType.THUMB, img_url)] - break - return artist - - async def _parse_album(self, album_obj: dict): - """Parse spotify album object to generic layout.""" - name, version = parse_title_and_version(album_obj["name"]) - album = Album( - item_id=album_obj["id"], provider=self.type, name=name, version=version - ) - for artist_obj in album_obj["artists"]: - album.artists.append(await self._parse_artist(artist_obj)) - if album_obj["album_type"] == "single": - album.album_type = AlbumType.SINGLE - elif album_obj["album_type"] == "compilation": - album.album_type = AlbumType.COMPILATION - elif album_obj["album_type"] == "album": - album.album_type = AlbumType.ALBUM - if "genres" in album_obj: - album.metadata.genre = set(album_obj["genres"]) - if album_obj.get("images"): - album.metadata.images = [ - MediaItemImage(ImageType.THUMB, album_obj["images"][0]["url"]) - ] - if "external_ids" in album_obj and album_obj["external_ids"].get("upc"): - album.upc = album_obj["external_ids"]["upc"] - if "label" in album_obj: - album.metadata.label = album_obj["label"] - if album_obj.get("release_date"): - album.year = int(album_obj["release_date"].split("-")[0]) - if album_obj.get("copyrights"): - album.metadata.copyright = album_obj["copyrights"][0]["text"] - if album_obj.get("explicit"): - album.metadata.explicit = album_obj["explicit"] - album.add_provider_id( - MediaItemProviderId( - item_id=album_obj["id"], - prov_type=self.type, - prov_id=self.id, - quality=MediaQuality.LOSSY_OGG, - url=album_obj["external_urls"]["spotify"], - ) - ) - return album - - async def _parse_track(self, track_obj, artist=None): - """Parse spotify track object to generic layout.""" - name, version = parse_title_and_version(track_obj["name"]) - track = Track( - item_id=track_obj["id"], - provider=self.type, - name=name, - version=version, - duration=track_obj["duration_ms"] / 1000, - disc_number=track_obj["disc_number"], - track_number=track_obj["track_number"], - position=track_obj.get("position"), - ) - if artist: - track.artists.append(artist) - for track_artist in track_obj.get("artists", []): - artist = await self._parse_artist(track_artist) - if artist and artist.item_id not in {x.item_id for x in track.artists}: - track.artists.append(artist) - - track.metadata.explicit = track_obj["explicit"] - if "preview_url" in track_obj: - track.metadata.preview = track_obj["preview_url"] - if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]: - track.isrc = track_obj["external_ids"]["isrc"] - if "album" in track_obj: - track.album = await self._parse_album(track_obj["album"]) - if track_obj["album"].get("images"): - track.metadata.images = [ - MediaItemImage( - ImageType.THUMB, track_obj["album"]["images"][0]["url"] - ) - ] - if track_obj.get("copyright"): - track.metadata.copyright = track_obj["copyright"] - if track_obj.get("explicit"): - track.metadata.explicit = True - if track_obj.get("popularity"): - track.metadata.popularity = track_obj["popularity"] - track.add_provider_id( - MediaItemProviderId( - item_id=track_obj["id"], - prov_type=self.type, - prov_id=self.id, - quality=MediaQuality.LOSSY_OGG, - url=track_obj["external_urls"]["spotify"], - available=not track_obj["is_local"] and track_obj["is_playable"], - ) - ) - return track - - async def _parse_playlist(self, playlist_obj): - """Parse spotify playlist object to generic layout.""" - playlist = Playlist( - item_id=playlist_obj["id"], - provider=self.type, - name=playlist_obj["name"], - owner=playlist_obj["owner"]["display_name"], - ) - playlist.add_provider_id( - MediaItemProviderId( - item_id=playlist_obj["id"], - prov_type=self.type, - prov_id=self.id, - url=playlist_obj["external_urls"]["spotify"], - ) - ) - playlist.is_editable = ( - playlist_obj["owner"]["id"] == self._sp_user["id"] - or playlist_obj["collaborative"] - ) - if playlist_obj.get("images"): - playlist.metadata.images = [ - MediaItemImage(ImageType.THUMB, playlist_obj["images"][0]["url"]) - ] - playlist.metadata.checksum = str(playlist_obj["snapshot_id"]) - return playlist - - async def get_token(self): - """Get auth token on spotify.""" - # return existing token if we have one in memory - if ( - self._auth_token - and os.path.isdir(self._cache_dir) - and (self._auth_token["expiresAt"] > int(time.time()) + 20) - ): - return self._auth_token - tokeninfo = {} - if not self.config.username or not self.config.password: - return tokeninfo - # retrieve token with librespot - retries = 0 - while retries < 4: - try: - tokeninfo = await asyncio.wait_for(self._get_token(), 5) - if tokeninfo: - break - retries += 1 - await asyncio.sleep(2) - except TimeoutError: - pass - if tokeninfo: - self._auth_token = tokeninfo - self._sp_user = await self._get_data("me") - self.mass.metadata.preferred_language = self._sp_user["country"] - self.logger.info( - "Succesfully logged in to Spotify as %s", self._sp_user["id"] - ) - self._auth_token = tokeninfo - else: - self.logger.error("Login failed for user %s", self.config.username) - return tokeninfo - - async def _get_token(self): - """Get spotify auth token with librespot bin.""" - # authorize with username and password (NOTE: this can also be Spotify Connect) - args = [ - await self.get_librespot_binary(), - "-O", - "-c", - self._cache_dir, - "-a", - "-u", - self.config.username, - "-p", - self.config.password, - ] - librespot = await asyncio.create_subprocess_exec(*args) - await librespot.wait() - # get token with (authorized) librespot - scopes = [ - "user-read-playback-state", - "user-read-currently-playing", - "user-modify-playback-state", - "playlist-read-private", - "playlist-read-collaborative", - "playlist-modify-public", - "playlist-modify-private", - "user-follow-modify", - "user-follow-read", - "user-library-read", - "user-library-modify", - "user-read-private", - "user-read-email", - "user-read-birthdate", - "user-top-read", - ] - scope = ",".join(scopes) - args = [ - await self.get_librespot_binary(), - "-O", - "-t", - "--client-id", - app_var(2), - "--scope", - scope, - "-c", - self._cache_dir, - ] - librespot = await asyncio.create_subprocess_exec( - *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT - ) - stdout, _ = await librespot.communicate() - try: - result = json.loads(stdout) - except JSONDecodeError: - self.logger.warning( - "Error while retrieving Spotify token, details: %s", stdout - ) - return None - # transform token info to spotipy compatible format - if result and "accessToken" in result: - tokeninfo = result - tokeninfo["expiresAt"] = tokeninfo["expiresIn"] + int(time.time()) - return tokeninfo - return None - - @use_cache(3600 * 24) - async def _get_all_items(self, endpoint, key="items", **kwargs) -> List[dict]: - """Get all items from a paged list.""" - limit = 50 - offset = 0 - all_items = [] - while True: - kwargs["limit"] = limit - kwargs["offset"] = offset - result = await self._get_data(endpoint, skip_cache=True, **kwargs) - offset += limit - if not result or key not in result or not result[key]: - break - for item in result[key]: - item["position"] = len(all_items) + 1 - all_items.append(item) - if len(result[key]) < limit: - break - return all_items - - @use_cache(3600 * 2) - async def _get_data(self, endpoint, **kwargs): - """Get data from api.""" - url = f"https://api.spotify.com/v1/{endpoint}" - kwargs["market"] = "from_token" - kwargs["country"] = "from_token" - token = await self.get_token() - if not token: - return None - headers = {"Authorization": f'Bearer {token["accessToken"]}'} - async with self._throttler: - async with self.mass.http_session.get( - url, headers=headers, params=kwargs, verify_ssl=False - ) as response: - try: - result = await response.json() - if "error" in result or ( - "status" in result and "error" in result["status"] - ): - self.logger.error("%s - %s", endpoint, result) - return None - except ( - aiohttp.ContentTypeError, - JSONDecodeError, - ) as err: - self.logger.error("%s - %s", endpoint, str(err)) - return None - return result - - async def _delete_data(self, endpoint, data=None, **kwargs): - """Delete data from api.""" - url = f"https://api.spotify.com/v1/{endpoint}" - token = await self.get_token() - if not token: - return None - headers = {"Authorization": f'Bearer {token["accessToken"]}'} - async with self.mass.http_session.delete( - url, headers=headers, params=kwargs, json=data, verify_ssl=False - ) as response: - return await response.text() - - async def _put_data(self, endpoint, data=None, **kwargs): - """Put data on api.""" - url = f"https://api.spotify.com/v1/{endpoint}" - token = await self.get_token() - if not token: - return None - headers = {"Authorization": f'Bearer {token["accessToken"]}'} - async with self.mass.http_session.put( - url, headers=headers, params=kwargs, json=data, verify_ssl=False - ) as response: - return await response.text() - - async def _post_data(self, endpoint, data=None, **kwargs): - """Post data on api.""" - url = f"https://api.spotify.com/v1/{endpoint}" - token = await self.get_token() - if not token: - return None - headers = {"Authorization": f'Bearer {token["accessToken"]}'} - async with self.mass.http_session.post( - url, headers=headers, params=kwargs, json=data, verify_ssl=False - ) as response: - return await response.text() - - async def get_librespot_binary(self): - """Find the correct librespot binary belonging to the platform.""" - if self._librespot_bin is not None: - return self._librespot_bin - - async def check_librespot(librespot_path: str) -> str | None: - try: - librespot = await asyncio.create_subprocess_exec( - *[librespot_path, "-V"], stdout=asyncio.subprocess.PIPE - ) - stdout, _ = await librespot.communicate() - if librespot.returncode == 0 and b"librespot" in stdout: - self._librespot_bin = librespot_path - return librespot_path - except OSError: - return None - - base_path = os.path.join(os.path.dirname(__file__), "librespot") - if platform.system() == "Windows": - if librespot := await check_librespot( - os.path.join(base_path, "windows", "librespot.exe") - ): - return librespot - if platform.system() == "Darwin": - # macos binary is x86_64 intel - if librespot := await check_librespot( - os.path.join(base_path, "osx", "librespot") - ): - return librespot - - if platform.system() == "Linux": - architecture = platform.machine() - if architecture in ["AMD64", "x86_64"]: - # generic linux x86_64 binary - if librespot := await check_librespot( - os.path.join( - base_path, - "linux", - "librespot-x86_64", - ) - ): - return librespot - - # arm architecture... try all options one by one... - for arch in ["aarch64", "armv7", "armhf", "arm"]: - if librespot := await check_librespot( - os.path.join( - base_path, - "linux", - f"librespot-{arch}", - ) - ): - return librespot - - raise RuntimeError( - f"Unable to locate Libespot for platform {platform.system()}" - ) diff --git a/music_assistant/controllers/music/providers/tunein.py b/music_assistant/controllers/music/providers/tunein.py deleted file mode 100644 index fe6ef60a..00000000 --- a/music_assistant/controllers/music/providers/tunein.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Tune-In musicprovider support for MusicAssistant.""" -from __future__ import annotations - -from typing import AsyncGenerator, List, Optional - -from asyncio_throttle import Throttler - -from music_assistant.helpers.audio import get_radio_stream -from music_assistant.helpers.cache import use_cache -from music_assistant.helpers.util import create_clean_string -from music_assistant.models.enums import ProviderType -from music_assistant.models.errors import LoginFailed, MediaNotFoundError -from music_assistant.models.media_items import ( - ContentType, - ImageType, - MediaItemImage, - MediaItemProviderId, - MediaItemType, - MediaQuality, - MediaType, - Radio, - StreamDetails, -) -from music_assistant.models.provider import MusicProvider - - -class TuneInProvider(MusicProvider): - """Provider implementation for Tune In.""" - - _attr_type = ProviderType.TUNEIN - _attr_name = "Tune-in Radio" - _attr_supported_mediatypes = [MediaType.RADIO] - _throttler = Throttler(rate_limit=1, period=1) - - async def setup(self) -> bool: - """Handle async initialization of the provider.""" - if not self.config.enabled: - return False - if not self.config.username: - raise LoginFailed("Username is invalid") - return True - - async def search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> List[MediaItemType]: - """ - Perform search on musicprovider. - - :param search_query: Search query. - :param media_types: A list of media_types to include. All types if None. - :param limit: Number of items to return in the search (per type). - """ - # pylint: disable=no-self-use - # TODO: search for radio stations - return [] - - async def get_library_radios(self) -> AsyncGenerator[Radio, None]: - """Retrieve library/subscribed radio stations from the provider.""" - - async def parse_items( - items: List[dict], folder: str = None - ) -> AsyncGenerator[Radio, None]: - for item in items: - item_type = item.get("type", "") - if item_type == "audio": - if "preset_id" not in item: - continue - # each radio station can have multiple streams add each one as different quality - stream_info = await self.__get_data( - "Tune.ashx", id=item["preset_id"] - ) - for stream in stream_info["body"]: - yield await self._parse_radio(item, stream, folder) - elif item_type == "link": - # stations are in sublevel (new style) - if sublevel := await self.__get_data(item["URL"], render="json"): - async for subitem in parse_items( - sublevel["body"], item["text"] - ): - yield subitem - elif item.get("children"): - # stations are in sublevel (old style ?) - async for subitem in parse_items(item["children"], item["text"]): - yield subitem - - data = await self.__get_data("Browse.ashx", c="presets") - if data and "body" in data: - async for item in parse_items(data["body"]): - yield item - - async def get_radio(self, prov_radio_id: str) -> Radio: - """Get radio station details.""" - prov_radio_id, media_type = prov_radio_id.split("--", 1) - params = {"c": "composite", "detail": "listing", "id": prov_radio_id} - result = await self.__get_data("Describe.ashx", **params) - if result and result.get("body") and result["body"][0].get("children"): - item = result["body"][0]["children"][0] - stream_info = await self.__get_data("Tune.ashx", id=prov_radio_id) - for stream in stream_info["body"]: - if stream["media_type"] != media_type: - continue - return await self._parse_radio(item, stream) - return None - - async def _parse_radio( - self, details: dict, stream: dict, folder: Optional[str] = None - ) -> Radio: - """Parse Radio object from json obj returned from api.""" - if "name" in details: - name = details["name"] - else: - # parse name from text attr - name = details["text"] - if " | " in name: - name = name.split(" | ")[1] - name = name.split(" (")[0] - item_id = f'{details["preset_id"]}--{stream["media_type"]}' - radio = Radio(item_id=item_id, provider=self.type, name=name) - if stream["media_type"] == "aac": - quality = MediaQuality.LOSSY_AAC - elif stream["media_type"] == "ogg": - quality = MediaQuality.LOSSY_OGG - else: - quality = MediaQuality.LOSSY_MP3 - radio.add_provider_id( - MediaItemProviderId( - item_id=item_id, - prov_type=self.type, - prov_id=self.id, - quality=quality, - details=stream["url"], - ) - ) - # preset number is used for sorting (not present at stream time) - preset_number = details.get("preset_number") - if preset_number and folder: - radio.sort_name = f'{folder}-{details["preset_number"]}' - elif preset_number: - radio.sort_name = details["preset_number"] - radio.sort_name += create_clean_string(name) - if "text" in details: - radio.metadata.description = details["text"] - # images - if img := details.get("image"): - radio.metadata.images = [MediaItemImage(ImageType.THUMB, img)] - if img := details.get("logo"): - radio.metadata.images = [MediaItemImage(ImageType.LOGO, img)] - return radio - - async def get_stream_details(self, item_id: str) -> StreamDetails: - """Get streamdetails for a radio station.""" - item_id, media_type = item_id.split("--", 1) - stream_info = await self.__get_data("Tune.ashx", id=item_id) - for stream in stream_info["body"]: - if stream["media_type"] == media_type: - return StreamDetails( - provider=self.type, - item_id=item_id, - content_type=ContentType(stream["media_type"]), - media_type=MediaType.RADIO, - data=stream, - ) - raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}") - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item.""" - async for chunk in get_radio_stream( - self.mass, streamdetails.data["url"], streamdetails - ): - yield chunk - - @use_cache(3600 * 2) - async def __get_data(self, endpoint: str, **kwargs): - """Get data from api.""" - if endpoint.startswith("http"): - url = endpoint - else: - url = f"https://opml.radiotime.com/{endpoint}" - kwargs["formats"] = "ogg,aac,wma,mp3" - kwargs["username"] = self.config.username - kwargs["partnerId"] = "1" - kwargs["render"] = "json" - async with self._throttler: - async with self.mass.http_session.get( - url, params=kwargs, verify_ssl=False - ) as response: - result = await response.json() - if not result or "error" in result: - self.logger.error(url) - self.logger.error(kwargs) - result = None - return result diff --git a/music_assistant/controllers/music/providers/url.py b/music_assistant/controllers/music/providers/url.py deleted file mode 100644 index 1dbb50f8..00000000 --- a/music_assistant/controllers/music/providers/url.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Basic provider allowing for external URL's to be streamed.""" -from __future__ import annotations - -import os -from typing import AsyncGenerator, List, Optional - -from music_assistant.helpers.audio import ( - get_file_stream, - get_http_stream, - get_radio_stream, -) -from music_assistant.models.config import MusicProviderConfig -from music_assistant.models.enums import ContentType, MediaType, ProviderType -from music_assistant.models.media_items import MediaItemType, StreamDetails -from music_assistant.models.provider import MusicProvider - -PROVIDER_CONFIG = MusicProviderConfig(ProviderType.URL) - - -class URLProvider(MusicProvider): - """Music Provider for manual URL's/files added to the queue.""" - - _attr_name: str = "URL" - _attr_type: ProviderType = ProviderType.URL - _attr_available: bool = True - _attr_supported_mediatypes: List[MediaType] = [] - - async def setup(self) -> bool: - """ - Handle async initialization of the provider. - - Called when provider is registered. - """ - return True - - async def search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> List[MediaItemType]: - """Perform search on musicprovider.""" - return [] - - async def get_stream_details(self, item_id: str) -> StreamDetails | None: - """Get streamdetails for a track/radio.""" - url = item_id - return StreamDetails( - provider=ProviderType.URL, - item_id=item_id, - content_type=ContentType.try_parse(url), - media_type=MediaType.URL, - data=url, - ) - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item.""" - if streamdetails.media_type == MediaType.RADIO: - # radio stream url - async for chunk in get_radio_stream( - self.mass, streamdetails.data, streamdetails - ): - yield chunk - elif os.path.isfile(streamdetails.data): - # local file - async for chunk in get_file_stream( - self.mass, streamdetails.data, streamdetails, seek_position - ): - yield chunk - else: - # regular stream url (without icy meta and reconnect) - async for chunk in get_http_stream( - self.mass, streamdetails.data, streamdetails, seek_position - ): - yield chunk diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py new file mode 100644 index 00000000..3c6038fd --- /dev/null +++ b/music_assistant/models/music_provider.py @@ -0,0 +1,282 @@ +"""Model for a Music Providers.""" +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Tuple + +from music_assistant.models.config import MusicProviderConfig +from music_assistant.models.enums import MediaType, ProviderType +from music_assistant.models.media_items import ( + Album, + Artist, + MediaItemType, + Playlist, + Radio, + StreamDetails, + Track, +) + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +class MusicProvider: + """Model for a Music Provider.""" + + _attr_name: str = None + _attr_type: ProviderType = None + _attr_available: bool = True + _attr_supported_mediatypes: List[MediaType] = [] + + def __init__(self, mass: MusicAssistant, config: MusicProviderConfig) -> None: + """Initialize MusicProvider.""" + self.mass = mass + self.config = config + self.logger = mass.logger + self.cache = mass.cache + + @abstractmethod + async def setup(self) -> bool: + """ + Handle async initialization of the provider. + + Called when provider is registered. + """ + + @property + def type(self) -> ProviderType: + """Return provider type for this provider.""" + return self._attr_type + + @property + def name(self) -> str: + """Return provider Name for this provider.""" + if sum(1 for x in self.mass.music.providers if x.type == self.type) > 1: + append_str = self.config.path or self.config.username + return f"{self._attr_name} ({append_str})" + return self._attr_name + + @property + def available(self) -> bool: + """Return boolean if this provider is available/initialized.""" + return self._attr_available + + @property + def supported_mediatypes(self) -> List[MediaType]: + """Return MediaTypes the provider supports.""" + return self._attr_supported_mediatypes + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """ + Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. All types if None. + :param limit: Number of items to return in the search (per type). + """ + raise NotImplementedError + + async def get_library_artists(self) -> AsyncGenerator[Artist, None]: + """Retrieve library artists from the provider.""" + if MediaType.ARTIST in self.supported_mediatypes: + raise NotImplementedError + + async def get_library_albums(self) -> AsyncGenerator[Album, None]: + """Retrieve library albums from the provider.""" + if MediaType.ALBUM in self.supported_mediatypes: + raise NotImplementedError + + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + """Retrieve library tracks from the provider.""" + if MediaType.TRACK in self.supported_mediatypes: + raise NotImplementedError + + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: + """Retrieve library/subscribed playlists from the provider.""" + if MediaType.PLAYLIST in self.supported_mediatypes: + raise NotImplementedError + + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + if MediaType.RADIO in self.supported_mediatypes: + raise NotImplementedError + + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + if MediaType.ARTIST in self.supported_mediatypes: + raise NotImplementedError + + async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: + """Get a list of all albums for the given artist.""" + if MediaType.ALBUM in self.supported_mediatypes: + raise NotImplementedError + + async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: + """Get a list of most popular tracks for the given artist.""" + if MediaType.TRACK in self.supported_mediatypes: + raise NotImplementedError + + async def get_album(self, prov_album_id: str) -> Album: + """Get full album details by id.""" + if MediaType.ALBUM in self.supported_mediatypes: + raise NotImplementedError + + async def get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + if MediaType.TRACK in self.supported_mediatypes: + raise NotImplementedError + + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get full playlist details by id.""" + if MediaType.PLAYLIST in self.supported_mediatypes: + raise NotImplementedError + + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get full radio details by id.""" + if MediaType.RADIO in self.supported_mediatypes: + raise NotImplementedError + + async def get_album_tracks(self, prov_album_id: str) -> List[Track]: + """Get album tracks for given album id.""" + if MediaType.ALBUM in self.supported_mediatypes: + raise NotImplementedError + + async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: + """Get all playlist tracks for given playlist id.""" + if MediaType.PLAYLIST in self.supported_mediatypes: + raise NotImplementedError + + async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool: + """Add item to provider's library. Return true on succes.""" + raise NotImplementedError + + async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: + """Remove item from provider's library. Return true on succes.""" + raise NotImplementedError + + async def add_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> None: + """Add track(s) to playlist.""" + if MediaType.PLAYLIST in self.supported_mediatypes: + raise NotImplementedError + + async def remove_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> None: + """Remove track(s) from playlist.""" + if MediaType.PLAYLIST in self.supported_mediatypes: + raise NotImplementedError + + async def get_stream_details(self, item_id: str) -> StreamDetails | None: + """Get streamdetails for a track/radio.""" + raise NotImplementedError + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + raise NotImplementedError + + async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemType: + """Get single MediaItem from provider.""" + if media_type == MediaType.ARTIST: + return await self.get_artist(prov_item_id) + if media_type == MediaType.ALBUM: + return await self.get_album(prov_item_id) + if media_type == MediaType.TRACK: + return await self.get_track(prov_item_id) + if media_type == MediaType.PLAYLIST: + return await self.get_playlist(prov_item_id) + if media_type == MediaType.RADIO: + return await self.get_radio(prov_item_id) + + async def sync_library( + self, media_types: Optional[Tuple[MediaType]] = None + ) -> None: + """Run library sync for this provider.""" + # this reference implementation can be overridden with provider specific approach + # this logic is aimed at streaming/online providers, + # which all have more or less the same structure. + # filesystem implementation(s) just override this. + async with self.mass.database.get_db() as db: + for media_type in self.supported_mediatypes: + if media_types is not None and media_type not in media_types: + continue + self.logger.debug("Start sync of %s items.", media_type.value) + controller = self.mass.music.get_controller(media_type) + + # create a set of all previous and current db id's + # note we only store the items in the prev_ids list that are + # unique to this provider to avoid getting into a mess where + # for example an item still exists on disk (in case of file provider) + # and no longer favorite on streaming provider. + # Bottomline this means that we don't do a full 2 way sync if multiple + # providers are attached to the same media item. + prev_ids = set() + for db_item in await controller.library(): + prov_types = {x.prov_type for x in db_item.provider_ids} + if len(prov_types) > 1: + continue + for prov_id in db_item.provider_ids: + if prov_id.prov_id == self.id: + prev_ids.add(db_item.item_id) + cur_ids = set() + async for prov_item in self._get_library_gen(media_type)(): + prov_item: MediaItemType = prov_item + + db_item: MediaItemType = await controller.get_db_item_by_prov_id( + provider_item_id=prov_item.item_id, + provider=prov_item.provider, + db=db, + ) + if not db_item: + # dump the item in the db, rich metadata is lazy loaded later + db_item = await controller.add_db_item(prov_item, db=db) + elif ( + db_item.metadata.checksum and prov_item.metadata.checksum + ) and db_item.metadata.checksum != prov_item.metadata.checksum: + # item checksum changed + db_item = await controller.update_db_item( + db_item.item_id, prov_item, db=db + ) + cur_ids.add(db_item.item_id) + if not db_item.in_library: + await controller.set_db_library(db_item.item_id, True, db=db) + + # process deletions + for item_id in prev_ids: + if item_id not in cur_ids: + # only mark the item as not in library and leave the metadata in db + await controller.set_db_library(item_id, False, db=db) + + # DO NOT OVERRIDE BELOW + + @property + def id(self) -> str: + """Return unique provider id to distinguish multiple instances of the same provider.""" + return self.config.id + + def to_dict(self) -> Dict[str, Any]: + """Return (serializable) dict representation of MusicProvider.""" + return { + "type": self.type.value, + "name": self.name, + "id": self.id, + "supported_mediatypes": [x.value for x in self.supported_mediatypes], + } + + def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType]: + """Return library generator for given media_type.""" + if media_type == MediaType.ARTIST: + return self.get_library_artists + if media_type == MediaType.ALBUM: + return self.get_library_albums + if media_type == MediaType.TRACK: + return self.get_library_tracks + if media_type == MediaType.PLAYLIST: + return self.get_library_playlists + if media_type == MediaType.RADIO: + return self.get_library_radios diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py deleted file mode 100644 index 3c6038fd..00000000 --- a/music_assistant/models/provider.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Model for a Music Providers.""" -from __future__ import annotations - -from abc import abstractmethod -from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Tuple - -from music_assistant.models.config import MusicProviderConfig -from music_assistant.models.enums import MediaType, ProviderType -from music_assistant.models.media_items import ( - Album, - Artist, - MediaItemType, - Playlist, - Radio, - StreamDetails, - Track, -) - -if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - - -class MusicProvider: - """Model for a Music Provider.""" - - _attr_name: str = None - _attr_type: ProviderType = None - _attr_available: bool = True - _attr_supported_mediatypes: List[MediaType] = [] - - def __init__(self, mass: MusicAssistant, config: MusicProviderConfig) -> None: - """Initialize MusicProvider.""" - self.mass = mass - self.config = config - self.logger = mass.logger - self.cache = mass.cache - - @abstractmethod - async def setup(self) -> bool: - """ - Handle async initialization of the provider. - - Called when provider is registered. - """ - - @property - def type(self) -> ProviderType: - """Return provider type for this provider.""" - return self._attr_type - - @property - def name(self) -> str: - """Return provider Name for this provider.""" - if sum(1 for x in self.mass.music.providers if x.type == self.type) > 1: - append_str = self.config.path or self.config.username - return f"{self._attr_name} ({append_str})" - return self._attr_name - - @property - def available(self) -> bool: - """Return boolean if this provider is available/initialized.""" - return self._attr_available - - @property - def supported_mediatypes(self) -> List[MediaType]: - """Return MediaTypes the provider supports.""" - return self._attr_supported_mediatypes - - async def search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> List[MediaItemType]: - """ - Perform search on musicprovider. - - :param search_query: Search query. - :param media_types: A list of media_types to include. All types if None. - :param limit: Number of items to return in the search (per type). - """ - raise NotImplementedError - - async def get_library_artists(self) -> AsyncGenerator[Artist, None]: - """Retrieve library artists from the provider.""" - if MediaType.ARTIST in self.supported_mediatypes: - raise NotImplementedError - - async def get_library_albums(self) -> AsyncGenerator[Album, None]: - """Retrieve library albums from the provider.""" - if MediaType.ALBUM in self.supported_mediatypes: - raise NotImplementedError - - async def get_library_tracks(self) -> AsyncGenerator[Track, None]: - """Retrieve library tracks from the provider.""" - if MediaType.TRACK in self.supported_mediatypes: - raise NotImplementedError - - async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: - """Retrieve library/subscribed playlists from the provider.""" - if MediaType.PLAYLIST in self.supported_mediatypes: - raise NotImplementedError - - async def get_library_radios(self) -> AsyncGenerator[Radio, None]: - """Retrieve library/subscribed radio stations from the provider.""" - if MediaType.RADIO in self.supported_mediatypes: - raise NotImplementedError - - async def get_artist(self, prov_artist_id: str) -> Artist: - """Get full artist details by id.""" - if MediaType.ARTIST in self.supported_mediatypes: - raise NotImplementedError - - async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: - """Get a list of all albums for the given artist.""" - if MediaType.ALBUM in self.supported_mediatypes: - raise NotImplementedError - - async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: - """Get a list of most popular tracks for the given artist.""" - if MediaType.TRACK in self.supported_mediatypes: - raise NotImplementedError - - async def get_album(self, prov_album_id: str) -> Album: - """Get full album details by id.""" - if MediaType.ALBUM in self.supported_mediatypes: - raise NotImplementedError - - async def get_track(self, prov_track_id: str) -> Track: - """Get full track details by id.""" - if MediaType.TRACK in self.supported_mediatypes: - raise NotImplementedError - - async def get_playlist(self, prov_playlist_id: str) -> Playlist: - """Get full playlist details by id.""" - if MediaType.PLAYLIST in self.supported_mediatypes: - raise NotImplementedError - - async def get_radio(self, prov_radio_id: str) -> Radio: - """Get full radio details by id.""" - if MediaType.RADIO in self.supported_mediatypes: - raise NotImplementedError - - async def get_album_tracks(self, prov_album_id: str) -> List[Track]: - """Get album tracks for given album id.""" - if MediaType.ALBUM in self.supported_mediatypes: - raise NotImplementedError - - async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: - """Get all playlist tracks for given playlist id.""" - if MediaType.PLAYLIST in self.supported_mediatypes: - raise NotImplementedError - - async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool: - """Add item to provider's library. Return true on succes.""" - raise NotImplementedError - - async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: - """Remove item from provider's library. Return true on succes.""" - raise NotImplementedError - - async def add_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> None: - """Add track(s) to playlist.""" - if MediaType.PLAYLIST in self.supported_mediatypes: - raise NotImplementedError - - async def remove_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> None: - """Remove track(s) from playlist.""" - if MediaType.PLAYLIST in self.supported_mediatypes: - raise NotImplementedError - - async def get_stream_details(self, item_id: str) -> StreamDetails | None: - """Get streamdetails for a track/radio.""" - raise NotImplementedError - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item.""" - raise NotImplementedError - - async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemType: - """Get single MediaItem from provider.""" - if media_type == MediaType.ARTIST: - return await self.get_artist(prov_item_id) - if media_type == MediaType.ALBUM: - return await self.get_album(prov_item_id) - if media_type == MediaType.TRACK: - return await self.get_track(prov_item_id) - if media_type == MediaType.PLAYLIST: - return await self.get_playlist(prov_item_id) - if media_type == MediaType.RADIO: - return await self.get_radio(prov_item_id) - - async def sync_library( - self, media_types: Optional[Tuple[MediaType]] = None - ) -> None: - """Run library sync for this provider.""" - # this reference implementation can be overridden with provider specific approach - # this logic is aimed at streaming/online providers, - # which all have more or less the same structure. - # filesystem implementation(s) just override this. - async with self.mass.database.get_db() as db: - for media_type in self.supported_mediatypes: - if media_types is not None and media_type not in media_types: - continue - self.logger.debug("Start sync of %s items.", media_type.value) - controller = self.mass.music.get_controller(media_type) - - # create a set of all previous and current db id's - # note we only store the items in the prev_ids list that are - # unique to this provider to avoid getting into a mess where - # for example an item still exists on disk (in case of file provider) - # and no longer favorite on streaming provider. - # Bottomline this means that we don't do a full 2 way sync if multiple - # providers are attached to the same media item. - prev_ids = set() - for db_item in await controller.library(): - prov_types = {x.prov_type for x in db_item.provider_ids} - if len(prov_types) > 1: - continue - for prov_id in db_item.provider_ids: - if prov_id.prov_id == self.id: - prev_ids.add(db_item.item_id) - cur_ids = set() - async for prov_item in self._get_library_gen(media_type)(): - prov_item: MediaItemType = prov_item - - db_item: MediaItemType = await controller.get_db_item_by_prov_id( - provider_item_id=prov_item.item_id, - provider=prov_item.provider, - db=db, - ) - if not db_item: - # dump the item in the db, rich metadata is lazy loaded later - db_item = await controller.add_db_item(prov_item, db=db) - elif ( - db_item.metadata.checksum and prov_item.metadata.checksum - ) and db_item.metadata.checksum != prov_item.metadata.checksum: - # item checksum changed - db_item = await controller.update_db_item( - db_item.item_id, prov_item, db=db - ) - cur_ids.add(db_item.item_id) - if not db_item.in_library: - await controller.set_db_library(db_item.item_id, True, db=db) - - # process deletions - for item_id in prev_ids: - if item_id not in cur_ids: - # only mark the item as not in library and leave the metadata in db - await controller.set_db_library(item_id, False, db=db) - - # DO NOT OVERRIDE BELOW - - @property - def id(self) -> str: - """Return unique provider id to distinguish multiple instances of the same provider.""" - return self.config.id - - def to_dict(self) -> Dict[str, Any]: - """Return (serializable) dict representation of MusicProvider.""" - return { - "type": self.type.value, - "name": self.name, - "id": self.id, - "supported_mediatypes": [x.value for x in self.supported_mediatypes], - } - - def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType]: - """Return library generator for given media_type.""" - if media_type == MediaType.ARTIST: - return self.get_library_artists - if media_type == MediaType.ALBUM: - return self.get_library_albums - if media_type == MediaType.TRACK: - return self.get_library_tracks - if media_type == MediaType.PLAYLIST: - return self.get_library_playlists - if media_type == MediaType.RADIO: - return self.get_library_radios diff --git a/music_assistant/music_providers/__init__.py b/music_assistant/music_providers/__init__.py new file mode 100644 index 00000000..01895ef6 --- /dev/null +++ b/music_assistant/music_providers/__init__.py @@ -0,0 +1 @@ +"""Package with Music Providers.""" diff --git a/music_assistant/music_providers/filesystem.py b/music_assistant/music_providers/filesystem.py new file mode 100644 index 00000000..6658cb38 --- /dev/null +++ b/music_assistant/music_providers/filesystem.py @@ -0,0 +1,861 @@ +"""Filesystem musicprovider support for MusicAssistant.""" +from __future__ import annotations + +import asyncio +import os +import urllib.parse +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncGenerator, List, Optional, Set, Tuple + +import aiofiles +import xmltodict +from aiofiles.os import wrap +from aiofiles.threadpool.binary import AsyncFileIO +from tinytag.tinytag import TinyTag + +from music_assistant.helpers.audio import get_file_stream +from music_assistant.helpers.compare import compare_strings +from music_assistant.helpers.database import SCHEMA_VERSION +from music_assistant.helpers.util import ( + create_clean_string, + parse_title_and_version, + try_parse_int, +) +from music_assistant.models.enums import ProviderType +from music_assistant.models.errors import MediaNotFoundError, MusicAssistantError +from music_assistant.models.media_items import ( + Album, + AlbumType, + Artist, + ContentType, + ImageType, + ItemMapping, + MediaItemImage, + MediaItemProviderId, + MediaItemType, + MediaQuality, + MediaType, + Playlist, + StreamDetails, + Track, +) +from music_assistant.models.music_provider import MusicProvider + +FALLBACK_ARTIST = "Various Artists" +SPLITTERS = (";", ",", "Featuring", " Feat. ", " Feat ", "feat.", " & ", " / ") +CONTENT_TYPE_EXT = { + # map of supported file extensions (mapped to ContentType) + "mp3": ContentType.MP3, + "m4a": ContentType.M4A, + "flac": ContentType.FLAC, + "wav": ContentType.WAV, + "ogg": ContentType.OGG, + "wma": ContentType.WMA, +} + + +async def scantree(path: str) -> AsyncGenerator[os.DirEntry, None]: + """Recursively yield DirEntry objects for given directory.""" + + def is_dir(entry: os.DirEntry) -> bool: + return entry.is_dir(follow_symlinks=False) + + loop = asyncio.get_running_loop() + for entry in await loop.run_in_executor(None, os.scandir, path): + if await loop.run_in_executor(None, is_dir, entry): + async for subitem in scantree(entry.path): + yield subitem + else: + yield entry + + +def split_items(org_str: str) -> Tuple[str]: + """Split up a tags string by common splitter.""" + if isinstance(org_str, list): + return org_str + if org_str is None: + return tuple() + for splitter in SPLITTERS: + if splitter in org_str: + return tuple((x.strip() for x in org_str.split(splitter))) + return (org_str,) + + +class FileSystemProvider(MusicProvider): + """ + Implementation of a musicprovider for local files. + + Reads ID3 tags from file and falls back to parsing filename. + Optionally reads metadata from nfo files and images in folder structure /. + Supports m3u files only for playlists. + Supports having URI's from streaming providers within m3u playlist. + """ + + _attr_name = "Filesystem" + _attr_type = ProviderType.FILESYSTEM_LOCAL + _attr_supported_mediatypes = [ + MediaType.TRACK, + MediaType.PLAYLIST, + MediaType.ARTIST, + MediaType.ALBUM, + ] + + async def setup(self) -> bool: + """Handle async initialization of the provider.""" + + isdir = wrap(os.path.exists) + + if not await isdir(self.config.path): + raise MediaNotFoundError( + f"Music Directory {self.config.path} does not exist" + ) + + return True + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """Perform search on musicprovider.""" + result = [] + # searching the filesystem is slow and unreliable, + # instead we make some (slow) freaking queries to the db ;-) + params = {"name": f"%{search_query}%", "prov_type": f"%{self.type.value}%"} + if media_types is None or MediaType.TRACK in media_types: + query = "SELECT * FROM tracks WHERE name LIKE :name AND provider_ids LIKE :prov_type" + tracks = await self.mass.music.tracks.get_db_items(query, params) + result += tracks + if media_types is None or MediaType.ALBUM in media_types: + query = "SELECT * FROM albums WHERE name LIKE :name AND provider_ids LIKE :prov_type" + albums = await self.mass.music.albums.get_db_items(query, params) + result += albums + if media_types is None or MediaType.ARTIST in media_types: + query = "SELECT * FROM artists WHERE name LIKE :name AND provider_ids LIKE :prov_type" + artists = await self.mass.music.artists.get_db_items(query, params) + result += artists + if media_types is None or MediaType.PLAYLIST in media_types: + query = "SELECT * FROM playlists WHERE name LIKE :name AND provider_ids LIKE :prov_type" + playlists = await self.mass.music.playlists.get_db_items(query, params) + result += playlists + return result + + async def sync_library( + self, media_types: Optional[Tuple[MediaType]] = None + ) -> None: + """Run library sync for this provider.""" + cache_key = f"{self.id}.checksums" + prev_checksums = await self.mass.cache.get(cache_key, SCHEMA_VERSION) + if prev_checksums is None: + prev_checksums = {} + # find all music files in the music directory and all subfolders + # we work bottom up, as-in we derive all info from the tracks + cur_checksums = {} + async with self.mass.database.get_db() as db: + async for entry in scantree(self.config.path): + + # mtime is used as file checksum + stat = await asyncio.get_running_loop().run_in_executor( + None, entry.stat + ) + checksum = int(stat.st_mtime) + cur_checksums[entry.path] = checksum + if checksum == prev_checksums.get(entry.path): + continue + try: + if track := await self._parse_track(entry.path): + # process album + if track.album: + db_album = await self.mass.music.albums.add_db_item( + track.album, db=db + ) + if not db_album.in_library: + await self.mass.music.albums.set_db_library( + db_album.item_id, True, db=db + ) + # process (album)artist + if track.album.artist: + db_artist = await self.mass.music.artists.add_db_item( + track.album.artist, db=db + ) + if not db_artist.in_library: + await self.mass.music.artists.set_db_library( + db_artist.item_id, True, db=db + ) + # add/update track to db + db_track = await self.mass.music.tracks.add_db_item( + track, db=db + ) + if not db_track.in_library: + await self.mass.music.tracks.set_db_library( + db_track.item_id, True, db=db + ) + elif playlist := await self._parse_playlist(entry.path): + # add/update] playlist to db + playlist.metadata.checksum = checksum + await self.mass.music.playlists.add_db_item(playlist, db=db) + except Exception: # pylint: disable=broad-except + # we don't want the whole sync to crash on one file so we catch all exceptions here + self.logger.exception("Error processing %s", entry.path) + + # save checksums for next sync + await self.mass.cache.set(cache_key, cur_checksums, SCHEMA_VERSION) + + # work out deletions + deleted_files = set(prev_checksums.keys()) - set(cur_checksums.keys()) + artists: Set[ItemMapping] = set() + albums: Set[ItemMapping] = set() + # process deleted tracks + for file_path in deleted_files: + item_id = self._get_item_id(file_path) + if db_item := await self.mass.music.tracks.get_db_item_by_prov_id( + item_id, self.type + ): + await self.mass.music.tracks.remove_prov_mapping( + db_item.item_id, self.id + ) + # gather artists(s) attached to this track + for artist in db_item.artists: + artists.add(artist.item_id) + # gather album and albumartist(s) attached to this track + if db_item.album: + albums.add(db_item.album.item_id) + for artist in db_item.album.artists: + artists.add(artist.item_id) + # check if albums are deleted + for album_id in albums: + album = await self.mass.music.albums.get_db_item(album_id) + if not album: + continue + prov_album_id = next( + x.item_id for x in album.provider_ids if x.prov_id == self.id + ) + album_tracks = await self.get_album_tracks(prov_album_id) + if album_tracks: + continue + # album has no more tracks attached, delete prov mapping + await self.mass.music.albums.remove_prov_mapping(album_id) + # check if artists are deleted + for artist_id in artists: + artist = await self.mass.music.artists.get_db_item(artist_id) + prov_artist_id = next( + x.item_id for x in artist.provider_ids if x.prov_id == self.id + ) + artist_tracks = await self.get_artist_toptracks(prov_artist_id) + if artist_tracks: + continue + artist_albums = await self.get_artist_albums(prov_artist_id) + if artist_albums: + continue + # artist has no more tracks attached, delete prov mapping + await self.mass.music.artists.remove_prov_mapping(artist_id) + + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + itempath = await self.get_filepath(MediaType.ARTIST, prov_artist_id) + if await self.exists(itempath): + # if path exists on disk allow parsing full details to allow refresh of metadata + return await self._parse_artist(artist_path=itempath) + return await self.mass.music.artists.get_db_item_by_prov_id( + provider_item_id=prov_artist_id, provider_id=self.id + ) + + async def get_album(self, prov_album_id: str) -> Album: + """Get full album details by id.""" + db_album = await self.mass.music.albums.get_db_item_by_prov_id( + provider_item_id=prov_album_id, provider_id=self.id + ) + if db_album is None: + raise MediaNotFoundError(f"Album not found: {prov_album_id}") + itempath = await self.get_filepath(MediaType.ALBUM, prov_album_id) + if await self.exists(itempath): + # if path exists on disk allow parsing full details to allow refresh of metadata + return await self._parse_album(None, itempath, db_album.artists) + return db_album + + async def get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + itempath = await self.get_filepath(MediaType.TRACK, prov_track_id) + return await self._parse_track(itempath) + + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get full playlist details by id.""" + itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) + return await self._parse_playlist(itempath) + + async def get_album_tracks(self, prov_album_id: str) -> List[Track]: + """Get album tracks for given album id.""" + # filesystem items are always stored in db so we can query the database + db_album = await self.mass.music.albums.get_db_item_by_prov_id( + prov_album_id, provider_id=self.id + ) + if db_album is None: + raise MediaNotFoundError(f"Album not found: {prov_album_id}") + # TODO: adjust to json query instead of text search + query = f"SELECT * FROM tracks WHERE albums LIKE '%\"{db_album.item_id}\"%'" + query += f" AND provider_ids LIKE '%\"{self.type.value}\"%'" + result = [] + for track in await self.mass.music.tracks.get_db_items(query): + track.album = db_album + album_mapping = next( + (x for x in track.albums if x.item_id == db_album.item_id), None + ) + track.disc_number = album_mapping.disc_number + track.track_number = album_mapping.track_number + result.append(track) + return result + + async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: + """Get playlist tracks for given playlist id.""" + result = [] + playlist_path = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) + if not await self.exists(playlist_path): + raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}") + getmtime = wrap(os.path.getmtime) + mtime = await getmtime(playlist_path) + checksum = f"{SCHEMA_VERSION}.{int(mtime)}" + cache_key = f"playlist_{self.id}_tracks_{prov_playlist_id}" + if cache := await self.mass.cache.get(cache_key, checksum): + return [Track.from_dict(x) for x in cache] + playlist_base_path = Path(playlist_path).parent + index = 0 + try: + async with self.open_file(playlist_path, "r") as _file: + for line in await _file.readlines(): + line = urllib.parse.unquote(line.strip()) + if line and not line.startswith("#"): + # TODO: add support for .pls playlist files + if track := await self._parse_playlist_line( + line, playlist_base_path + ): + track.position = index + result.append(track) + index += 1 + except Exception as err: # pylint: disable=broad-except + self.logger.warning( + "Error while parsing playlist %s", playlist_path, exc_info=err + ) + await self.mass.cache.set(cache_key, [x.to_dict() for x in result], checksum) + return result + + async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None: + """Try to parse a track from a playlist line.""" + if "://" in line: + # track is uri from external provider? + try: + return await self.mass.music.get_item_by_uri(line) + except MusicAssistantError as err: + self.logger.warning( + "Could not parse uri %s to track: %s", line, str(err) + ) + return None + # try to treat uri as filename + if await self.exists(line): + return await self._parse_track(line) + rel_path = os.path.join(playlist_path, line) + if await self.exists(rel_path): + return await self._parse_track(rel_path) + return None + + async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: + """Get a list of albums for the given artist.""" + # filesystem items are always stored in db so we can query the database + db_artist = await self.mass.music.artists.get_db_item_by_prov_id( + prov_artist_id, provider_id=self.id + ) + if db_artist is None: + raise MediaNotFoundError(f"Artist not found: {prov_artist_id}") + # TODO: adjust to json query instead of text search + query = f"SELECT * FROM albums WHERE artists LIKE '%\"{db_artist.item_id}\"%'" + query += f" AND provider_ids LIKE '%\"{self.type.value}\"%'" + return await self.mass.music.albums.get_db_items(query) + + async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: + """Get a list of all tracks as we have no clue about preference.""" + # filesystem items are always stored in db so we can query the database + db_artist = await self.mass.music.artists.get_db_item_by_prov_id( + prov_artist_id, provider_id=self.id + ) + if db_artist is None: + raise MediaNotFoundError(f"Artist not found: {prov_artist_id}") + # TODO: adjust to json query instead of text search + query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{db_artist.item_id}\"%'" + query += f" AND provider_ids LIKE '%\"{self.type.value}\"%'" + return await self.mass.music.tracks.get_db_items(query) + + async def library_add(self, *args, **kwargs) -> bool: + """Add item to provider's library. Return true on succes.""" + # already handled by database + + async def library_remove(self, *args, **kwargs) -> bool: + """Remove item from provider's library. Return true on succes.""" + # already handled by database + # TODO: do we want to process/offer deletions here ? + + async def add_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> None: + """Add track(s) to playlist.""" + itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) + if not await self.exists(itempath): + raise MediaNotFoundError(f"Playlist path does not exist: {itempath}") + async with self.open_file(itempath, "r") as _file: + cur_data = await _file.read() + async with self.open_file(itempath, "w") as _file: + await _file.write(cur_data) + for uri in prov_track_ids: + await _file.write(f"\n{uri}") + + async def remove_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> None: + """Remove track(s) from playlist.""" + itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id) + if not await self.exists(itempath): + raise MediaNotFoundError(f"Playlist path does not exist: {itempath}") + cur_lines = [] + async with self.open_file(itempath, "r") as _file: + for line in await _file.readlines(): + line = urllib.parse.unquote(line.strip()) + if line not in prov_track_ids: + cur_lines.append(line) + async with self.open_file(itempath, "w") as _file: + for uri in cur_lines: + await _file.write(f"{uri}\n") + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Return the content details for the given track when it will be streamed.""" + itempath = await self.get_filepath(MediaType.TRACK, item_id) + if not await self.exists(itempath): + raise MediaNotFoundError(f"Track path does not exist: {itempath}") + + def parse_tag(): + return TinyTag.get(itempath) + + tags = await self.mass.loop.run_in_executor(None, parse_tag) + _, ext = Path(itempath).name.rsplit(".", 1) + content_type = CONTENT_TYPE_EXT.get(ext.lower()) + + stat = await self.mass.loop.run_in_executor(None, os.stat, itempath) + + return StreamDetails( + provider=self.type, + item_id=item_id, + content_type=content_type, + media_type=MediaType.TRACK, + duration=tags.duration, + size=stat.st_size, + sample_rate=tags.samplerate or 44100, + bit_depth=16, # TODO: parse bitdepth + data=itempath, + ) + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + async for chunk in get_file_stream( + self.mass, streamdetails.data, streamdetails, seek_position + ): + yield chunk + + async def _parse_track(self, track_path: str) -> Track | None: + """Try to parse a track from a filename by reading its tags.""" + + if not await self.exists(track_path): + raise MediaNotFoundError(f"Track path does not exist: {track_path}") + + if "." not in track_path or track_path.startswith("."): + # skip system files and files without extension + return None + + filename_base, ext = Path(track_path).name.rsplit(".", 1) + content_type = CONTENT_TYPE_EXT.get(ext.lower()) + if content_type is None: + # unsupported file extension + return None + + track_item_id = self._get_item_id(track_path) + + # parse ID3 tags with TinyTag + def parse_tags(): + return TinyTag.get(track_path, image=True, ignore_errors=True) + + tags = await self.mass.loop.run_in_executor(None, parse_tags) + + # prefer title from tags, fallback to filename + if not tags.title or not tags.artist: + self.logger.warning( + "%s is missing ID3 tags, using filename as fallback", track_path + ) + filename_parts = filename_base.split(" - ", 1) + if len(filename_parts) == 2: + tags.artist = tags.artist or filename_parts[0] + tags.title = tags.title or filename_parts[1] + else: + tags.artist = tags.artist or FALLBACK_ARTIST + tags.title = tags.title or filename_base + + name, version = parse_title_and_version(tags.title) + track = Track( + item_id=track_item_id, + provider=self.type, + name=name, + version=version, + # a track on disk is always in library + in_library=True, + ) + + # album + # work out if we have an artist/album/track.ext structure + if tags.album: + track_parts = track_path.rsplit(os.sep) + album_folder = None + artist_folder = None + parentdir = os.path.dirname(track_path) + for _ in range(len(track_parts)): + dirname = parentdir.rsplit(os.sep)[-1] + if compare_strings(dirname, tags.albumartist): + artist_folder = parentdir + if compare_strings(dirname, tags.album): + album_folder = parentdir + parentdir = os.path.dirname(parentdir) + + # album artist + if artist_folder: + album_artists = [ + await self._parse_artist( + name=tags.albumartist, + artist_path=artist_folder, + in_library=True, + ) + ] + elif tags.albumartist: + album_artists = [ + await self._parse_artist(name=item, in_library=True) + for item in split_items(tags.albumartist) + ] + + else: + # always fallback to various artists as album artist if user did not tag album artist + # ID3 tag properly because we must have an album artist + album_artists = [await self._parse_artist(name=FALLBACK_ARTIST)] + self.logger.warning( + "%s is missing ID3 tag [albumartist], using %s as fallback", + track_path, + FALLBACK_ARTIST, + ) + + track.album = await self._parse_album( + tags.album, + album_folder, + artists=album_artists, + in_library=True, + ) + else: + self.logger.warning("%s is missing ID3 tag [album]", track_path) + + # track artist(s) + if tags.artist == tags.albumartist and track.album: + track.artists = track.album.artists + else: + # Parse track artist(s) from artist string using common splitters used in ID3 tags + # NOTE: do not use a '/' or '&' to prevent artists like AC/DC become messed up + track_artists_str = tags.artist or FALLBACK_ARTIST + track.artists = [ + await self._parse_artist(item, in_library=False) + for item in split_items(track_artists_str) + ] + + # Check if track has embedded metadata + img = await self.mass.loop.run_in_executor(None, tags.get_image) + if not track.metadata.images and img: + # we do not actually embed the image in the metadata because that would consume too + # much space and bandwidth. Instead we set the filename as value so the image can + # be retrieved later in realtime. + track.metadata.images = [MediaItemImage(ImageType.THUMB, track_path, True)] + if track.album and not track.album.metadata.images: + track.album.metadata.images = track.metadata.images + + # parse other info + track.duration = tags.duration + track.metadata.genres = set(split_items(tags.genre)) + track.disc_number = try_parse_int(tags.disc) + track.track_number = try_parse_int(tags.track) + track.isrc = tags.extra.get("isrc", "") + if "copyright" in tags.extra: + track.metadata.copyright = tags.extra["copyright"] + if "lyrics" in tags.extra: + track.metadata.lyrics = tags.extra["lyrics"] + + quality_details = "" + if content_type == ContentType.FLAC: + # TODO: get bit depth + quality = MediaQuality.FLAC_LOSSLESS + if tags.samplerate > 192000: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 + elif tags.samplerate > 96000: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 + elif tags.samplerate > 48000: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 + quality_details = f"{tags.samplerate / 1000} Khz" + elif track_path.endswith(".ogg"): + quality = MediaQuality.LOSSY_OGG + quality_details = f"{tags.bitrate} kbps" + elif track_path.endswith(".m4a"): + quality = MediaQuality.LOSSY_AAC + quality_details = f"{tags.bitrate} kbps" + else: + quality = MediaQuality.LOSSY_MP3 + quality_details = f"{tags.bitrate} kbps" + track.add_provider_id( + MediaItemProviderId( + item_id=track_item_id, + prov_type=self.type, + prov_id=self.id, + quality=quality, + details=quality_details, + url=track_path, + ) + ) + return track + + async def _parse_artist( + self, + name: Optional[str] = None, + artist_path: Optional[str] = None, + in_library: bool = True, + ) -> Artist | None: + """Lookup metadata in Artist folder.""" + assert name or artist_path + if not artist_path: + # create fake path + artist_path = os.path.join(self.config.path, name) + + artist_item_id = self._get_item_id(artist_path) + if not name: + name = artist_path.split(os.sep)[-1] + + artist = Artist( + artist_item_id, + self.type, + name, + provider_ids={ + MediaItemProviderId(artist_item_id, self.type, self.id, url=artist_path) + }, + in_library=in_library, + ) + + if not await self.exists(artist_path): + # return basic object if there is no dedicated artist folder + return artist + + # always mark artist as in-library when it exists as folder on disk + artist.in_library = True + + 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 + async with self.open_file(nfo_file, "r") as _file: + data = await _file.read() + info = await self.mass.loop.run_in_executor(None, 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 musicbrainz_id := info.get("musicbrainzartistid"): + artist.musicbrainz_id = musicbrainz_id + if descripton := info.get("biography"): + artist.metadata.description = descripton + if genre := info.get("genre"): + artist.metadata.genres = set(split_items(genre)) + # find local images + images = [] + async for _path in scantree(artist_path): + _filename = _path.path + ext = _filename.split(".")[-1] + if ext not in ("jpg", "png"): + continue + _filepath = os.path.join(artist_path, _filename) + for img_type in ImageType: + if img_type.value in _filepath: + images.append(MediaItemImage(img_type, _filepath, True)) + elif _filename == "folder.jpg": + images.append(MediaItemImage(ImageType.THUMB, _filepath, True)) + if images: + artist.metadata.images = images + + return artist + + async def _parse_album( + self, + name: Optional[str], + album_path: Optional[str], + artists: List[Artist], + in_library: bool = True, + ) -> Album | None: + """Lookup metadata in Album folder.""" + assert (name or album_path) and artists + if not album_path: + # create fake path + album_path = os.path.join(self.config.path, artists[0].name, name) + + album_item_id = self._get_item_id(album_path) + if not name: + name = album_path.split(os.sep)[-1] + + album = Album( + album_item_id, + self.type, + name, + artists=artists, + provider_ids={ + MediaItemProviderId(album_item_id, self.type, self.id, url=album_path) + }, + in_library=in_library, + ) + + if not await self.exists(album_path): + # return basic object if there is no dedicated album folder + return album + + # always mark as in-library when it exists as folder on disk + album.in_library = True + + nfo_file = os.path.join(album_path, "album.nfo") + if await self.exists(nfo_file): + # found NFO file with metadata + # https://kodi.wiki/view/NFO_files/Artists + async with self.open_file(nfo_file) as _file: + data = await _file.read() + info = await self.mass.loop.run_in_executor(None, xmltodict.parse, data) + info = info["album"] + album.name = info.get("title", info.get("name", name)) + if sort_name := info.get("sortname"): + album.sort_name = sort_name + if musicbrainz_id := info.get("musicbrainzreleasegroupid"): + album.musicbrainz_id = musicbrainz_id + if mb_artist_id := info.get("musicbrainzalbumartistid"): + if album.artist and not album.artist.musicbrainz_id: + album.artist.musicbrainz_id = mb_artist_id + if description := info.get("review"): + album.metadata.description = description + if year := info.get("label"): + album.year = int(year) + if genre := info.get("genre"): + album.metadata.genres = set(split_items(genre)) + # parse name/version + album.name, album.version = parse_title_and_version(album.name) + + # try to guess the album type + album_tracks = [ + x async for x in scantree(album_path) if TinyTag.is_supported(x.path) + ] + if album.artist.sort_name == "variousartists": + album.album_type = AlbumType.COMPILATION + elif len(album_tracks) <= 5: + album.album_type = AlbumType.SINGLE + else: + album.album_type = AlbumType.ALBUM + + # find local images + images = [] + async for _path in scantree(album_path): + _filename = _path.path + ext = _filename.split(".")[-1] + if ext not in ("jpg", "png"): + continue + _filepath = os.path.join(album_path, _filename) + for img_type in ImageType: + if img_type.value in _filepath: + images.append(MediaItemImage(img_type, _filepath, True)) + elif _filename == "folder.jpg": + images.append(MediaItemImage(ImageType.THUMB, _filepath, True)) + if images: + album.metadata.images = images + + return album + + async def _parse_playlist(self, playlist_path: str) -> Playlist | None: + """Parse playlist from file.""" + playlist_item_id = self._get_item_id(playlist_path) + + if not playlist_path.endswith(".m3u"): + return None + + if not await self.exists(playlist_path): + raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}") + + name = playlist_path.split(os.sep)[-1].replace(".m3u", "") + + playlist = Playlist(playlist_item_id, provider=self.type, name=name) + playlist.is_editable = True + playlist.in_library = True + playlist.add_provider_id( + MediaItemProviderId( + item_id=playlist_item_id, + prov_type=self.type, + prov_id=self.id, + url=playlist_path, + ) + ) + playlist.owner = self._attr_name + return playlist + + async def exists(self, file_path: str) -> bool: + """Return bool is this FileSystem musicprovider has given file/dir.""" + if not file_path: + return False # guard + # ensure we have a full path and not relative + if self.config.path not in file_path: + file_path = os.path.join(self.config.path, file_path) + _exists = wrap(os.path.exists) + return await _exists(file_path) + + @asynccontextmanager + async def open_file(self, file_path: str, mode="rb") -> AsyncFileIO: + """Return (async) handle to given file.""" + # ensure we have a full path and not relative + if self.config.path not in file_path: + file_path = os.path.join(self.config.path, file_path) + # remote file locations should return a tempfile here ? + async with aiofiles.open(file_path, mode) as _file: + yield _file + + async def get_embedded_image(self, file_path) -> bytes | None: + """Return embedded image data.""" + if not TinyTag.is_supported(file_path): + return None + + # embedded image in music file + def _get_data(): + tags = TinyTag.get(file_path, image=True) + return tags.get_image() + + return await self.mass.loop.run_in_executor(None, _get_data) + + async def get_filepath( + self, media_type: MediaType, prov_item_id: str + ) -> str | None: + """Get full filepath on disk for item_id.""" + if prov_item_id is None: + return None # guard + # funky sql queries go here ;-) + table = f"{media_type.value}s" + query = ( + f"SELECT json_extract(json_each.value, '$.url') as url FROM {table}" + " ,json_each(provider_ids) WHERE" + f" json_extract(json_each.value, '$.prov_id') = '{self.id}'" + f" AND json_extract(json_each.value, '$.item_id') = '{prov_item_id}'" + ) + for db_row in await self.mass.database.get_rows_from_query(query): + file_path = db_row["url"] + # ensure we have a full path and not relative + if self.config.path not in file_path: + file_path = os.path.join(self.config.path, file_path) + return file_path + return None + + def _get_item_id(self, file_path: str) -> str: + """Create item id from filename.""" + return create_clean_string(file_path.replace(self.config.path, "")) diff --git a/music_assistant/music_providers/librespot/linux/librespot-aarch64 b/music_assistant/music_providers/librespot/linux/librespot-aarch64 new file mode 100755 index 00000000..5359098f Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-aarch64 differ diff --git a/music_assistant/music_providers/librespot/linux/librespot-arm b/music_assistant/music_providers/librespot/linux/librespot-arm new file mode 100755 index 00000000..5cd38c75 Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-arm differ diff --git a/music_assistant/music_providers/librespot/linux/librespot-armhf b/music_assistant/music_providers/librespot/linux/librespot-armhf new file mode 100755 index 00000000..18c2e05b Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-armhf differ diff --git a/music_assistant/music_providers/librespot/linux/librespot-armv7 b/music_assistant/music_providers/librespot/linux/librespot-armv7 new file mode 100755 index 00000000..0a792b2e Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-armv7 differ diff --git a/music_assistant/music_providers/librespot/linux/librespot-x86_64 b/music_assistant/music_providers/librespot/linux/librespot-x86_64 new file mode 100755 index 00000000..e025abdb Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-x86_64 differ diff --git a/music_assistant/music_providers/librespot/osx/librespot b/music_assistant/music_providers/librespot/osx/librespot new file mode 100755 index 00000000..c1b37543 Binary files /dev/null and b/music_assistant/music_providers/librespot/osx/librespot differ diff --git a/music_assistant/music_providers/librespot/windows/librespot.exe b/music_assistant/music_providers/librespot/windows/librespot.exe new file mode 100755 index 00000000..a973f4e1 Binary files /dev/null and b/music_assistant/music_providers/librespot/windows/librespot.exe differ diff --git a/music_assistant/music_providers/qobuz.py b/music_assistant/music_providers/qobuz.py new file mode 100644 index 00000000..ffb4c37b --- /dev/null +++ b/music_assistant/music_providers/qobuz.py @@ -0,0 +1,733 @@ +"""Qobuz musicprovider support for MusicAssistant.""" +from __future__ import annotations + +import datetime +import hashlib +import time +from json import JSONDecodeError +from typing import AsyncGenerator, List, Optional + +import aiohttp +from asyncio_throttle import Throttler + +from music_assistant.helpers.app_vars import ( # pylint: disable=no-name-in-module + app_var, +) +from music_assistant.helpers.audio import get_http_stream +from music_assistant.helpers.cache import use_cache +from music_assistant.helpers.util import parse_title_and_version, try_parse_int +from music_assistant.models.enums import ProviderType +from music_assistant.models.errors import LoginFailed, MediaNotFoundError +from music_assistant.models.media_items import ( + Album, + AlbumType, + Artist, + ContentType, + ImageType, + MediaItemImage, + MediaItemProviderId, + MediaItemType, + MediaQuality, + MediaType, + Playlist, + StreamDetails, + Track, +) +from music_assistant.models.music_provider import MusicProvider + + +class QobuzProvider(MusicProvider): + """Provider for the Qobux music service.""" + + _attr_type = ProviderType.QOBUZ + _attr_name = "Qobuz" + _attr_supported_mediatypes = [ + MediaType.ARTIST, + MediaType.ALBUM, + MediaType.TRACK, + MediaType.PLAYLIST, + ] + _user_auth_info = None + _throttler = Throttler(rate_limit=4, period=1) + + async def setup(self) -> bool: + """Handle async initialization of the provider.""" + if not self.config.enabled: + return False + if not self.config.username or not self.config.password: + raise LoginFailed("Invalid login credentials") + # try to get a token, raise if that fails + token = await self._auth_token() + if not token: + raise LoginFailed(f"Login failed for user {self.config.username}") + return True + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """ + Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. All types if None. + :param limit: Number of items to return in the search (per type). + """ + result = [] + params = {"query": search_query, "limit": limit} + if len(media_types) == 1: + # qobuz does not support multiple searchtypes, falls back to all if no type given + if media_types[0] == MediaType.ARTIST: + params["type"] = "artists" + if media_types[0] == MediaType.ALBUM: + params["type"] = "albums" + if media_types[0] == MediaType.TRACK: + params["type"] = "tracks" + if media_types[0] == MediaType.PLAYLIST: + params["type"] = "playlists" + if searchresult := await self._get_data("catalog/search", **params): + if "artists" in searchresult: + result += [ + await self._parse_artist(item) + for item in searchresult["artists"]["items"] + if (item and item["id"]) + ] + if "albums" in searchresult: + result += [ + await self._parse_album(item) + for item in searchresult["albums"]["items"] + if (item and item["id"]) + ] + if "tracks" in searchresult: + result += [ + await self._parse_track(item) + for item in searchresult["tracks"]["items"] + if (item and item["id"]) + ] + if "playlists" in searchresult: + result += [ + await self._parse_playlist(item) + for item in searchresult["playlists"]["items"] + if (item and item["id"]) + ] + return result + + async def get_library_artists(self) -> AsyncGenerator[Artist, None]: + """Retrieve all library artists from Qobuz.""" + endpoint = "favorite/getUserFavorites" + for item in await self._get_all_items(endpoint, key="artists", type="artists"): + if item and item["id"]: + yield await self._parse_artist(item) + + async def get_library_albums(self) -> AsyncGenerator[Album, None]: + """Retrieve all library albums from Qobuz.""" + endpoint = "favorite/getUserFavorites" + for item in await self._get_all_items(endpoint, key="albums", type="albums"): + if item and item["id"]: + yield await self._parse_album(item) + + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + """Retrieve library tracks from Qobuz.""" + endpoint = "favorite/getUserFavorites" + for item in await self._get_all_items(endpoint, key="tracks", type="tracks"): + if item and item["id"]: + yield await self._parse_track(item) + + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: + """Retrieve all library playlists from the provider.""" + endpoint = "playlist/getUserPlaylists" + for item in await self._get_all_items(endpoint, key="playlists"): + if item and item["id"]: + yield await self._parse_playlist(item) + + async def get_artist(self, prov_artist_id) -> Artist: + """Get full artist details by id.""" + params = {"artist_id": prov_artist_id} + artist_obj = await self._get_data("artist/get", **params) + return ( + await self._parse_artist(artist_obj) + if artist_obj and artist_obj["id"] + else None + ) + + async def get_album(self, prov_album_id) -> Album: + """Get full album details by id.""" + params = {"album_id": prov_album_id} + album_obj = await self._get_data("album/get", **params) + return ( + await self._parse_album(album_obj) + if album_obj and album_obj["id"] + else None + ) + + async def get_track(self, prov_track_id) -> Track: + """Get full track details by id.""" + params = {"track_id": prov_track_id} + track_obj = await self._get_data("track/get", **params) + return ( + await self._parse_track(track_obj) + if track_obj and track_obj["id"] + else None + ) + + async def get_playlist(self, prov_playlist_id) -> Playlist: + """Get full playlist details by id.""" + params = {"playlist_id": prov_playlist_id} + playlist_obj = await self._get_data("playlist/get", **params) + return ( + await self._parse_playlist(playlist_obj) + if playlist_obj and playlist_obj["id"] + else None + ) + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + """Get all album tracks for given album id.""" + params = {"album_id": prov_album_id} + return [ + await self._parse_track(item) + for item in await self._get_all_items("album/get", **params, key="tracks") + if (item and item["id"]) + ] + + async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: + """Get all playlist tracks for given playlist id.""" + playlist = await self.get_playlist(prov_playlist_id) + endpoint = "playlist/get" + return [ + await self._parse_track(item) + for item in await self._get_all_items( + endpoint, + key="tracks", + playlist_id=prov_playlist_id, + extra="tracks", + cache_checksum=playlist.metadata.checksum, + ) + if (item and item["id"]) + ] + + async def get_artist_albums(self, prov_artist_id) -> List[Album]: + """Get a list of albums for the given artist.""" + endpoint = "artist/get" + return [ + await self._parse_album(item) + for item in await self._get_all_items( + endpoint, key="albums", artist_id=prov_artist_id, extra="albums" + ) + if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id) + ] + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + """Get a list of most popular tracks for the given artist.""" + result = await self._get_data( + "artist/get", + artist_id=prov_artist_id, + extra="playlists", + offset=0, + limit=25, + ) + if result and result["playlists"]: + return [ + await self._parse_track(item) + for item in result["playlists"][0]["tracks"]["items"] + if (item and item["id"]) + ] + # fallback to search + artist = await self.get_artist(prov_artist_id) + searchresult = await self._get_data( + "catalog/search", query=artist.name, limit=25, type="tracks" + ) + return [ + await self._parse_track(item) + for item in searchresult["tracks"]["items"] + if ( + item + and item["id"] + and "performer" in item + and str(item["performer"]["id"]) == str(prov_artist_id) + ) + ] + + async def get_similar_artists(self, prov_artist_id): + """Get similar artists for given artist.""" + # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3 + + async def library_add(self, prov_item_id, media_type: MediaType): + """Add item to library.""" + result = None + if media_type == MediaType.ARTIST: + result = await self._get_data("favorite/create", artist_id=prov_item_id) + elif media_type == MediaType.ALBUM: + result = await self._get_data("favorite/create", album_ids=prov_item_id) + elif media_type == MediaType.TRACK: + result = await self._get_data("favorite/create", track_ids=prov_item_id) + elif media_type == MediaType.PLAYLIST: + result = await self._get_data( + "playlist/subscribe", playlist_id=prov_item_id + ) + return result + + async def library_remove(self, prov_item_id, media_type: MediaType): + """Remove item from library.""" + result = None + if media_type == MediaType.ARTIST: + result = await self._get_data("favorite/delete", artist_ids=prov_item_id) + elif media_type == MediaType.ALBUM: + result = await self._get_data("favorite/delete", album_ids=prov_item_id) + elif media_type == MediaType.TRACK: + result = await self._get_data("favorite/delete", track_ids=prov_item_id) + elif media_type == MediaType.PLAYLIST: + playlist = await self.get_playlist(prov_item_id) + if playlist.is_editable: + result = await self._get_data( + "playlist/delete", playlist_id=prov_item_id + ) + else: + result = await self._get_data( + "playlist/unsubscribe", playlist_id=prov_item_id + ) + return result + + async def add_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> None: + """Add track(s) to playlist.""" + return await self._get_data( + "playlist/addTracks", + playlist_id=prov_playlist_id, + track_ids=",".join(prov_track_ids), + playlist_track_ids=",".join(prov_track_ids), + ) + + async def remove_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> None: + """Remove track(s) from playlist.""" + playlist_track_ids = set() + for track in await self._get_all_items( + "playlist/get", key="tracks", playlist_id=prov_playlist_id, extra="tracks" + ): + if str(track["id"]) in prov_track_ids: + playlist_track_ids.add(str(track["playlist_track_id"])) + return await self._get_data( + "playlist/deleteTracks", + playlist_id=prov_playlist_id, + playlist_track_ids=",".join(playlist_track_ids), + ) + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Return the content details for the given track when it will be streamed.""" + streamdata = None + for format_id in [27, 7, 6, 5]: + # it seems that simply requesting for highest available quality does not work + # from time to time the api response is empty for this request ?! + result = await self._get_data( + "track/getFileUrl", + sign_request=True, + format_id=format_id, + track_id=item_id, + intent="stream", + skip_cache=True, + ) + if result and result.get("url"): + streamdata = result + break + if not streamdata: + raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}") + if streamdata["mime_type"] == "audio/mpeg": + content_type = ContentType.MPEG + elif streamdata["mime_type"] == "audio/flac": + content_type = ContentType.FLAC + else: + raise MediaNotFoundError(f"Unsupported mime type for {item_id}") + return StreamDetails( + item_id=str(item_id), + provider=self.type, + content_type=content_type, + duration=streamdata["duration"], + sample_rate=int(streamdata["sampling_rate"] * 1000), + bit_depth=streamdata["bit_depth"], + data=streamdata, # we need these details for reporting playback + expires=time.time() + 1800, # not sure about the real allowed value + ) + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + self.mass.create_task(self._report_playback_started(streamdetails)) + bytes_sent = 0 + try: + url = streamdetails.data["url"] + async for chunk in get_http_stream( + self.mass, url, streamdetails, seek_position + ): + yield chunk + bytes_sent += len(chunk) + finally: + if bytes_sent: + self.mass.create_task( + self._report_playback_stopped(streamdetails, bytes_sent) + ) + + async def _report_playback_started(self, streamdetails: StreamDetails) -> None: + """Report playback start to qobuz.""" + # TODO: need to figure out if the streamed track is purchased by user + # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx + # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}} + device_id = self._user_auth_info["user"]["device"]["id"] + credential_id = self._user_auth_info["user"]["credential"]["id"] + user_id = self._user_auth_info["user"]["id"] + format_id = streamdetails.data["format_id"] + timestamp = int(time.time()) + events = [ + { + "online": True, + "sample": False, + "intent": "stream", + "device_id": device_id, + "track_id": str(streamdetails.item_id), + "purchase": False, + "date": timestamp, + "credential_id": credential_id, + "user_id": user_id, + "local": False, + "format_id": format_id, + } + ] + await self._post_data("track/reportStreamingStart", data=events) + + async def _report_playback_stopped( + self, streamdetails: StreamDetails, bytes_sent: int + ) -> None: + """Report playback stop to qobuz.""" + user_id = self._user_auth_info["user"]["id"] + await self._get_data( + "/track/reportStreamingEnd", + user_id=user_id, + track_id=str(streamdetails.item_id), + duration=try_parse_int(streamdetails.seconds_streamed), + ) + + async def _parse_artist(self, artist_obj: dict): + """Parse qobuz artist object to generic layout.""" + artist = Artist( + item_id=str(artist_obj["id"]), provider=self.type, name=artist_obj["name"] + ) + artist.add_provider_id( + MediaItemProviderId( + item_id=str(artist_obj["id"]), + prov_type=self.type, + prov_id=self.id, + url=artist_obj.get( + "url", f'https://open.qobuz.com/artist/{artist_obj["id"]}' + ), + ) + ) + if img := self.__get_image(artist_obj): + artist.metadata.images = [MediaItemImage(ImageType.THUMB, img)] + if artist_obj.get("biography"): + artist.metadata.description = artist_obj["biography"].get("content") + return artist + + async def _parse_album(self, album_obj: dict, artist_obj: dict = None): + """Parse qobuz album object to generic layout.""" + if not artist_obj and "artist" not in album_obj: + # artist missing in album info, return full abum instead + return await self.get_album(album_obj["id"]) + name, version = parse_title_and_version( + album_obj["title"], album_obj.get("version") + ) + album = Album( + item_id=str(album_obj["id"]), provider=self.type, name=name, version=version + ) + if album_obj["maximum_sampling_rate"] > 192: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 + elif album_obj["maximum_sampling_rate"] > 96: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 + elif album_obj["maximum_sampling_rate"] > 48: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 + elif album_obj["maximum_bit_depth"] > 16: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 + elif album_obj.get("format_id", 0) == 5: + quality = MediaQuality.LOSSY_AAC + else: + quality = MediaQuality.FLAC_LOSSLESS + album.add_provider_id( + MediaItemProviderId( + item_id=str(album_obj["id"]), + prov_type=self.type, + prov_id=self.id, + quality=quality, + url=album_obj.get( + "url", f'https://open.qobuz.com/album/{album_obj["id"]}' + ), + details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit', + available=album_obj["streamable"] and album_obj["displayable"], + ) + ) + + album.artist = await self._parse_artist(artist_obj or album_obj["artist"]) + if ( + album_obj.get("product_type", "") == "single" + or album_obj.get("release_type", "") == "single" + ): + album.album_type = AlbumType.SINGLE + elif ( + album_obj.get("product_type", "") == "compilation" + or "Various" in album.artist.name + ): + album.album_type = AlbumType.COMPILATION + elif ( + album_obj.get("product_type", "") == "album" + or album_obj.get("release_type", "") == "album" + ): + album.album_type = AlbumType.ALBUM + if "genre" in album_obj: + album.metadata.genres = {album_obj["genre"]["name"]} + if img := self.__get_image(album_obj): + album.metadata.images = [MediaItemImage(ImageType.THUMB, img)] + if len(album_obj["upc"]) == 13: + # qobuz writes ean as upc ?! + album.upc = album_obj["upc"][1:] + else: + album.upc = album_obj["upc"] + if "label" in album_obj: + album.metadata.label = album_obj["label"]["name"] + if album_obj.get("released_at"): + album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year + if album_obj.get("copyright"): + album.metadata.copyright = album_obj["copyright"] + if album_obj.get("description"): + album.metadata.description = album_obj["description"] + return album + + async def _parse_track(self, track_obj: dict): + """Parse qobuz track object to generic layout.""" + name, version = parse_title_and_version( + track_obj["title"], track_obj.get("version") + ) + track = Track( + item_id=str(track_obj["id"]), + provider=self.type, + name=name, + version=version, + disc_number=track_obj["media_number"], + track_number=track_obj["track_number"], + duration=track_obj["duration"], + position=track_obj.get("position"), + ) + if track_obj.get("performer") and "Various " not in track_obj["performer"]: + artist = await self._parse_artist(track_obj["performer"]) + if artist: + track.artists.append(artist) + if not track.artists: + # try to grab artist from album + if ( + track_obj.get("album") + and track_obj["album"].get("artist") + and "Various " not in track_obj["album"]["artist"] + ): + artist = await self._parse_artist(track_obj["album"]["artist"]) + if artist: + track.artists.append(artist) + if not track.artists: + # last resort: parse from performers string + for performer_str in track_obj["performers"].split(" - "): + role = performer_str.split(", ")[1] + name = performer_str.split(", ")[0] + if "artist" in role.lower(): + artist = Artist(name, self.type, name) + track.artists.append(artist) + # TODO: fix grabbing composer from details + + if "album" in track_obj: + album = await self._parse_album(track_obj["album"]) + if album: + track.album = album + if track_obj.get("isrc"): + track.isrc = track_obj["isrc"] + if track_obj.get("performers"): + track.metadata.performers = { + x.strip() for x in track_obj["performers"].split("-") + } + if track_obj.get("copyright"): + track.metadata.copyright = track_obj["copyright"] + if track_obj.get("audio_info"): + track.metadata.replaygain = track_obj["audio_info"]["replaygain_track_gain"] + if track_obj.get("parental_warning"): + track.metadata.explicit = True + if img := self.__get_image(track_obj): + track.metadata.images = [MediaItemImage(ImageType.THUMB, img)] + # get track quality + if track_obj["maximum_sampling_rate"] > 192: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 + elif track_obj["maximum_sampling_rate"] > 96: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 + elif track_obj["maximum_sampling_rate"] > 48: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 + elif track_obj["maximum_bit_depth"] > 16: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 + elif track_obj.get("format_id", 0) == 5: + quality = MediaQuality.LOSSY_AAC + else: + quality = MediaQuality.FLAC_LOSSLESS + track.add_provider_id( + MediaItemProviderId( + item_id=str(track_obj["id"]), + prov_type=self.type, + prov_id=self.id, + quality=quality, + url=track_obj.get( + "url", f'https://open.qobuz.com/track/{track_obj["id"]}' + ), + details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit', + available=track_obj["streamable"] and track_obj["displayable"], + ) + ) + return track + + async def _parse_playlist(self, playlist_obj): + """Parse qobuz playlist object to generic layout.""" + playlist = Playlist( + item_id=str(playlist_obj["id"]), + provider=self.type, + name=playlist_obj["name"], + owner=playlist_obj["owner"]["name"], + ) + playlist.add_provider_id( + MediaItemProviderId( + item_id=str(playlist_obj["id"]), + prov_type=self.type, + prov_id=self.id, + url=playlist_obj.get( + "url", f'https://open.qobuz.com/playlist/{playlist_obj["id"]}' + ), + ) + ) + playlist.is_editable = ( + playlist_obj["owner"]["id"] == self._user_auth_info["user"]["id"] + or playlist_obj["is_collaborative"] + ) + if img := self.__get_image(playlist_obj): + playlist.metadata.images = [MediaItemImage(ImageType.THUMB, img)] + playlist.metadata.checksum = str(playlist_obj["updated_at"]) + return playlist + + async def _auth_token(self): + """Login to qobuz and store the token.""" + if self._user_auth_info: + return self._user_auth_info["user_auth_token"] + params = { + "username": self.config.username, + "password": self.config.password, + "device_manufacturer_id": "music_assistant", + } + details = await self._get_data("user/login", **params) + if details and "user" in details: + self._user_auth_info = details + self.logger.info( + "Succesfully logged in to Qobuz as %s", details["user"]["display_name"] + ) + self.mass.metadata.preferred_language = details["user"]["country_code"] + return details["user_auth_token"] + + @use_cache(3600 * 24) + async def _get_all_items(self, endpoint, key="tracks", **kwargs): + """Get all items from a paged list.""" + limit = 50 + offset = 0 + all_items = [] + while True: + kwargs["limit"] = limit + kwargs["offset"] = offset + result = await self._get_data(endpoint, skip_cache=True, **kwargs) + offset += limit + if not result: + break + if not result.get(key) or not result[key].get("items"): + break + for item in result[key]["items"]: + item["position"] = len(all_items) + 1 + all_items.append(item) + if len(result[key]["items"]) < limit: + break + return all_items + + @use_cache(3600 * 2) + async def _get_data(self, endpoint, sign_request=False, **kwargs): + """Get data from api.""" + url = f"http://www.qobuz.com/api.json/0.2/{endpoint}" + headers = {"X-App-Id": app_var(0)} + if endpoint != "user/login": + auth_token = await self._auth_token() + if not auth_token: + self.logger.debug("Not logged in") + return None + headers["X-User-Auth-Token"] = auth_token + if sign_request: + signing_data = "".join(endpoint.split("/")) + keys = list(kwargs.keys()) + keys.sort() + for key in keys: + signing_data += f"{key}{kwargs[key]}" + request_ts = str(time.time()) + request_sig = signing_data + request_ts + app_var(1) + request_sig = str(hashlib.md5(request_sig.encode()).hexdigest()) + kwargs["request_ts"] = request_ts + kwargs["request_sig"] = request_sig + kwargs["app_id"] = app_var(0) + kwargs["user_auth_token"] = await self._auth_token() + async with self._throttler: + async with self.mass.http_session.get( + url, headers=headers, params=kwargs, verify_ssl=False + ) as response: + try: + result = await response.json() + if "error" in result or ( + "status" in result and "error" in result["status"] + ): + self.logger.error("%s - %s", endpoint, result) + return None + except ( + aiohttp.ContentTypeError, + JSONDecodeError, + ) as err: + self.logger.error("%s - %s", endpoint, str(err)) + return None + return result + + async def _post_data(self, endpoint, params=None, data=None): + """Post data to api.""" + if not params: + params = {} + if not data: + data = {} + url = f"http://www.qobuz.com/api.json/0.2/{endpoint}" + params["app_id"] = app_var(0) + params["user_auth_token"] = await self._auth_token() + async with self.mass.http_session.post( + url, params=params, json=data, verify_ssl=False + ) as response: + result = await response.json() + if "error" in result or ( + "status" in result and "error" in result["status"] + ): + self.logger.error("%s - %s", endpoint, result) + return None + return result + + def __get_image(self, obj: dict) -> Optional[str]: + """Try to parse image from Qobuz media object.""" + if obj.get("image"): + for key in ["extralarge", "large", "medium", "small"]: + if obj["image"].get(key): + if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]: + continue + return obj["image"][key] + if obj.get("images300"): + # playlists seem to use this strange format + return obj["images300"][0] + if obj.get("album"): + return self.__get_image(obj["album"]) + if obj.get("artist"): + return self.__get_image(obj["artist"]) + return None diff --git a/music_assistant/music_providers/spotify.py b/music_assistant/music_providers/spotify.py new file mode 100644 index 00000000..56912c1e --- /dev/null +++ b/music_assistant/music_providers/spotify.py @@ -0,0 +1,694 @@ +"""Spotify musicprovider support for MusicAssistant.""" +from __future__ import annotations + +import asyncio +import json +import os +import platform +import time +from json.decoder import JSONDecodeError +from tempfile import gettempdir +from typing import AsyncGenerator, List, Optional + +import aiohttp +from asyncio_throttle import Throttler + +from music_assistant.helpers.app_vars import ( # noqa # pylint: disable=no-name-in-module + app_var, +) +from music_assistant.helpers.cache import use_cache +from music_assistant.helpers.process import AsyncProcess +from music_assistant.helpers.util import parse_title_and_version +from music_assistant.models.enums import ProviderType +from music_assistant.models.errors import LoginFailed, MediaNotFoundError +from music_assistant.models.media_items import ( + Album, + AlbumType, + Artist, + ContentType, + ImageType, + MediaItemImage, + MediaItemProviderId, + MediaItemType, + MediaQuality, + MediaType, + Playlist, + StreamDetails, + Track, +) +from music_assistant.models.music_provider import MusicProvider + +CACHE_DIR = gettempdir() + + +class SpotifyProvider(MusicProvider): + """Implementation of a Spotify MusicProvider.""" + + _attr_type = ProviderType.SPOTIFY + _attr_name = "Spotify" + _attr_supported_mediatypes = [ + MediaType.ARTIST, + MediaType.ALBUM, + MediaType.TRACK, + MediaType.PLAYLIST + # TODO: Return spotify radio + ] + _auth_token = None + _sp_user = None + _librespot_bin = None + _throttler = Throttler(rate_limit=4, period=1) + _cache_dir = CACHE_DIR + + async def setup(self) -> bool: + """Handle async initialization of the provider.""" + if not self.config.enabled: + return False + if not self.config.username or not self.config.password: + raise LoginFailed("Invalid login credentials") + # try to get a token, raise if that fails + self._cache_dir = os.path.join(CACHE_DIR, self.id) + token = await self.get_token() + if not token: + raise LoginFailed(f"Login failed for user {self.config.username}") + return True + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """ + Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. All types if None. + :param limit: Number of items to return in the search (per type). + """ + result = [] + searchtypes = [] + if MediaType.ARTIST in media_types: + searchtypes.append("artist") + if MediaType.ALBUM in media_types: + searchtypes.append("album") + if MediaType.TRACK in media_types: + searchtypes.append("track") + if MediaType.PLAYLIST in media_types: + searchtypes.append("playlist") + searchtype = ",".join(searchtypes) + if searchresult := await self._get_data( + "search", q=search_query, type=searchtype, limit=limit + ): + if "artists" in searchresult: + result += [ + await self._parse_artist(item) + for item in searchresult["artists"]["items"] + if (item and item["id"]) + ] + if "albums" in searchresult: + result += [ + await self._parse_album(item) + for item in searchresult["albums"]["items"] + if (item and item["id"]) + ] + if "tracks" in searchresult: + result += [ + await self._parse_track(item) + for item in searchresult["tracks"]["items"] + if (item and item["id"]) + ] + if "playlists" in searchresult: + result += [ + await self._parse_playlist(item) + for item in searchresult["playlists"]["items"] + if (item and item["id"]) + ] + return result + + async def get_library_artists(self) -> AsyncGenerator[Artist, None]: + """Retrieve library artists from spotify.""" + endpoint = "me/following" + while True: + spotify_artists = await self._get_data( + endpoint, type="artist", limit=50, skip_cache=True + ) + for item in spotify_artists["artists"]["items"]: + if item and item["id"]: + yield await self._parse_artist(item) + if spotify_artists["artists"]["next"]: + endpoint = spotify_artists["artists"]["next"] + endpoint = endpoint.replace("https://api.spotify.com/v1/", "") + else: + break + + async def get_library_albums(self) -> AsyncGenerator[Album, None]: + """Retrieve library albums from the provider.""" + for item in await self._get_all_items("me/albums", skip_cache=True): + if item["album"] and item["album"]["id"]: + yield await self._parse_album(item["album"]) + + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + """Retrieve library tracks from the provider.""" + for item in await self._get_all_items("me/tracks", skip_cache=True): + if item and item["track"]["id"]: + yield await self._parse_track(item["track"]) + + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: + """Retrieve playlists from the provider.""" + for item in await self._get_all_items("me/playlists", skip_cache=True): + if item and item["id"]: + yield await self._parse_playlist(item) + + async def get_artist(self, prov_artist_id) -> Artist: + """Get full artist details by id.""" + artist_obj = await self._get_data(f"artists/{prov_artist_id}") + return await self._parse_artist(artist_obj) if artist_obj else None + + async def get_album(self, prov_album_id) -> Album: + """Get full album details by id.""" + album_obj = await self._get_data(f"albums/{prov_album_id}") + return await self._parse_album(album_obj) if album_obj else None + + async def get_track(self, prov_track_id) -> Track: + """Get full track details by id.""" + track_obj = await self._get_data(f"tracks/{prov_track_id}") + return await self._parse_track(track_obj) if track_obj else None + + async def get_playlist(self, prov_playlist_id) -> Playlist: + """Get full playlist details by id.""" + playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}") + return await self._parse_playlist(playlist_obj) if playlist_obj else None + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + """Get all album tracks for given album id.""" + return [ + await self._parse_track(item) + for item in await self._get_all_items(f"albums/{prov_album_id}/tracks") + if (item and item["id"]) + ] + + async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: + """Get all playlist tracks for given playlist id.""" + playlist = await self.get_playlist(prov_playlist_id) + return [ + await self._parse_track(item["track"]) + for item in await self._get_all_items( + f"playlists/{prov_playlist_id}/tracks", + cache_checksum=playlist.metadata.checksum, + ) + if (item and item["track"] and item["track"]["id"]) + ] + + async def get_artist_albums(self, prov_artist_id) -> List[Album]: + """Get a list of all albums for the given artist.""" + return [ + await self._parse_album(item) + for item in await self._get_all_items( + f"artists/{prov_artist_id}/albums?include_groups=album,single,compilation" + ) + if (item and item["id"]) + ] + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + """Get a list of 10 most popular tracks for the given artist.""" + artist = await self.get_artist(prov_artist_id) + endpoint = f"artists/{prov_artist_id}/top-tracks" + items = await self._get_data(endpoint) + return [ + await self._parse_track(item, artist=artist) + for item in items["tracks"] + if (item and item["id"]) + ] + + async def library_add(self, prov_item_id, media_type: MediaType): + """Add item to library.""" + result = False + if media_type == MediaType.ARTIST: + result = await self._put_data( + "me/following", {"ids": prov_item_id, "type": "artist"} + ) + elif media_type == MediaType.ALBUM: + result = await self._put_data("me/albums", {"ids": prov_item_id}) + elif media_type == MediaType.TRACK: + result = await self._put_data("me/tracks", {"ids": prov_item_id}) + elif media_type == MediaType.PLAYLIST: + result = await self._put_data( + f"playlists/{prov_item_id}/followers", data={"public": False} + ) + return result + + async def library_remove(self, prov_item_id, media_type: MediaType): + """Remove item from library.""" + result = False + if media_type == MediaType.ARTIST: + result = await self._delete_data( + "me/following", {"ids": prov_item_id, "type": "artist"} + ) + elif media_type == MediaType.ALBUM: + result = await self._delete_data("me/albums", {"ids": prov_item_id}) + elif media_type == MediaType.TRACK: + result = await self._delete_data("me/tracks", {"ids": prov_item_id}) + elif media_type == MediaType.PLAYLIST: + result = await self._delete_data(f"playlists/{prov_item_id}/followers") + return result + + async def add_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ): + """Add track(s) to playlist.""" + track_uris = [] + for track_id in prov_track_ids: + track_uris.append(f"spotify:track:{track_id}") + data = {"uris": track_uris} + return await self._post_data(f"playlists/{prov_playlist_id}/tracks", data=data) + + async def remove_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> None: + """Remove track(s) from playlist.""" + track_uris = [] + for track_id in prov_track_ids: + track_uris.append({"uri": f"spotify:track:{track_id}"}) + data = {"tracks": track_uris} + return await self._delete_data( + f"playlists/{prov_playlist_id}/tracks", data=data + ) + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Return the content details for the given track when it will be streamed.""" + # make sure a valid track is requested. + track = await self.get_track(item_id) + if not track: + raise MediaNotFoundError(f"track {item_id} not found") + # make sure that the token is still valid by just requesting it + await self.get_token() + return StreamDetails( + item_id=track.item_id, + provider=self.type, + content_type=ContentType.OGG, + duration=track.duration, + ) + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + # make sure that the token is still valid by just requesting it + await self.get_token() + librespot = await self.get_librespot_binary() + args = [ + librespot, + "-c", + self._cache_dir, + "--pass-through", + "-b", + "320", + "--single-track", + f"spotify://track:{streamdetails.item_id}", + ] + if seek_position: + args += ["--start-position", str(int(seek_position))] + async with AsyncProcess(args) as librespot_proc: + async for chunk in librespot_proc.iterate_chunks(): + yield chunk + + async def _parse_artist(self, artist_obj): + """Parse spotify artist object to generic layout.""" + artist = Artist( + item_id=artist_obj["id"], provider=self.type, name=artist_obj["name"] + ) + artist.add_provider_id( + MediaItemProviderId( + item_id=artist_obj["id"], + prov_type=self.type, + prov_id=self.id, + url=artist_obj["external_urls"]["spotify"], + ) + ) + if "genres" in artist_obj: + artist.metadata.genres = set(artist_obj["genres"]) + if artist_obj.get("images"): + for img in artist_obj["images"]: + img_url = img["url"] + if "2a96cbd8b46e442fc41c2b86b821562f" not in img_url: + artist.metadata.images = [MediaItemImage(ImageType.THUMB, img_url)] + break + return artist + + async def _parse_album(self, album_obj: dict): + """Parse spotify album object to generic layout.""" + name, version = parse_title_and_version(album_obj["name"]) + album = Album( + item_id=album_obj["id"], provider=self.type, name=name, version=version + ) + for artist_obj in album_obj["artists"]: + album.artists.append(await self._parse_artist(artist_obj)) + if album_obj["album_type"] == "single": + album.album_type = AlbumType.SINGLE + elif album_obj["album_type"] == "compilation": + album.album_type = AlbumType.COMPILATION + elif album_obj["album_type"] == "album": + album.album_type = AlbumType.ALBUM + if "genres" in album_obj: + album.metadata.genre = set(album_obj["genres"]) + if album_obj.get("images"): + album.metadata.images = [ + MediaItemImage(ImageType.THUMB, album_obj["images"][0]["url"]) + ] + if "external_ids" in album_obj and album_obj["external_ids"].get("upc"): + album.upc = album_obj["external_ids"]["upc"] + if "label" in album_obj: + album.metadata.label = album_obj["label"] + if album_obj.get("release_date"): + album.year = int(album_obj["release_date"].split("-")[0]) + if album_obj.get("copyrights"): + album.metadata.copyright = album_obj["copyrights"][0]["text"] + if album_obj.get("explicit"): + album.metadata.explicit = album_obj["explicit"] + album.add_provider_id( + MediaItemProviderId( + item_id=album_obj["id"], + prov_type=self.type, + prov_id=self.id, + quality=MediaQuality.LOSSY_OGG, + url=album_obj["external_urls"]["spotify"], + ) + ) + return album + + async def _parse_track(self, track_obj, artist=None): + """Parse spotify track object to generic layout.""" + name, version = parse_title_and_version(track_obj["name"]) + track = Track( + item_id=track_obj["id"], + provider=self.type, + name=name, + version=version, + duration=track_obj["duration_ms"] / 1000, + disc_number=track_obj["disc_number"], + track_number=track_obj["track_number"], + position=track_obj.get("position"), + ) + if artist: + track.artists.append(artist) + for track_artist in track_obj.get("artists", []): + artist = await self._parse_artist(track_artist) + if artist and artist.item_id not in {x.item_id for x in track.artists}: + track.artists.append(artist) + + track.metadata.explicit = track_obj["explicit"] + if "preview_url" in track_obj: + track.metadata.preview = track_obj["preview_url"] + if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]: + track.isrc = track_obj["external_ids"]["isrc"] + if "album" in track_obj: + track.album = await self._parse_album(track_obj["album"]) + if track_obj["album"].get("images"): + track.metadata.images = [ + MediaItemImage( + ImageType.THUMB, track_obj["album"]["images"][0]["url"] + ) + ] + if track_obj.get("copyright"): + track.metadata.copyright = track_obj["copyright"] + if track_obj.get("explicit"): + track.metadata.explicit = True + if track_obj.get("popularity"): + track.metadata.popularity = track_obj["popularity"] + track.add_provider_id( + MediaItemProviderId( + item_id=track_obj["id"], + prov_type=self.type, + prov_id=self.id, + quality=MediaQuality.LOSSY_OGG, + url=track_obj["external_urls"]["spotify"], + available=not track_obj["is_local"] and track_obj["is_playable"], + ) + ) + return track + + async def _parse_playlist(self, playlist_obj): + """Parse spotify playlist object to generic layout.""" + playlist = Playlist( + item_id=playlist_obj["id"], + provider=self.type, + name=playlist_obj["name"], + owner=playlist_obj["owner"]["display_name"], + ) + playlist.add_provider_id( + MediaItemProviderId( + item_id=playlist_obj["id"], + prov_type=self.type, + prov_id=self.id, + url=playlist_obj["external_urls"]["spotify"], + ) + ) + playlist.is_editable = ( + playlist_obj["owner"]["id"] == self._sp_user["id"] + or playlist_obj["collaborative"] + ) + if playlist_obj.get("images"): + playlist.metadata.images = [ + MediaItemImage(ImageType.THUMB, playlist_obj["images"][0]["url"]) + ] + playlist.metadata.checksum = str(playlist_obj["snapshot_id"]) + return playlist + + async def get_token(self): + """Get auth token on spotify.""" + # return existing token if we have one in memory + if ( + self._auth_token + and os.path.isdir(self._cache_dir) + and (self._auth_token["expiresAt"] > int(time.time()) + 20) + ): + return self._auth_token + tokeninfo = {} + if not self.config.username or not self.config.password: + return tokeninfo + # retrieve token with librespot + retries = 0 + while retries < 4: + try: + tokeninfo = await asyncio.wait_for(self._get_token(), 5) + if tokeninfo: + break + retries += 1 + await asyncio.sleep(2) + except TimeoutError: + pass + if tokeninfo: + self._auth_token = tokeninfo + self._sp_user = await self._get_data("me") + self.mass.metadata.preferred_language = self._sp_user["country"] + self.logger.info( + "Succesfully logged in to Spotify as %s", self._sp_user["id"] + ) + self._auth_token = tokeninfo + else: + self.logger.error("Login failed for user %s", self.config.username) + return tokeninfo + + async def _get_token(self): + """Get spotify auth token with librespot bin.""" + # authorize with username and password (NOTE: this can also be Spotify Connect) + args = [ + await self.get_librespot_binary(), + "-O", + "-c", + self._cache_dir, + "-a", + "-u", + self.config.username, + "-p", + self.config.password, + ] + librespot = await asyncio.create_subprocess_exec(*args) + await librespot.wait() + # get token with (authorized) librespot + scopes = [ + "user-read-playback-state", + "user-read-currently-playing", + "user-modify-playback-state", + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + "user-follow-modify", + "user-follow-read", + "user-library-read", + "user-library-modify", + "user-read-private", + "user-read-email", + "user-read-birthdate", + "user-top-read", + ] + scope = ",".join(scopes) + args = [ + await self.get_librespot_binary(), + "-O", + "-t", + "--client-id", + app_var(2), + "--scope", + scope, + "-c", + self._cache_dir, + ] + librespot = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) + stdout, _ = await librespot.communicate() + try: + result = json.loads(stdout) + except JSONDecodeError: + self.logger.warning( + "Error while retrieving Spotify token, details: %s", stdout + ) + return None + # transform token info to spotipy compatible format + if result and "accessToken" in result: + tokeninfo = result + tokeninfo["expiresAt"] = tokeninfo["expiresIn"] + int(time.time()) + return tokeninfo + return None + + @use_cache(3600 * 24) + async def _get_all_items(self, endpoint, key="items", **kwargs) -> List[dict]: + """Get all items from a paged list.""" + limit = 50 + offset = 0 + all_items = [] + while True: + kwargs["limit"] = limit + kwargs["offset"] = offset + result = await self._get_data(endpoint, skip_cache=True, **kwargs) + offset += limit + if not result or key not in result or not result[key]: + break + for item in result[key]: + item["position"] = len(all_items) + 1 + all_items.append(item) + if len(result[key]) < limit: + break + return all_items + + @use_cache(3600 * 2) + async def _get_data(self, endpoint, **kwargs): + """Get data from api.""" + url = f"https://api.spotify.com/v1/{endpoint}" + kwargs["market"] = "from_token" + kwargs["country"] = "from_token" + token = await self.get_token() + if not token: + return None + headers = {"Authorization": f'Bearer {token["accessToken"]}'} + async with self._throttler: + async with self.mass.http_session.get( + url, headers=headers, params=kwargs, verify_ssl=False + ) as response: + try: + result = await response.json() + if "error" in result or ( + "status" in result and "error" in result["status"] + ): + self.logger.error("%s - %s", endpoint, result) + return None + except ( + aiohttp.ContentTypeError, + JSONDecodeError, + ) as err: + self.logger.error("%s - %s", endpoint, str(err)) + return None + return result + + async def _delete_data(self, endpoint, data=None, **kwargs): + """Delete data from api.""" + url = f"https://api.spotify.com/v1/{endpoint}" + token = await self.get_token() + if not token: + return None + headers = {"Authorization": f'Bearer {token["accessToken"]}'} + async with self.mass.http_session.delete( + url, headers=headers, params=kwargs, json=data, verify_ssl=False + ) as response: + return await response.text() + + async def _put_data(self, endpoint, data=None, **kwargs): + """Put data on api.""" + url = f"https://api.spotify.com/v1/{endpoint}" + token = await self.get_token() + if not token: + return None + headers = {"Authorization": f'Bearer {token["accessToken"]}'} + async with self.mass.http_session.put( + url, headers=headers, params=kwargs, json=data, verify_ssl=False + ) as response: + return await response.text() + + async def _post_data(self, endpoint, data=None, **kwargs): + """Post data on api.""" + url = f"https://api.spotify.com/v1/{endpoint}" + token = await self.get_token() + if not token: + return None + headers = {"Authorization": f'Bearer {token["accessToken"]}'} + async with self.mass.http_session.post( + url, headers=headers, params=kwargs, json=data, verify_ssl=False + ) as response: + return await response.text() + + async def get_librespot_binary(self): + """Find the correct librespot binary belonging to the platform.""" + if self._librespot_bin is not None: + return self._librespot_bin + + async def check_librespot(librespot_path: str) -> str | None: + try: + librespot = await asyncio.create_subprocess_exec( + *[librespot_path, "-V"], stdout=asyncio.subprocess.PIPE + ) + stdout, _ = await librespot.communicate() + if librespot.returncode == 0 and b"librespot" in stdout: + self._librespot_bin = librespot_path + return librespot_path + except OSError: + return None + + base_path = os.path.join(os.path.dirname(__file__), "librespot") + if platform.system() == "Windows": + if librespot := await check_librespot( + os.path.join(base_path, "windows", "librespot.exe") + ): + return librespot + if platform.system() == "Darwin": + # macos binary is x86_64 intel + if librespot := await check_librespot( + os.path.join(base_path, "osx", "librespot") + ): + return librespot + + if platform.system() == "Linux": + architecture = platform.machine() + if architecture in ["AMD64", "x86_64"]: + # generic linux x86_64 binary + if librespot := await check_librespot( + os.path.join( + base_path, + "linux", + "librespot-x86_64", + ) + ): + return librespot + + # arm architecture... try all options one by one... + for arch in ["aarch64", "armv7", "armhf", "arm"]: + if librespot := await check_librespot( + os.path.join( + base_path, + "linux", + f"librespot-{arch}", + ) + ): + return librespot + + raise RuntimeError( + f"Unable to locate Libespot for platform {platform.system()}" + ) diff --git a/music_assistant/music_providers/tunein.py b/music_assistant/music_providers/tunein.py new file mode 100644 index 00000000..239896d4 --- /dev/null +++ b/music_assistant/music_providers/tunein.py @@ -0,0 +1,194 @@ +"""Tune-In musicprovider support for MusicAssistant.""" +from __future__ import annotations + +from typing import AsyncGenerator, List, Optional + +from asyncio_throttle import Throttler + +from music_assistant.helpers.audio import get_radio_stream +from music_assistant.helpers.cache import use_cache +from music_assistant.helpers.util import create_clean_string +from music_assistant.models.enums import ProviderType +from music_assistant.models.errors import LoginFailed, MediaNotFoundError +from music_assistant.models.media_items import ( + ContentType, + ImageType, + MediaItemImage, + MediaItemProviderId, + MediaItemType, + MediaQuality, + MediaType, + Radio, + StreamDetails, +) +from music_assistant.models.music_provider import MusicProvider + + +class TuneInProvider(MusicProvider): + """Provider implementation for Tune In.""" + + _attr_type = ProviderType.TUNEIN + _attr_name = "Tune-in Radio" + _attr_supported_mediatypes = [MediaType.RADIO] + _throttler = Throttler(rate_limit=1, period=1) + + async def setup(self) -> bool: + """Handle async initialization of the provider.""" + if not self.config.enabled: + return False + if not self.config.username: + raise LoginFailed("Username is invalid") + return True + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """ + Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. All types if None. + :param limit: Number of items to return in the search (per type). + """ + # pylint: disable=no-self-use + # TODO: search for radio stations + return [] + + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + + async def parse_items( + items: List[dict], folder: str = None + ) -> AsyncGenerator[Radio, None]: + for item in items: + item_type = item.get("type", "") + if item_type == "audio": + if "preset_id" not in item: + continue + # each radio station can have multiple streams add each one as different quality + stream_info = await self.__get_data( + "Tune.ashx", id=item["preset_id"] + ) + for stream in stream_info["body"]: + yield await self._parse_radio(item, stream, folder) + elif item_type == "link": + # stations are in sublevel (new style) + if sublevel := await self.__get_data(item["URL"], render="json"): + async for subitem in parse_items( + sublevel["body"], item["text"] + ): + yield subitem + elif item.get("children"): + # stations are in sublevel (old style ?) + async for subitem in parse_items(item["children"], item["text"]): + yield subitem + + data = await self.__get_data("Browse.ashx", c="presets") + if data and "body" in data: + async for item in parse_items(data["body"]): + yield item + + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get radio station details.""" + prov_radio_id, media_type = prov_radio_id.split("--", 1) + params = {"c": "composite", "detail": "listing", "id": prov_radio_id} + result = await self.__get_data("Describe.ashx", **params) + if result and result.get("body") and result["body"][0].get("children"): + item = result["body"][0]["children"][0] + stream_info = await self.__get_data("Tune.ashx", id=prov_radio_id) + for stream in stream_info["body"]: + if stream["media_type"] != media_type: + continue + return await self._parse_radio(item, stream) + return None + + async def _parse_radio( + self, details: dict, stream: dict, folder: Optional[str] = None + ) -> Radio: + """Parse Radio object from json obj returned from api.""" + if "name" in details: + name = details["name"] + else: + # parse name from text attr + name = details["text"] + if " | " in name: + name = name.split(" | ")[1] + name = name.split(" (")[0] + item_id = f'{details["preset_id"]}--{stream["media_type"]}' + radio = Radio(item_id=item_id, provider=self.type, name=name) + if stream["media_type"] == "aac": + quality = MediaQuality.LOSSY_AAC + elif stream["media_type"] == "ogg": + quality = MediaQuality.LOSSY_OGG + else: + quality = MediaQuality.LOSSY_MP3 + radio.add_provider_id( + MediaItemProviderId( + item_id=item_id, + prov_type=self.type, + prov_id=self.id, + quality=quality, + details=stream["url"], + ) + ) + # preset number is used for sorting (not present at stream time) + preset_number = details.get("preset_number") + if preset_number and folder: + radio.sort_name = f'{folder}-{details["preset_number"]}' + elif preset_number: + radio.sort_name = details["preset_number"] + radio.sort_name += create_clean_string(name) + if "text" in details: + radio.metadata.description = details["text"] + # images + if img := details.get("image"): + radio.metadata.images = [MediaItemImage(ImageType.THUMB, img)] + if img := details.get("logo"): + radio.metadata.images = [MediaItemImage(ImageType.LOGO, img)] + return radio + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Get streamdetails for a radio station.""" + item_id, media_type = item_id.split("--", 1) + stream_info = await self.__get_data("Tune.ashx", id=item_id) + for stream in stream_info["body"]: + if stream["media_type"] == media_type: + return StreamDetails( + provider=self.type, + item_id=item_id, + content_type=ContentType(stream["media_type"]), + media_type=MediaType.RADIO, + data=stream, + ) + raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}") + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + async for chunk in get_radio_stream( + self.mass, streamdetails.data["url"], streamdetails + ): + yield chunk + + @use_cache(3600 * 2) + async def __get_data(self, endpoint: str, **kwargs): + """Get data from api.""" + if endpoint.startswith("http"): + url = endpoint + else: + url = f"https://opml.radiotime.com/{endpoint}" + kwargs["formats"] = "ogg,aac,wma,mp3" + kwargs["username"] = self.config.username + kwargs["partnerId"] = "1" + kwargs["render"] = "json" + async with self._throttler: + async with self.mass.http_session.get( + url, params=kwargs, verify_ssl=False + ) as response: + result = await response.json() + if not result or "error" in result: + self.logger.error(url) + self.logger.error(kwargs) + result = None + return result diff --git a/music_assistant/music_providers/url.py b/music_assistant/music_providers/url.py new file mode 100644 index 00000000..1e82483f --- /dev/null +++ b/music_assistant/music_providers/url.py @@ -0,0 +1,74 @@ +"""Basic provider allowing for external URL's to be streamed.""" +from __future__ import annotations + +import os +from typing import AsyncGenerator, List, Optional + +from music_assistant.helpers.audio import ( + get_file_stream, + get_http_stream, + get_radio_stream, +) +from music_assistant.models.config import MusicProviderConfig +from music_assistant.models.enums import ContentType, MediaType, ProviderType +from music_assistant.models.media_items import MediaItemType, StreamDetails +from music_assistant.models.music_provider import MusicProvider + +PROVIDER_CONFIG = MusicProviderConfig(ProviderType.URL) + + +class URLProvider(MusicProvider): + """Music Provider for manual URL's/files added to the queue.""" + + _attr_name: str = "URL" + _attr_type: ProviderType = ProviderType.URL + _attr_available: bool = True + _attr_supported_mediatypes: List[MediaType] = [] + + async def setup(self) -> bool: + """ + Handle async initialization of the provider. + + Called when provider is registered. + """ + return True + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """Perform search on musicprovider.""" + return [] + + async def get_stream_details(self, item_id: str) -> StreamDetails | None: + """Get streamdetails for a track/radio.""" + url = item_id + return StreamDetails( + provider=ProviderType.URL, + item_id=item_id, + content_type=ContentType.try_parse(url), + media_type=MediaType.URL, + data=url, + ) + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + if streamdetails.media_type == MediaType.RADIO: + # radio stream url + async for chunk in get_radio_stream( + self.mass, streamdetails.data, streamdetails + ): + yield chunk + elif os.path.isfile(streamdetails.data): + # local file + async for chunk in get_file_stream( + self.mass, streamdetails.data, streamdetails, seek_position + ): + yield chunk + else: + # regular stream url (without icy meta and reconnect) + async for chunk in get_http_stream( + self.mass, streamdetails.data, streamdetails, seek_position + ): + yield chunk