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
MediaType,
Track,
)
-from music_assistant.models.provider import MusicProvider
+from music_assistant.models.music_provider import MusicProvider
class AlbumsController(MediaControllerBase[Album]):
MediaType,
Track,
)
-from music_assistant.models.provider import MusicProvider
+from music_assistant.models.music_provider import MusicProvider
class ArtistsController(MediaControllerBase[Artist]):
+++ /dev/null
-"""Package with Music Providers."""
+++ /dev/null
-"""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 <artist>/<album>.
- 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, ""))
+++ /dev/null
-"""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
+++ /dev/null
-"""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()}"
- )
+++ /dev/null
-"""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
+++ /dev/null
-"""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
--- /dev/null
+"""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
+++ /dev/null
-"""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
--- /dev/null
+"""Package with Music Providers."""
--- /dev/null
+"""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 <artist>/<album>.
+ 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, ""))
--- /dev/null
+"""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
--- /dev/null
+"""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()}"
+ )
--- /dev/null
+"""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
--- /dev/null
+"""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