+++ /dev/null
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
-
- {
- "name": "Python: Module",
- "type": "python",
- "request": "launch",
- "module": "music_assistant",
- "args": ["--debug"]
- },
- {
- "name": "Python: Huidige bestand",
- "type": "python",
- "request": "launch",
- "program": "${file}",
- "console": "integratedTerminal",
- "args": ["--debug"]
- },
- {
- "name": "Python: Attach using Process Id",
- "type": "python",
- "request": "attach",
- "processId": "${command:pickProcess}"
- }
- ]
-}
\ No newline at end of file
--- /dev/null
+"""Extended example/script to run Music Assistant with all bells and whistles."""
+import argparse
+import asyncio
+import logging
+import os
+from sys import path
+
+from aiorun import run
+
+# pylint: disable=wrong-import-position
+from music_assistant.models.player import Player, PlayerState
+
+path.insert(1, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from music_assistant.mass import MusicAssistant
+from music_assistant.providers.spotify import SpotifyProvider
+from music_assistant.providers.qobuz import QobuzProvider
+from music_assistant.providers.tunein import TuneInProvider
+from music_assistant.providers.filesystem import FileSystemProvider
+
+parser = argparse.ArgumentParser(description="MusicAssistant")
+parser.add_argument(
+ "--spotify-username",
+ required=False,
+ help="Spotify username",
+)
+parser.add_argument(
+ "--spotify-password",
+ required=False,
+ help="Spotify password.",
+)
+parser.add_argument(
+ "--qobuz-username",
+ required=False,
+ help="Qobuz username",
+)
+parser.add_argument(
+ "--qobuz-password",
+ required=False,
+ help="Qobuz password.",
+)
+parser.add_argument(
+ "--tunein-username",
+ required=False,
+ help="Tunein username",
+)
+parser.add_argument(
+ "--musicdir",
+ required=False,
+ help="Directory on disk for local music library",
+)
+parser.add_argument(
+ "--playlistdir",
+ required=False,
+ help="Directory on disk for local (m3u) playlists",
+)
+parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Enable verbose debug logging",
+)
+args = parser.parse_args()
+
+
+# setup logger
+logging.basicConfig(
+ level=logging.DEBUG if args.debug else logging.INFO,
+ format="%(asctime)-15s %(levelname)-5s %(name)s -- %(message)s",
+)
+# silence some loggers
+logging.getLogger("aiorun").setLevel(logging.WARNING)
+logging.getLogger("asyncio").setLevel(logging.INFO)
+logging.getLogger("aiosqlite").setLevel(logging.WARNING)
+logging.getLogger("databases").setLevel(logging.WARNING)
+
+
+# default database based on sqlite
+data_dir = os.getenv("APPDATA") if os.name == "nt" else os.path.expanduser("~")
+data_dir = os.path.join(data_dir, ".musicassistant")
+if not os.path.isdir(data_dir):
+ os.makedirs(data_dir)
+db_file = os.path.join(data_dir, "music_assistant.db")
+
+mass = MusicAssistant(f"sqlite:///{db_file}")
+
+
+providers = []
+if args.spotify_username and args.spotify_password:
+ providers.append(SpotifyProvider(args.spotify_username, args.spotify_password))
+if args.qobuz_username and args.qobuz_password:
+ providers.append(QobuzProvider(args.qobuz_username, args.qobuz_password))
+if args.tunein_username:
+ providers.append(TuneInProvider(args.tunein_username))
+if args.musicdir:
+ providers.append(FileSystemProvider(args.musicdir, args.playlistdir))
+
+class TestPlayer(Player):
+ def __init__(self):
+ self.player_id = "test"
+ self.is_group = False
+ self._attr_name = "Test player"
+ self._attr_powered = False
+ self._attr_elapsed_time = 0
+ self._attr_current_url = None
+ self._attr_state = PlayerState.IDLE
+ self._attr_available = True
+ self._attr_volume_level = 100
+
+ async def play_url(self, url: str) -> None:
+ """Play the specified url on the player."""
+ print("play uri: %s" % url)
+ self._attr_current_url = url
+ self.update_state()
+
+ async def stop(self) -> None:
+ """Send STOP command to player."""
+ print("STOP CALLED")
+ self._attr_state = PlayerState.IDLE
+ self._attr_current_url = None
+ self._attr_elapsed_time = 0
+ self.update_state()
+
+ async def play(self) -> None:
+ """Send PLAY/UNPAUSE command to player."""
+ print("PLAY CALLED")
+ self._attr_state = PlayerState.PLAYING
+ self._attr_elapsed_time = 1
+ self.update_state()
+
+ async def pause(self) -> None:
+ """Send PAUSE command to player."""
+ print("PAUSE CALLED")
+ self._attr_state = PlayerState.PAUSED
+ self.update_state()
+
+ async def power(self, powered: bool) -> None:
+ """Send POWER command to player."""
+ print("POWER CALLED - %s" % powered)
+ self._attr_powered = powered
+ self._attr_current_url = None
+ self.update_state()
+
+ async def volume_set(self, volume_level: int) -> None:
+ """Send volume level (0..100) command to player."""
+ print("VOLUME SET CALLED - %s" % volume_level)
+ self._attr_volume_level = volume_level
+ self.update_state()
+
+
+def main():
+ """Handle main execution."""
+
+ async def async_main():
+ """Async main routine."""
+ asyncio.get_event_loop().set_debug(args.debug)
+
+ await mass.setup()
+ # register music provider(s)
+ for prov in providers:
+ await mass.music.register_provider(prov)
+ # get some data
+ artists = await mass.music.artists.library()
+ print(f"Got {len(artists)} artists in library")
+ albums = await mass.music.albums.library()
+ print(f"Got {len(albums)} albums in library")
+ tracks = await mass.music.tracks.library()
+ print(f"Got {len(tracks)} tracks in library")
+ radios = await mass.music.radio.library()
+ print(f"Got {len(radios)} radio stations in library")
+ playlists = await mass.music.playlists.library()
+ print(f"Got {len(playlists)} playlists in library")
+ # register a player
+ test_player = TestPlayer()
+ await mass.players.register_player(test_player)
+ # try to play some playlist
+ await test_player.queue.set_crossfade_duration(10)
+ await test_player.queue.set_shuffle_enabled(True)
+ if len(playlists) > 0:
+ await test_player.queue.play_media(playlists[0].uri)
+
+ def on_shutdown(loop):
+ loop.run_until_complete(mass.stop())
+
+ run(
+ async_main(),
+ use_uvloop=True,
+ shutdown_callback=on_shutdown,
+ executor_workers=64,
+ )
+
+
+if __name__ == "__main__":
+ main()
--- /dev/null
+"""Simple example/script to run Music Assistant with Spotify provider."""
+import argparse
+import asyncio
+import logging
+import os
+from sys import path
+
+from aiorun import run
+
+path.insert(1, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from music_assistant.mass import MusicAssistant
+from music_assistant.providers.spotify import SpotifyProvider
+
+parser = argparse.ArgumentParser(description="MusicAssistant")
+parser.add_argument(
+ "--username",
+ required=True,
+ help="Spotify username",
+)
+parser.add_argument(
+ "--password",
+ required=True,
+ help="Spotify password.",
+)
+parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Enable verbose debug logging",
+)
+args = parser.parse_args()
+
+
+# setup logger
+if args.debug:
+ logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)-15s %(levelname)-5s %(name)s -- %(message)s",
+ )
+ # silence some loggers
+ logging.getLogger("aiorun").setLevel(logging.WARNING)
+ logging.getLogger("asyncio").setLevel(logging.INFO)
+ logging.getLogger("aiosqlite").setLevel(logging.WARNING)
+ logging.getLogger("databases").setLevel(logging.WARNING)
+
+
+# default database based on sqlite
+data_dir = os.getenv("APPDATA") if os.name == "nt" else os.path.expanduser("~")
+data_dir = os.path.join(data_dir, ".musicassistant")
+if not os.path.isdir(data_dir):
+ os.makedirs(data_dir)
+db_file = os.path.join(data_dir, "music_assistant.db")
+
+mass = MusicAssistant(f"sqlite:///{db_file}")
+spotify = SpotifyProvider(args.username, args.password)
+
+
+def main():
+ """Handle main execution."""
+
+ async def async_main():
+ """Async main routine."""
+ asyncio.get_event_loop().set_debug(args.debug)
+ await mass.setup()
+ # register music provider(s)
+ await mass.music.register_provider(spotify)
+ # get some data
+ await mass.music.artists.library()
+ await mass.music.tracks.library()
+ await mass.music.radio.library()
+
+ def on_shutdown(loop):
+ loop.run_until_complete(mass.stop())
+
+ run(
+ async_main(),
+ use_uvloop=True,
+ shutdown_callback=on_shutdown,
+ executor_workers=64,
+ )
+
+
+if __name__ == "__main__":
+ main()
+++ /dev/null
-{
- "folders": [
- {
- "path": "."
- }
- ],
- "settings": {
- "python.pythonPath": "venv/bin/python"
- }
-}
\ No newline at end of file
-"""Init file for Music Assistant."""
+"""Music Assistant: The music library manager in python."""
+
+from .mass import MusicAssistant # noqa
+++ /dev/null
-"""Start Music Assistant."""
-import argparse
-import os
-
-from aiorun import run
-from music_assistant.helpers.logger import setup_logger
-from music_assistant.mass import MusicAssistant
-
-
-def get_arguments():
- """Arguments handling."""
- parser = argparse.ArgumentParser(description="MusicAssistant")
-
- default_data_dir = (
- os.getenv("APPDATA") if os.name == "nt" else os.path.expanduser("~")
- )
- default_data_dir = os.path.join(default_data_dir, ".musicassistant")
-
- parser.add_argument(
- "-c",
- "--config",
- metavar="path_to_config_dir",
- default=default_data_dir,
- help="Directory that contains the MusicAssistant configuration",
- )
- parser.add_argument(
- "-p",
- "--port",
- metavar="port",
- default=8095,
- help="TCP port on which the server should be run.",
- )
- parser.add_argument(
- "--debug",
- action="store_true",
- help="Start MusicAssistant with verbose debug logging",
- )
- arguments = parser.parse_args()
- return arguments
-
-
-def main():
- """Start MusicAssistant."""
- # parse arguments
- args = get_arguments()
- data_dir = args.config
- if not os.path.isdir(data_dir):
- os.makedirs(data_dir)
- # setup logger
- logger = setup_logger(data_dir)
- # config debug settings if needed
- if args.debug or bool(os.environ.get("DEBUG")):
- debug = True
- else:
- debug = False
- mass = MusicAssistant(data_dir, debug, int(args.port))
-
- def on_shutdown(loop):
- logger.info("shutdown requested!")
- loop.run_until_complete(mass.stop())
-
- run(
- mass.start(),
- use_uvloop=True,
- shutdown_callback=on_shutdown,
- executor_workers=64,
- )
-
-
-if __name__ == "__main__":
- main()
"""All constants for Music Assistant."""
-__version__ = "0.2.13"
-REQUIRED_PYTHON_VER = "3.9"
+from enum import Enum
-# configuration keys/attributes
-CONF_USERNAME = "username"
-CONF_PASSWORD = "password"
-CONF_ENABLED = "enabled"
-CONF_HOSTNAME = "hostname"
-CONF_PORT = "port"
-CONF_TOKEN = "token"
-CONF_URL = "url"
-CONF_NAME = "name"
-CONF_CROSSFADE_DURATION = "crossfade_duration"
-CONF_GROUP_DELAY = "group_delay"
-CONF_VOLUME_CONTROL = "volume_control"
-CONF_POWER_CONTROL = "power_control"
-CONF_MAX_SAMPLE_RATE = "max_sample_rate"
-CONF_VOLUME_NORMALISATION = "volume_normalisation"
-CONF_TARGET_VOLUME = "target_volume"
-CONF_SSL_CERTIFICATE = "ssl_certificate"
-CONF_SSL_KEY = "ssl_key"
-CONF_EXTERNAL_URL = "external_url"
+class EventType(Enum):
+ """Enum with possible Events."""
-# configuration base keys/attributes
-CONF_KEY_BASE = "base"
-CONF_KEY_PLAYER_SETTINGS = "player_settings"
-CONF_KEY_MUSIC_PROVIDERS = "music_providers"
-CONF_KEY_PLAYER_PROVIDERS = "player_providers"
-CONF_KEY_METADATA_PROVIDERS = "metadata_providers"
-CONF_KEY_PLUGINS = "plugins"
-CONF_KEY_SECURITY = "security"
-CONF_KEY_SECURITY_LOGIN = "login"
-CONF_KEY_SECURITY_APP_TOKENS = "app_tokens"
-CONF_KEY_BASE_INFO = "info"
+ PLAYER_ADDED = "player added"
+ PLAYER_REMOVED = "player removed"
+ PLAYER_CHANGED = "player changed"
+ STREAM_STARTED = "streaming started"
+ STREAM_ENDED = "streaming ended"
+ CONFIG_CHANGED = "config changed"
+ MUSIC_SYNC_STATUS = "music sync status"
+ QUEUE_ADDED = "queue_added"
+ QUEUE_UPDATED = "queue updated"
+ QUEUE_ITEMS_UPDATED = "queue items updated"
+ SHUTDOWN = "application shutdown"
+ ARTIST_ADDED = "artist added"
+ ALBUM_ADDED = "album added"
+ TRACK_ADDED = "track added"
+ PLAYLIST_ADDED = "playlist added"
+ RADIO_ADDED = "radio added"
+ TASK_UPDATED = "task updated"
+ PROVIDER_REGISTERED = "PROVIDER_REGISTERED"
-# events
-EVENT_PLAYER_ADDED = "player added"
-EVENT_PLAYER_REMOVED = "player removed"
-EVENT_PLAYER_CHANGED = "player changed"
-EVENT_STREAM_STARTED = "streaming started"
-EVENT_STREAM_ENDED = "streaming ended"
-EVENT_CONFIG_CHANGED = "config changed"
-EVENT_MUSIC_SYNC_STATUS = "music sync status"
-EVENT_QUEUE_UPDATED = "queue updated"
-EVENT_QUEUE_ITEMS_UPDATED = "queue items updated"
-EVENT_SHUTDOWN = "application shutdown"
-EVENT_PROVIDER_REGISTERED = "provider registered"
-EVENT_PROVIDER_UNREGISTERED = "provider unregistered"
-EVENT_ARTIST_ADDED = "artist added"
-EVENT_ALBUM_ADDED = "album added"
-EVENT_TRACK_ADDED = "track added"
-EVENT_PLAYLIST_ADDED = "playlist added"
-EVENT_RADIO_ADDED = "radio added"
-EVENT_TASK_UPDATED = "task updated"
# player attributes
ATTR_PLAYER_ID = "player_id"
--- /dev/null
+"""All logic for metadata retrieval."""
+
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.images import create_thumbnail
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import merge_dict
+
+from .fanarttv import FanartTv
+from .musicbrainz import MusicBrainz
+
+# TODO: add more metadata providers such as theaudiodb
+# TODO: add metadata support for albums and other media types
+
+TABLE_THUMBS = "thumbnails"
+
+
+class MetaDataController:
+ """Several helpers to search and store metadata for mediaitems."""
+
+ # TODO: create periodic task to search for missing metadata
+ def __init__(self, mass: MusicAssistant) -> None:
+ """Initialize class."""
+ self.mass = mass
+ self.cache = mass.cache
+ self.logger = mass.logger.getChild("metadata")
+ self.fanarttv = FanartTv(mass)
+ self.musicbrainz = MusicBrainz(mass)
+
+ async def setup(self):
+ """Async initialize of module."""
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {TABLE_THUMBS}(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url TEXT NOT NULL,
+ size INTEGER,
+ img BLOB,
+ UNIQUE(url, size));"""
+ )
+
+ async def get_artist_metadata(self, mb_artist_id: str, cur_metadata: dict) -> dict:
+ """Get/update rich metadata for an artist by providing the musicbrainz artist id."""
+ metadata = cur_metadata
+ if "fanart" in metadata:
+ # no need to query (other) metadata providers if we already have a result
+ return metadata
+ self.logger.info(
+ "Fetching metadata for MusicBrainz Artist %s on Fanrt.tv", mb_artist_id
+ )
+ cache_key = f"fanarttv.artist_metadata.{mb_artist_id}"
+ res = await cached(
+ self.cache, cache_key, self.fanarttv.get_artist_images, mb_artist_id
+ )
+ if res:
+ metadata = merge_dict(metadata, res)
+ self.logger.debug(
+ "Found metadata for MusicBrainz Artist %s on Fanart.tv: %s",
+ mb_artist_id,
+ ", ".join(res.keys()),
+ )
+ return metadata
+
+ async def get_thumbnail(self, url, size) -> bytes:
+ """Get/create thumbnail image for url."""
+ match = {"url": url, "size": size}
+ if result := await self.mass.database.get_row(TABLE_THUMBS, match):
+ return result["img"]
+ # create thumbnail if it doesn't exist
+ thumbnail = await create_thumbnail(self.mass, url, size)
+ await self.mass.database.insert_or_replace(
+ TABLE_THUMBS, {**match, "img": thumbnail}
+ )
+ return thumbnail
--- /dev/null
+"""FanartTv Metadata provider."""
+
+from json.decoder import JSONDecodeError
+from typing import Dict
+
+import aiohttp
+from asyncio_throttle import Throttler
+
+from music_assistant.helpers.typing import MusicAssistant
+
+
+# TODO: add support for personal api keys ?
+# TODO: Add support for album artwork ?
+
+
+class FanartTv:
+ """Fanart.tv metadata provider."""
+
+ def __init__(self, mass: MusicAssistant):
+ """Initialize class."""
+ self.mass = mass
+ self.logger = mass.logger.getChild("fanarttv")
+ self.throttler = Throttler(rate_limit=1, period=2)
+
+ async def get_artist_images(self, mb_artist_id: str) -> Dict:
+ """Retrieve images by musicbrainz artist id."""
+ metadata = {}
+ data = await self._get_data(f"music/{mb_artist_id}")
+ if data:
+ if data.get("hdmusiclogo"):
+ metadata["logo"] = data["hdmusiclogo"][0]["url"]
+ elif data.get("musiclogo"):
+ metadata["logo"] = data["musiclogo"][0]["url"]
+ if data.get("artistbackground"):
+ count = 0
+ for item in data["artistbackground"]:
+ key = "fanart" if count == 0 else f"fanart.{count}"
+ metadata[key] = item["url"]
+ if data.get("artistthumb"):
+ url = data["artistthumb"][0]["url"]
+ if "2a96cbd8b46e442fc41c2b86b821562f" not in url:
+ metadata["image"] = url
+ if data.get("musicbanner"):
+ metadata["banner"] = data["musicbanner"][0]["url"]
+ return metadata
+
+ async def _get_data(self, endpoint, params=None):
+ """Get data from api."""
+ if params is None:
+ params = {}
+ url = f"http://webservice.fanart.tv/v3/{endpoint}"
+ params["api_key"] = "639191cb0774661597f28a47e7e2bad5"
+ async with self.throttler:
+ async with self.mass.http_session.get(
+ url, params=params, verify_ssl=False
+ ) as response:
+ try:
+ result = await response.json()
+ except (
+ aiohttp.ContentTypeError,
+ JSONDecodeError,
+ ):
+ self.logger.error("Failed to retrieve %s", endpoint)
+ text_result = await response.text()
+ self.logger.debug(text_result)
+ return None
+ except aiohttp.ClientConnectorError:
+ self.logger.error("Failed to retrieve %s", endpoint)
+ return None
+ if "error" in result and "limit" in result["error"]:
+ self.logger.error(result["error"])
+ return None
+ return result
--- /dev/null
+"""Handle getting Id's from MusicBrainz."""
+
+import re
+from json.decoder import JSONDecodeError
+from typing import Optional
+
+import aiohttp
+from asyncio_throttle import Throttler
+
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.compare import compare_strings, get_compare_string
+from music_assistant.helpers.typing import MusicAssistant
+
+LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
+
+
+class MusicBrainz:
+ """Handle getting Id's from MusicBrainz."""
+
+ def __init__(self, mass: MusicAssistant):
+ """Initialize class."""
+ self.mass = mass
+ self.cache = mass.cache
+ self.logger = mass.logger.getChild("musicbrainz")
+ self.throttler = Throttler(rate_limit=1, period=1)
+
+ async def get_mb_artist_id(
+ self,
+ artistname,
+ albumname=None,
+ album_upc=None,
+ trackname=None,
+ track_isrc=None,
+ ):
+ """Retrieve musicbrainz artist id for the given details."""
+ self.logger.debug(
+ "searching musicbrainz for %s \
+ (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)",
+ artistname,
+ albumname,
+ album_upc,
+ trackname,
+ track_isrc,
+ )
+ mb_artist_id = None
+ if album_upc:
+ mb_artist_id = await self.search_artist_by_album(
+ artistname, None, album_upc
+ )
+ if mb_artist_id:
+ self.logger.debug(
+ "Got MusicbrainzArtistId for %s after search on upc %s --> %s",
+ artistname,
+ album_upc,
+ mb_artist_id,
+ )
+ if not mb_artist_id and track_isrc:
+ mb_artist_id = await self.search_artist_by_track(
+ artistname, None, track_isrc
+ )
+ if mb_artist_id:
+ self.logger.debug(
+ "Got MusicbrainzArtistId for %s after search on isrc %s --> %s",
+ artistname,
+ track_isrc,
+ mb_artist_id,
+ )
+ if not mb_artist_id and albumname:
+ mb_artist_id = await self.search_artist_by_album(artistname, albumname)
+ if mb_artist_id:
+ self.logger.debug(
+ "Got MusicbrainzArtistId for %s after search on albumname %s --> %s",
+ artistname,
+ albumname,
+ mb_artist_id,
+ )
+ if not mb_artist_id and trackname:
+ mb_artist_id = await self.search_artist_by_track(artistname, trackname)
+ if mb_artist_id:
+ self.logger.debug(
+ "Got MusicbrainzArtistId for %s after search on trackname %s --> %s",
+ artistname,
+ trackname,
+ mb_artist_id,
+ )
+ return mb_artist_id
+
+ async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
+ """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
+ for searchartist in [
+ re.sub(LUCENE_SPECIAL, r"\\\1", artistname),
+ get_compare_string(artistname),
+ ]:
+ if album_upc:
+ endpoint = "release"
+ params = {"query": f"barcode:{album_upc}"}
+ cache_key = f"{endpoint}.barcode.{album_upc}"
+ else:
+ searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname)
+ endpoint = "release"
+ params = {
+ "query": f'artist:"{searchartist}" AND release:"{searchalbum}"'
+ }
+ cache_key = f"{endpoint}.{searchartist}.{searchalbum}"
+ result = await cached(
+ self.mass.cache, cache_key, self.get_data, endpoint, params
+ )
+ if result and "releases" in result:
+ for strictness in [True, False]:
+ for item in result["releases"]:
+ if album_upc or compare_strings(
+ item["title"], albumname, strictness
+ ):
+ for artist in item["artist-credit"]:
+ if compare_strings(
+ artist["artist"]["name"], artistname, strictness
+ ):
+ return artist["artist"]["id"]
+ for alias in artist.get("aliases", []):
+ if compare_strings(
+ alias["name"], artistname, strictness
+ ):
+ return artist["id"]
+ return ""
+
+ async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
+ """Retrieve artist id by providing the artist name and trackname or track isrc."""
+ endpoint = "recording"
+ searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname)
+ if track_isrc:
+ endpoint = f"isrc/{track_isrc}"
+ params = {"inc": "artist-credits"}
+ cache_key = endpoint
+ else:
+ searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname)
+ endpoint = "recording"
+ params = {"query": '"{searchtrack}" AND artist:"{searchartist}"'}
+ cache_key = f"{endpoint}.{searchtrack}.{searchartist}"
+ result = await cached(
+ self.mass.cache, cache_key, self.get_data(endpoint, params)
+ )
+ if result and "recordings" in result:
+ for strictness in [True, False]:
+ for item in result["recordings"]:
+ if track_isrc or compare_strings(
+ item["title"], trackname, strictness
+ ):
+ for artist in item["artist-credit"]:
+ if compare_strings(
+ artist["artist"]["name"], artistname, strictness
+ ):
+ return artist["artist"]["id"]
+ for alias in artist.get("aliases", []):
+ if compare_strings(
+ alias["name"], artistname, strictness
+ ):
+ return artist["id"]
+ return ""
+
+ async def get_data(self, endpoint: str, params: Optional[dict] = None):
+ """Get data from api."""
+ if params is None:
+ params = {}
+ url = f"http://musicbrainz.org/ws/2/{endpoint}"
+ headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"}
+ params["fmt"] = "json"
+ async with self.throttler:
+ async with self.mass.http_session.get(
+ url, headers=headers, params=params, verify_ssl=False
+ ) as response:
+ try:
+ result = await response.json()
+ except (
+ aiohttp.client_exceptions.ContentTypeError,
+ JSONDecodeError,
+ ) as exc:
+ msg = await response.text()
+ self.logger.error("%s - %s", str(exc), msg)
+ result = None
+ return result
--- /dev/null
+"""MusicController: Orchestrates all data from music providers and sync to internal database."""
+from __future__ import annotations
+
+import asyncio
+import statistics
+from typing import Dict, List, Tuple
+
+from music_assistant.constants import EventType
+from music_assistant.controllers.music.albums import AlbumsController
+from music_assistant.controllers.music.artists import ArtistsController
+from music_assistant.controllers.music.playlists import PlaylistController
+from music_assistant.controllers.music.radio import RadioController
+from music_assistant.controllers.music.tracks import TracksController
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.datetime import utc_timestamp
+from music_assistant.models.errors import (
+ AlreadyRegisteredError,
+ MusicAssistantError,
+ SetupFailedError,
+)
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import create_task
+from music_assistant.models.media_items import (
+ Album,
+ MediaItem,
+ MediaItemProviderId,
+ MediaItemType,
+ MediaType,
+ Playlist,
+)
+from music_assistant.models.provider import MusicProvider
+
+DB_PROV_MAPPINGS = "provider_mappings"
+DB_TRACK_LOUDNESS = "track_loudness"
+DB_PLAYLOG = "playlog"
+
+
+class MusicController:
+ """Several helpers around the musicproviders."""
+
+ def __init__(self, mass: MusicAssistant):
+ """Initialize class."""
+ self.logger = mass.logger.getChild("music")
+ self.mass = mass
+ self.artists = ArtistsController(mass)
+ self.albums = AlbumsController(mass)
+ self.tracks = TracksController(mass)
+ self.radio = RadioController(mass)
+ self.playlists = PlaylistController(mass)
+ self._providers: Dict[str, MusicProvider] = {}
+
+ async def setup(self):
+ """Async initialize of module."""
+ await self.__setup_database_tables()
+ # setup generic controllers
+ await self.artists.setup()
+ await self.albums.setup()
+ await self.tracks.setup()
+ await self.radio.setup()
+ await self.playlists.setup()
+ self.__schedule_sync_tasks()
+
+ @property
+ def provider_count(self) -> int:
+ """Return count of all registered music providers."""
+ return len(self._providers)
+
+ @property
+ def providers(self) -> Tuple[MusicProvider]:
+ """Return all (available) music providers."""
+ return tuple(x for x in self._providers.values() if x.available)
+
+ def get_provider(self, provider_id: str) -> MusicProvider | None:
+ """Return provider/plugin by id."""
+ prov = self._providers.get(provider_id, None)
+ if prov is None or not prov.available:
+ self.logger.warning("Provider %s is not available", provider_id)
+ return prov
+
+ async def register_provider(self, provider: MusicProvider) -> None:
+ """Register a music provider."""
+ if provider.id in self._providers:
+ raise AlreadyRegisteredError(
+ f"Provider {provider.id} is already registered"
+ )
+ try:
+ provider.mass = self.mass
+ provider.logger = self.logger.getChild(provider.id)
+ await provider.setup()
+ except Exception as err: # pylint: disable=broad-except
+ raise SetupFailedError(f"Setup failed of provider {provider.id}") from err
+ else:
+ self._providers[provider.id] = provider
+ self.mass.signal_event(EventType.PROVIDER_REGISTERED, provider)
+ await self.schedule_provider_sync(provider.id)
+
+ async def search(
+ self, search_query, media_types: List[MediaType], limit: int = 10
+ ) -> List[MediaItemType]:
+ """
+ Perform global search for media items on all providers.
+
+ :param search_query: Search query.
+ :param media_types: A list of media_types to include.
+ :param limit: number of items to return in the search (per type).
+ """
+ # include results from all music providers
+ provider_ids = ["database"] + [item.id for item in self.providers]
+ # TODO: sort by name and filter out duplicates ?
+ return await asyncio.gather(
+ *[
+ self.search_provider(search_query, prov_id, media_types, limit)
+ for prov_id in provider_ids
+ ]
+ )
+
+ async def search_provider(
+ self,
+ search_query: str,
+ provider_id: str,
+ media_types: List[MediaType],
+ limit: int = 10,
+ ) -> List[MediaItemType]:
+ """
+ Perform search on given provider.
+
+ :param search_query: Search query
+ :param provider_id: provider_id of the provider to perform the search on.
+ :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).
+ """
+ if provider_id == "database":
+ # get results from database
+ return (
+ await self.artists.search(search_query, "database", limit)
+ + await self.albums.search(search_query, "database", limit)
+ + await self.tracks.search(search_query, "database", limit)
+ + await self.playlists.search(search_query, "database", limit)
+ + await self.radio.search(search_query, "database", limit)
+ )
+ provider = self.get_provider(provider_id)
+ media_types_str = ".".join(sorted([x.value for x in media_types]))
+ cache_key = f"{provider_id}.search.{search_query}.{media_types_str}.{limit}"
+ return await cached(
+ self.mass.cache,
+ cache_key,
+ provider.search,
+ search_query,
+ media_types,
+ limit,
+ )
+
+ async def get_item_by_uri(
+ self, uri: str, force_refresh: bool = False, lazy: bool = True
+ ) -> MediaItemType:
+ """Fetch MediaItem by uri."""
+ if "://" in uri:
+ provider = uri.split("://")[0]
+ item_id = uri.split("/")[-1]
+ media_type = MediaType(uri.split("/")[-2])
+ else:
+ # spotify new-style uri
+ provider, media_type, item_id = uri.split(":")
+ media_type = MediaType(media_type)
+ return await self.get_item(
+ item_id, provider, media_type, force_refresh=force_refresh, lazy=lazy
+ )
+
+ async def get_item(
+ self,
+ item_id: str,
+ provider_id: str,
+ media_type: MediaType,
+ force_refresh: bool = False,
+ lazy: bool = True,
+ ) -> MediaItemType:
+ """Get single music item by id and media type."""
+ ctrl = self._get_controller(media_type)
+ return await ctrl.get(
+ item_id, provider_id, force_refresh=force_refresh, lazy=lazy
+ )
+
+ async def refresh_items(self, items: List[MediaItem]) -> None:
+ """
+ Refresh MediaItems to force retrieval of full info and matches.
+
+ Creates background tasks to process the action.
+ """
+ for media_item in items:
+ job_desc = f"Refresh metadata of {media_item.uri}"
+ self.mass.add_job(self.refresh_item(media_item), job_desc)
+
+ async def refresh_item(
+ self,
+ media_item: MediaItem,
+ ):
+ """Try to refresh a mediaitem by requesting it's full object or search for substitutes."""
+ try:
+ return await self.get_item(
+ media_item.item_id,
+ media_item.provider,
+ media_item.media_type,
+ force_refresh=True,
+ lazy=False,
+ )
+ except MusicAssistantError:
+ pass
+
+ for item in await self.search(media_item.name, [media_item.media_type], 20):
+ if item.available:
+ await self.get_item(
+ item.item_id, item.provider, item.media_type, lazy=False
+ )
+
+ async def get_provider_mapping(
+ self, media_type: MediaType, provider_id: str, provider_item_id: str
+ ) -> int | None:
+ """Lookup database id for media item from provider id."""
+ if result := await self.mass.database.get_row(
+ DB_PROV_MAPPINGS,
+ {
+ "media_type": media_type.value,
+ "provider": provider_id,
+ "prov_item_id": provider_item_id,
+ },
+ ):
+ return result["item_id"]
+ return None
+
+ async def add_provider_mappings(
+ self,
+ item_id: int,
+ media_type: MediaType,
+ prov_ids: List[MediaItemProviderId],
+ ):
+ """Add provider ids for media item to database."""
+ for prov in prov_ids:
+ await self.add_provider_mapping(item_id, media_type, prov)
+
+ async def add_provider_mapping(
+ self,
+ item_id: int,
+ media_type: MediaType,
+ prov_id: MediaItemProviderId,
+ ):
+ """Add provider id for media item to database."""
+ await self.mass.database.insert_or_replace(
+ DB_PROV_MAPPINGS,
+ {
+ "item_id": item_id,
+ "media_type": media_type.value,
+ "prov_item_id": prov_id.item_id,
+ "provider": prov_id.provider,
+ "quality": prov_id.quality.value,
+ "details": prov_id.details,
+ },
+ )
+
+ async def add_to_library(
+ self, media_type: MediaType, provider_item_id: str, provider_id: str
+ ) -> None:
+ """Add an item to the library."""
+ ctrl = self._get_controller(media_type)
+ await ctrl.add_to_library(provider_item_id, provider_id)
+
+ async def remove_from_library(
+ self, media_type: MediaType, provider_item_id: str, provider_id: str
+ ) -> None:
+ """Remove item from the library."""
+ ctrl = self._get_controller(media_type)
+ await ctrl.remove_from_library(provider_item_id, provider_id)
+
+ async def set_track_loudness(self, item_id: str, provider_id: str, loudness: int):
+ """List integrated loudness for a track in db."""
+ await self.mass.database.insert_or_replace(
+ DB_TRACK_LOUDNESS,
+ {"item_id": item_id, "provider": provider_id, "loudness": loudness},
+ )
+
+ async def get_track_loudness(
+ self, provider_item_id: str, provider_id: str
+ ) -> float | None:
+ """Get integrated loudness for a track in db."""
+ if result := await self.mass.database.get_row(
+ DB_TRACK_LOUDNESS,
+ {
+ "item_id": provider_item_id,
+ "provider": provider_id,
+ },
+ ):
+ return result["loudness"]
+ return None
+
+ async def get_provider_loudness(self, provider_id: str) -> float | None:
+ """Get average integrated loudness for tracks of given provider."""
+ all_items = []
+ for db_row in await self.mass.database.get_rows(
+ DB_TRACK_LOUDNESS,
+ {
+ "provider": provider_id,
+ },
+ ):
+ all_items.append(db_row["loudness"])
+ if all_items:
+ return statistics.fmean(all_items)
+ return None
+
+ async def mark_item_played(self, item_id: str, provider_id: str):
+ """Mark item as played in playlog."""
+ timestamp = utc_timestamp()
+ await self.mass.database.insert_or_replace(
+ DB_PLAYLOG,
+ {"item_id": item_id, "provider": provider_id, "timestamp": timestamp},
+ )
+
+ async def library_add_items(self, items: List[MediaItem]) -> None:
+ """
+ Add media item(s) to the library.
+
+ Creates background tasks to process the action.
+ """
+ for media_item in items:
+ job_desc = f"Add {media_item.uri} to library"
+ self.mass.add_job(
+ self.add_to_library(
+ media_item.media_type, media_item.item_id, media_item.provider
+ ),
+ job_desc,
+ )
+
+ async def library_remove_items(self, items: List[MediaItem]) -> None:
+ """
+ Remove media item(s) from the library.
+
+ Creates background tasks to process the action.
+ """
+ for media_item in items:
+ job_desc = f"Remove {media_item.uri} from library"
+ self.mass.add_job(
+ self.remove_from_library(
+ media_item.media_type, media_item.item_id, media_item.provider
+ ),
+ job_desc,
+ )
+
+ async def schedule_provider_sync(self, provider_id: str):
+ """Schedule library sync for a provider."""
+ provider = self.get_provider(provider_id)
+ if not provider:
+ return
+ for media_type in provider.supported_mediatypes:
+ self.mass.add_job(
+ self._library_items_sync(
+ media_type,
+ provider_id,
+ ),
+ f"Library sync of {media_type.value}s for provider {provider.name}",
+ )
+
+ async def _library_items_sync(
+ self, media_type: MediaType, provider_id: str
+ ) -> None:
+ """Sync library items for given provider."""
+ music_provider = self.get_provider(provider_id)
+ if not music_provider or not music_provider.available:
+ return
+ controller = self._get_controller(media_type)
+ # create a set of all previous and current db id's
+ prev_ids = set()
+ for db_item in await controller.library():
+ for prov_id in db_item.provider_ids:
+ if prov_id.provider == provider_id:
+ prev_ids.add(db_item.item_id)
+ cur_ids = set()
+ for prov_item in await music_provider.get_library_items(media_type):
+ prov_item: MediaItemType = prov_item
+ db_item: MediaItemType = await controller.get_db_item_by_prov_id(
+ prov_item.provider, prov_item.item_id
+ )
+ if not db_item and media_type == MediaType.ARTIST:
+ # for artists we need a fully matched item (with musicbrainz id)
+ db_item = await controller.get(
+ prov_item.item_id, prov_item.provider, details=prov_item
+ )
+ elif not db_item:
+ # for other mediatypes its enough to simply dump the item in the db
+ db_item = await controller.add_db_item(prov_item)
+ cur_ids.add(db_item.item_id)
+ if not db_item.in_library:
+ await controller.set_db_library(db_item.item_id, True)
+ # sync album tracks
+ if media_type == MediaType.ALBUM:
+ self.mass.add_job(
+ self._sync_album_tracks(db_item),
+ f"Sync album tracks for album {db_item.name}",
+ )
+ # sync playlist tracks
+ if media_type == MediaType.PLAYLIST:
+ self.mass.add_job(
+ self._sync_playlist_tracks(db_item),
+ f"Sync playlist tracks for playlist {db_item.name}",
+ )
+
+ # process deletions
+ for item_id in prev_ids:
+ if item_id not in cur_ids:
+ await controller.set_db_library(item_id, False)
+
+ async def _sync_album_tracks(self, db_album: Album) -> None:
+ """Store album tracks of in-library album in database."""
+ for prov_id in db_album.provider_ids:
+ for album_track in await self.albums.get_provider_album_tracks(
+ prov_id.item_id, prov_id.provider
+ ):
+ db_track = await self.tracks.get_db_item_by_prov_id(
+ album_track.provider, album_track.item_id
+ )
+ if not db_track:
+ db_track = await self.tracks.add_db_item(album_track)
+ # add track to album_tracks
+ await self.mass.music.albums.add_db_album_track(
+ db_album.item_id,
+ db_track.item_id,
+ album_track.disc_number,
+ album_track.track_number,
+ )
+
+ async def _sync_playlist_tracks(self, db_playlist: Playlist) -> None:
+ """Store playlist tracks of in-library playlist in database."""
+ for prov_id in db_playlist.provider_ids:
+ provider = self.get_provider(prov_id.provider)
+ if not provider:
+ continue
+ for playlist_track in await self.playlists.get_provider_playlist_tracks(
+ prov_id.item_id, prov_id.provider
+ ):
+ db_track = await self.tracks.get_db_item_by_prov_id(
+ playlist_track.provider, playlist_track.item_id
+ )
+ if not db_track:
+ db_track = await self.tracks.add_db_item(playlist_track)
+ assert playlist_track.position is not None
+ await self.playlists.add_db_playlist_track(
+ db_playlist.item_id,
+ db_track.item_id,
+ playlist_track.position,
+ )
+
+ def _get_controller(
+ self, media_type: MediaType
+ ) -> ArtistsController | AlbumsController | TracksController | RadioController | PlaylistController:
+ """Return controller for MediaType."""
+ if media_type == MediaType.ARTIST:
+ return self.artists
+ if media_type == MediaType.ALBUM:
+ return self.albums
+ if media_type == MediaType.TRACK:
+ return self.tracks
+ if media_type == MediaType.RADIO:
+ return self.radio
+ if media_type == MediaType.PLAYLIST:
+ return self.playlists
+
+ async def __setup_database_tables(self) -> None:
+ """Init generic database tables."""
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {DB_PROV_MAPPINGS}(
+ item_id INTEGER NOT NULL,
+ media_type TEXT NOT NULL,
+ prov_item_id TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ quality INTEGER NOT NULL,
+ details TEXT NULL,
+ UNIQUE(item_id, media_type, prov_item_id, provider)
+ );"""
+ )
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {DB_TRACK_LOUDNESS}(
+ item_id INTEGER NOT NULL,
+ provider TEXT NOT NULL,
+ loudness REAL,
+ UNIQUE(item_id, provider));"""
+ )
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {DB_PLAYLOG}(
+ item_id INTEGER NOT NULL,
+ provider TEXT NOT NULL,
+ timestamp REAL,
+ UNIQUE(item_id, provider));"""
+ )
+
+ def __schedule_sync_tasks(self):
+ """Schedule the sync tasks."""
+ for prov in self.providers:
+ create_task(self.schedule_provider_sync(prov.id))
+ # reschedule self
+ self.mass.loop.call_later(3 * 3600, self.__schedule_sync_tasks)
--- /dev/null
+"""Manage MediaItems of type Album."""
+from __future__ import annotations
+
+import asyncio
+from typing import List
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.compare import compare_album, compare_strings
+from music_assistant.helpers.json import json_serializer
+from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list
+from music_assistant.models.media_controller import MediaControllerBase
+from music_assistant.models.media_items import (
+ Album,
+ AlbumType,
+ ItemMapping,
+ MediaType,
+ Track,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class AlbumsController(MediaControllerBase[Album]):
+ """Controller managing MediaItems of type Album."""
+
+ db_table = "albums"
+ media_type = MediaType.ALBUM
+ item_cls = Album
+
+ async def setup(self):
+ """Async initialize of module."""
+ # prepare database
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {self.db_table}(
+ item_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ sort_name TEXT NOT NULL,
+ album_type TEXT,
+ year INTEGER,
+ version TEXT,
+ in_library BOOLEAN DEFAULT 0,
+ upc TEXT,
+ artist json,
+ metadata json,
+ provider_ids json
+ );"""
+ )
+ await _db.execute(
+ """CREATE TABLE IF NOT EXISTS album_tracks(
+ album_id INTEGER NOT NULL,
+ track_id INTEGER NOT NULL,
+ disc_number INTEGER NOT NULL,
+ track_number INTEGER NOT NULL,
+ UNIQUE(album_id, disc_number, track_number)
+ );"""
+ )
+
+ async def tracks(self, item_id: str, provider_id: str) -> List[Track]:
+ """Return album tracks for the given provider album id."""
+ album = await self.get(item_id, provider_id)
+ # for in-library albums we have the tracks in db
+ if album.in_library and album.provider == "database":
+ return await self.get_db_album_tracks(album.item_id)
+ # else: simply return the tracks from the first provider
+ for prov in album.provider_ids:
+ if tracks := await self.get_provider_album_tracks(
+ prov.item_id, prov.provider
+ ):
+ return tracks
+ return []
+
+ async def versions(self, item_id: str, provider_id: str) -> List[Album]:
+ """Return all versions of an album we can find on all providers."""
+ album = await self.get(item_id, provider_id)
+ provider_ids = {item.id for item in self.mass.music.providers}
+ search_query = f"{album.artist.name} {album.name}"
+ return [
+ prov_item
+ for prov_items in await asyncio.gather(
+ *[self.search(search_query, prov_id) for prov_id in provider_ids]
+ )
+ for prov_item in prov_items
+ if compare_strings(prov_item.artist.name, album.artist.name)
+ ]
+
+ async def add(self, item: Album) -> Album:
+ """Add album to local db and return the database item."""
+ # make sure we have an artist
+ assert item.artist
+ db_item = await self.add_db_item(item)
+ # also fetch same album on all providers
+ await self._match(db_item)
+ db_item = await self.get_db_item(db_item.item_id)
+ self.mass.signal_event(EventType.ALBUM_ADDED, db_item)
+ return db_item
+
+ async def get_provider_album_tracks(
+ self, item_id: str, provider_id: str
+ ) -> List[Track]:
+ """Return album tracks for the given provider album id."""
+ provider = self.mass.music.get_provider(provider_id)
+ if not provider:
+ return []
+ cache_key = f"{provider_id}.albumtracks.{item_id}"
+ return await cached(
+ self.mass.cache,
+ cache_key,
+ provider.get_album_tracks,
+ item_id,
+ )
+
+ async def add_db_item(self, album: Album) -> Album:
+ """Add a new album record to the database."""
+ cur_item = None
+ if not album.sort_name:
+ album.sort_name = create_sort_name(album.name)
+ # always try to grab existing item by external_id
+ if album.upc:
+ match = {"upc": album.upc}
+ cur_item = await self.mass.database.get_row(self.db_table, match)
+ if not cur_item:
+ # fallback to matching
+ match = {"sort_name": album.sort_name}
+ for row in await self.mass.database.get_rows(self.db_table, match):
+ row_album = Album.from_db_row(row)
+ if compare_album(row_album, album):
+ cur_item = row_album
+ break
+ if cur_item:
+ # update existing
+ return await self.update_db_album(cur_item.item_id, album)
+
+ # insert new album
+ album_artist = ItemMapping.from_item(
+ await self.mass.music.artists.get_db_item_by_prov_id(
+ album.artist.provider, album.artist.item_id
+ )
+ or album.artist
+ )
+ new_item = await self.mass.database.insert_or_replace(
+ self.db_table,
+ {**album.to_db_row(), "artist": json_serializer(album_artist)},
+ )
+ item_id = new_item["item_id"]
+ # store provider mappings
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.ALBUM, album.provider_ids
+ )
+ self.logger.debug("added %s to database", album.name)
+ # return created object
+ return await self.get_db_item(item_id)
+
+ async def update_db_album(self, item_id: int, album: Album) -> Album:
+ """Update Album record in the database."""
+ cur_item = await self.get_db_item(item_id)
+ metadata = merge_dict(cur_item.metadata, album.metadata)
+ provider_ids = merge_list(cur_item.provider_ids, album.provider_ids)
+ album_artist = ItemMapping.from_item(
+ await self.mass.music.artists.get_db_item_by_prov_id(
+ cur_item.artist.provider, cur_item.artist.item_id
+ )
+ or cur_item.artist
+ )
+
+ if cur_item.album_type == AlbumType.UNKNOWN:
+ album_type = album.album_type
+ else:
+ album_type = cur_item.album_type
+
+ match = {"item_id": item_id}
+ await self.mass.database.update(
+ self.db_table,
+ match,
+ {
+ "artist": json_serializer(album_artist),
+ "album_type": album_type.value,
+ "metadata": json_serializer(metadata),
+ "provider_ids": json_serializer(provider_ids),
+ },
+ )
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.ALBUM, album.provider_ids
+ )
+ self.logger.debug("updated %s in database: %s", album.name, item_id)
+ return await self.get_db_item(item_id)
+
+ async def get_db_album_tracks(self, item_id) -> List[Track]:
+ """Get album tracks for an in-library album."""
+ query = (
+ "SELECT TRACKS.*, ALBUMTRACKS.disc_number, ALBUMTRACKS.track_number "
+ "FROM [tracks] TRACKS "
+ "JOIN album_tracks ALBUMTRACKS ON TRACKS.item_id = ALBUMTRACKS.track_id "
+ f"WHERE ALBUMTRACKS.album_id = {item_id}"
+ )
+ return await self.mass.music.tracks.get_db_items(query)
+
+ async def add_db_album_track(
+ self, album_id: int, track_id: int, disc_number: int, track_number: int
+ ) -> None:
+ """Add album track for an in-library album."""
+ return await self.mass.database.insert_or_replace(
+ "album_tracks",
+ {
+ "album_id": album_id,
+ "track_id": track_id,
+ "disc_number": disc_number,
+ "track_number": track_number,
+ },
+ )
+
+ async def _match(self, db_album: Album) -> None:
+ """
+ Try to find matching album on all providers for the provided (database) album.
+
+ This is used to link objects of different providers/qualities together.
+ """
+ if db_album.provider != "database":
+ return # Matching only supported for database items
+
+ async def find_prov_match(provider: MusicProvider):
+ self.logger.debug(
+ "Trying to match album %s on provider %s", db_album.name, provider.name
+ )
+ match_found = False
+ searchstr = f"{db_album.artist.name} {db_album.name}"
+ if db_album.version:
+ searchstr += " " + db_album.version
+ search_result = await self.search(searchstr, provider.id)
+ for search_result_item in search_result:
+ if not search_result_item.available:
+ continue
+ if not compare_album(search_result_item, db_album):
+ continue
+ # we must fetch the full album version, search results are simplified objects
+ prov_album = await self.get_provider_item(
+ search_result_item.item_id, search_result_item.provider
+ )
+ if compare_album(prov_album, db_album):
+ # 100% match, we can simply update the db with additional provider ids
+ await self.update_db_album(db_album.item_id, prov_album)
+ match_found = True
+ # while we're here, also match the artist
+ if db_album.artist.provider == "database":
+ prov_artist = await self.mass.music.artists.get_provider_item(
+ prov_album.artist.item_id, prov_album.artist.provider
+ )
+ await self.mass.music.artists.update_db_artist(
+ db_album.artist.item_id, prov_artist
+ )
+
+ # no match found
+ if not match_found:
+ self.logger.debug(
+ "Could not find match for Album %s on provider %s",
+ db_album.name,
+ provider.name,
+ )
+
+ # try to find match on all providers
+ for provider in self.mass.music.providers:
+ if MediaType.ALBUM in provider.supported_mediatypes:
+ await find_prov_match(provider)
--- /dev/null
+"""Manage MediaItems of type Artist."""
+
+import asyncio
+import itertools
+from typing import List
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.compare import (
+ compare_album,
+ compare_strings,
+ compare_track,
+)
+from music_assistant.helpers.json import json_serializer
+from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list
+from music_assistant.models.media_controller import MediaControllerBase
+from music_assistant.models.media_items import (
+ Album,
+ AlbumType,
+ Artist,
+ ItemMapping,
+ MediaType,
+ Track,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class ArtistsController(MediaControllerBase[Artist]):
+ """Controller managing MediaItems of type Artist."""
+
+ db_table = "artists"
+ media_type = MediaType.ARTIST
+ item_cls = Artist
+
+ async def setup(self):
+ """Async initialize of module."""
+ # prepare database
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {self.db_table}(
+ item_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ sort_name TEXT NOT NULL,
+ musicbrainz_id TEXT NOT NULL UNIQUE,
+ in_library BOOLEAN DEFAULT 0,
+ metadata json,
+ provider_ids json
+ );"""
+ )
+
+ async def toptracks(self, item_id: str, provider_id: str) -> List[Track]:
+ """Return top tracks for an artist."""
+ artist = await self.get(item_id, provider_id)
+ # get results from all providers
+ # TODO: add db results
+ return itertools.chain.from_iterable(
+ await asyncio.gather(
+ *[
+ self.get_provider_artist_toptracks(item.item_id, item.provider)
+ for item in artist.provider_ids
+ ]
+ )
+ )
+
+ async def albums(self, item_id: str, provider_id: str) -> List[Album]:
+ """Return (all/most popular) albums for an artist."""
+ artist = await self.get(item_id, provider_id)
+ # get results from all providers
+ # TODO: add db results
+ return itertools.chain.from_iterable(
+ await asyncio.gather(
+ *[
+ self.get_provider_artist_albums(item.item_id, item.provider)
+ for item in artist.provider_ids
+ ]
+ )
+ )
+
+ async def add(self, item: Artist) -> Artist:
+ """Add artist to local db and return the database item."""
+ if not item.musicbrainz_id:
+ item.musicbrainz_id = await self.get_artist_musicbrainz_id(item)
+ # grab additional metadata
+ item.metadata = await self.mass.metadata.get_artist_metadata(
+ item.musicbrainz_id, item.metadata
+ )
+ db_item = await self.add_db_item(item)
+ # also fetch same artist on all providers
+ await self.match_artist(db_item)
+ db_item = await self.get_db_item(db_item.item_id)
+ self.mass.signal_event(EventType.ARTIST_ADDED, db_item)
+ return db_item
+
+ async def match_artist(self, db_artist: Artist):
+ """
+ Try to find matching artists on all providers for the provided (database) item_id.
+
+ This is used to link objects of different providers together.
+ """
+ assert (
+ db_artist.provider == "database"
+ ), "Matching only supported for database items!"
+ cur_providers = {item.provider for item in db_artist.provider_ids}
+ for provider in self.mass.music.providers:
+ if provider.id in cur_providers:
+ continue
+ if MediaType.ARTIST not in provider.supported_mediatypes:
+ continue
+ if not await self._match(db_artist, provider):
+ self.logger.debug(
+ "Could not find match for Artist %s on provider %s",
+ db_artist.name,
+ provider.name,
+ )
+
+ async def get_provider_artist_toptracks(
+ self, item_id: str, provider_id: str
+ ) -> List[Track]:
+ """Return top tracks for an artist on given provider."""
+ provider = self.mass.music.get_provider(provider_id)
+ if not provider:
+ return []
+ cache_key = f"{provider_id}.artist_toptracks.{item_id}"
+ return await cached(
+ self.mass.cache,
+ cache_key,
+ provider.get_artist_toptracks,
+ item_id,
+ )
+
+ async def get_provider_artist_albums(
+ self, item_id: str, provider_id: str
+ ) -> List[Album]:
+ """Return albums for an artist on given provider."""
+ provider = self.mass.music.get_provider(provider_id)
+ if not provider:
+ return []
+ cache_key = f"{provider_id}.artistalbums.{item_id}"
+ return await cached(
+ self.mass.cache,
+ cache_key,
+ provider.get_artist_albums,
+ item_id,
+ )
+
+ async def add_db_item(self, artist: Artist) -> Artist:
+ """Add a new artist record to the database."""
+ assert artist.musicbrainz_id
+ assert artist.name
+ match = {"musicbrainz_id": artist.musicbrainz_id}
+ if cur_item := await self.mass.database.get_row(self.db_table, match):
+ # update existing
+ return await self.update_db_artist(cur_item["item_id"], artist)
+ # insert artist
+ if not artist.sort_name:
+ artist.sort_name = create_sort_name(artist.name)
+ new_item = await self.mass.database.insert_or_replace(
+ self.db_table, artist.to_db_row()
+ )
+ item_id = new_item["item_id"]
+ # store provider mappings
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.ARTIST, artist.provider_ids
+ )
+ self.logger.debug("added %s to database", artist.name)
+ # return created object
+ return await self.get_db_item(item_id)
+
+ async def update_db_artist(self, item_id: int, artist: Artist) -> Artist:
+ """Update Artist record in the database."""
+ cur_item = await self.get_db_item(item_id)
+ metadata = merge_dict(cur_item.metadata, artist.metadata)
+ provider_ids = merge_list(cur_item.provider_ids, artist.provider_ids)
+ match = {"item_id": item_id}
+ await self.mass.database.update(
+ self.db_table,
+ match,
+ {
+ "metadata": json_serializer(metadata),
+ "provider_ids": json_serializer(provider_ids),
+ },
+ )
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.ARTIST, artist.provider_ids
+ )
+ self.logger.debug("updated %s in database: %s", artist.name, item_id)
+ return await self.get_db_item(item_id)
+
+ async def get_artist_musicbrainz_id(self, artist: Artist) -> str:
+ """Fetch musicbrainz id by performing search using the artist name, albums and tracks."""
+ # try with album first
+ for lookup_album in await self.get_provider_artist_albums(
+ artist.item_id, artist.provider
+ ):
+ if not lookup_album:
+ continue
+ if artist.name != lookup_album.artist.name:
+ continue
+ musicbrainz_id = await self.mass.metadata.musicbrainz.get_mb_artist_id(
+ artist.name,
+ albumname=lookup_album.name,
+ album_upc=lookup_album.upc,
+ )
+ if musicbrainz_id:
+ return musicbrainz_id
+ # fallback to track
+ for lookup_track in await self.get_provider_artist_toptracks(
+ artist.item_id, artist.provider
+ ):
+ if not lookup_track:
+ continue
+ musicbrainz_id = await self.mass.metadata.musicbrainz.get_mb_artist_id(
+ artist.name,
+ trackname=lookup_track.name,
+ track_isrc=lookup_track.isrc,
+ )
+ if musicbrainz_id:
+ return musicbrainz_id
+ # lookup failed, use the shitty workaround to use the name as id.
+ self.logger.warning("Unable to get musicbrainz ID for artist %s !", artist.name)
+ return artist.name
+
+ async def _match(self, db_artist: Artist, provider: MusicProvider):
+ """Try to find matching artists on given provider for the provided (database) artist."""
+ self.logger.debug(
+ "Trying to match artist %s on provider %s", db_artist.name, provider.name
+ )
+ # try to get a match with some reference tracks of this artist
+ for ref_track in await self.toptracks(db_artist.item_id, db_artist.provider):
+ # make sure we have a full track
+ if isinstance(ref_track.album, ItemMapping):
+ ref_track = await self.mass.music.tracks.get(
+ ref_track.item_id, ref_track.provider
+ )
+ searchstr = f"{db_artist.name} {ref_track.name}"
+ search_results = await self.mass.music.tracks.search(searchstr, provider.id)
+ for search_result_item in search_results:
+ if compare_track(search_result_item, ref_track):
+ # get matching artist from track
+ for search_item_artist in search_result_item.artists:
+ if compare_strings(db_artist.name, search_item_artist.name):
+ # 100% album match
+ # get full artist details so we have all metadata
+ prov_artist = await self.get_provider_item(
+ search_item_artist.item_id, search_item_artist.provider
+ )
+ await self.update_db_artist(db_artist.item_id, prov_artist)
+ return
+ # try to get a match with some reference albums of this artist
+ artist_albums = await self.albums(db_artist.item_id, db_artist.provider)
+ for ref_album in artist_albums:
+ if ref_album.album_type == AlbumType.COMPILATION:
+ continue
+ searchstr = f"{db_artist.name} {ref_album.name}"
+ search_result = await self.mass.music.albums.search(searchstr, provider.id)
+ for search_result_item in search_result:
+ # artist must match 100%
+ if not compare_strings(db_artist.name, search_result_item.artist.name):
+ continue
+ if compare_album(search_result_item, ref_album):
+ # 100% album match
+ # get full artist details so we have all metadata
+ prov_artist = await self.get_provider_item(
+ search_result_item.artist.item_id,
+ search_result_item.artist.provider,
+ )
+ await self.update_db_artist(db_artist.item_id, prov_artist)
+ return
+ return
--- /dev/null
+"""Manage MediaItems of type Playlist."""
+from __future__ import annotations
+import time
+
+from typing import List
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.cache import cached
+from music_assistant.models.errors import InvalidDataError, MediaNotFoundError
+from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list
+from music_assistant.helpers.json import json_serializer
+from music_assistant.models.media_controller import MediaControllerBase
+from music_assistant.models.media_items import MediaType, Playlist, Track
+
+
+class PlaylistController(MediaControllerBase[Playlist]):
+ """Controller managing MediaItems of type Playlist."""
+
+ db_table = "playlists"
+ media_type = MediaType.PLAYLIST
+ item_cls = Playlist
+
+ async def setup(self):
+ """Async initialize of module."""
+ # prepare database
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {self.db_table}(
+ item_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ sort_name TEXT NOT NULL,
+ owner TEXT NOT NULL,
+ is_editable BOOLEAN NOT NULL,
+ checksum TEXT NOT NULL,
+ in_library BOOLEAN DEFAULT 0,
+ metadata json,
+ provider_ids json,
+ UNIQUE(name, owner)
+ );"""
+ )
+ await _db.execute(
+ """CREATE TABLE IF NOT EXISTS playlist_tracks(
+ playlist_id INTEGER NOT NULL,
+ track_id INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ UNIQUE(playlist_id, position)
+ );"""
+ )
+
+ async def get_playlist_by_name(self, name: str) -> Playlist | None:
+ """Get in-library playlist by name."""
+ return await self.mass.database.get_row(self.db_table, {"name": name})
+
+ async def tracks(self, item_id: str, provider_id: str) -> List[Track]:
+ """Return playlist tracks for the given provider playlist id."""
+ playlist = await self.get(item_id, provider_id)
+ if playlist.in_library and playlist.provider == "database":
+ # for in-library playlists we have the tracks in db
+ return await self.get_db_playlist_tracks(playlist.item_id)
+ # else: simply return the tracks from the first provider
+ for prov in playlist.provider_ids:
+ if tracks := await self.get_provider_playlist_tracks(
+ prov.item_id, prov.provider
+ ):
+ return tracks
+ return []
+
+ async def add(self, item: Playlist) -> Playlist:
+ """Add playlist to local db and return the new database item."""
+ db_item = await self.add_db_item(item)
+ self.mass.signal_event(EventType.PLAYLIST_ADDED, db_item)
+ return db_item
+
+ async def add_playlist_tracks(
+ self, item_id: str, provider_id: str, tracks: List[Track]
+ ) -> None:
+ """Add multiple tracks to playlist. Creates background tasks to process the action."""
+ playlist = await self.get(item_id, provider_id)
+ if not playlist:
+ raise MediaNotFoundError(f"Playlist {item_id} not found")
+ if not playlist.is_editable:
+ raise InvalidDataError(f"Playlist {playlist.name} is not editable")
+ for track in tracks:
+ job_desc = f"Add track {track.uri} to playlist {playlist.uri}"
+ self.mass.add_job(
+ self.add_playlist_track(item_id, provider_id, track), job_desc
+ )
+
+ async def add_playlist_track(
+ self, item_id: str, provider_id: str, track: Track
+ ) -> None:
+ """Add track to playlist - make sure we dont add duplicates."""
+ # we can only edit playlists that are in the database (marked as editable)
+ playlist = await self.get(item_id, provider_id)
+ if not playlist:
+ raise MediaNotFoundError(f"Playlist {item_id} not found")
+ if not playlist.is_editable:
+ raise InvalidDataError(f"Playlist {playlist.name} is not editable")
+ # make sure we have recent full track details
+ track = await self.mass.music.tracks.get(
+ track.item_id, track.provider, force_refresh=True, lazy=False
+ )
+ # a playlist can only have one provider (for now)
+ playlist_prov = next(iter(playlist.provider_ids))
+ # grab all existing track ids in the playlist so we can check for duplicates
+ cur_playlist_track_ids = set()
+ for item in await self.tracks(playlist_prov.item_id, playlist_prov.provider):
+ cur_playlist_track_ids.update(
+ {
+ i.item_id
+ for i in item.provider_ids
+ if i.provider == playlist_prov.provider
+ }
+ )
+ # check for duplicates
+ for track_prov in track.provider_ids:
+ if (
+ track_prov.provider == playlist_prov.provider
+ and track_prov.item_id in cur_playlist_track_ids
+ ):
+ raise InvalidDataError(
+ "Track already exists in playlist {playlist.name}"
+ )
+ # add track to playlist
+ # we can only add a track to a provider playlist if track is available on that provider
+ # a track can contain multiple versions on the same provider
+ # simply sort by quality and just add the first one (assuming track is still available)
+ track_id_to_add = None
+ for track_version in sorted(
+ track.provider_ids, key=lambda x: x.quality, reverse=True
+ ):
+ if not track.available:
+ continue
+ if track_version.provider == playlist_prov.provider:
+ track_id_to_add = track_version.item_id
+ break
+ if playlist_prov.provider == "file":
+ # the file provider can handle uri's from all providers so simply add the uri
+ track_id_to_add = track.uri
+ break
+ if not track_id_to_add:
+ raise MediaNotFoundError(
+ "Track is not available on provider {playlist_prov.provider}"
+ )
+ # actually add the tracks to the playlist on the provider
+ # invalidate cache
+ playlist.checksum = str(time.time())
+ await self.update_db_playlist(playlist.item_id, playlist)
+ # return result of the action on the provider
+ provider = self.mass.music.get_provider(playlist_prov.provider)
+ return await provider.add_playlist_tracks(
+ playlist_prov.item_id, [track_id_to_add]
+ )
+
+ async def remove_playlist_tracks(
+ self, item_id: str, provider_id: str, tracks: List[Track]
+ ) -> None:
+ """Remove multiple tracks from playlist. Creates background tasks to process the action."""
+ playlist = await self.get(item_id, provider_id)
+ if not playlist:
+ raise MediaNotFoundError(f"Playlist {item_id} not found")
+ if not playlist.is_editable:
+ raise InvalidDataError(f"Playlist {playlist.name} is not editable")
+ for track in tracks:
+ job_desc = f"Remove track {track.uri} from playlist {playlist.uri}"
+ self.mass.add_job(
+ self.remove_playlist_track(item_id, provider_id, track), job_desc
+ )
+
+ async def remove_playlist_track(
+ self, item_id: str, provider_id: str, track: Track
+ ) -> None:
+ """Remove track from playlist."""
+ # we can only edit playlists that are in the database (marked as editable)
+ playlist = await self.get(item_id, provider_id)
+ if not playlist:
+ raise MediaNotFoundError(f"Playlist {item_id} not found")
+ if not playlist.is_editable:
+ raise InvalidDataError(f"Playlist {playlist.name} is not editable")
+ # playlist can only have one provider (for now)
+ prov_playlist = next(iter(playlist.provider_ids))
+ track_ids_to_remove = set()
+ # a track can contain multiple versions on the same provider, remove all
+ for track_provider in track.provider_ids:
+ if track_provider.provider == prov_playlist.provider:
+ track_ids_to_remove.add(track_provider.item_id)
+ # actually remove the tracks from the playlist on the provider
+ if track_ids_to_remove:
+ # invalidate cache
+ playlist.checksum = str(time.time())
+ await self.update_db_playlist(playlist.item_id, playlist)
+ provider = self.mass.music.get_provider(prov_playlist.provider)
+ return await provider.remove_playlist_tracks(
+ prov_playlist.item_id, track_ids_to_remove
+ )
+
+ async def get_provider_playlist_tracks(
+ self, item_id: str, provider_id: str
+ ) -> List[Track]:
+ """Return playlist tracks for the given provider playlist id."""
+ provider = self.mass.music.get_provider(provider_id)
+ if not provider:
+ return []
+ playlist = await provider.get_playlist(item_id)
+ cache_key = f"{provider_id}.playlisttracks.{item_id}"
+
+ # we need to make sure that position is set on the track
+ def playlist_track_with_position(track: Track, index: int):
+ if track.position is None:
+ track.position = index
+ return track
+
+ tracks = await cached(
+ self.mass.cache,
+ cache_key,
+ provider.get_playlist_tracks,
+ item_id,
+ checksum=playlist.checksum,
+ )
+
+ return [
+ playlist_track_with_position(track, index)
+ for index, track in enumerate(tracks)
+ ]
+
+ async def add_db_item(self, playlist: Playlist) -> Playlist:
+ """Add a new playlist record to the database."""
+ match = {"name": playlist.name, "owner": playlist.owner}
+ if cur_item := await self.mass.database.get_row(self.db_table, match):
+ # update existing
+ return await self.update_db_playlist(cur_item["item_id"], playlist)
+
+ # insert new playlist
+ new_item = await self.mass.database.insert_or_replace(
+ self.db_table,
+ playlist.to_db_row(),
+ )
+ item_id = new_item["item_id"]
+ # store provider mappings
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.PLAYLIST, playlist.provider_ids
+ )
+ self.logger.debug("added %s to database", playlist.name)
+ # return created object
+ return await self.get_db_item(item_id)
+
+ async def update_db_playlist(self, item_id: int, playlist: Playlist) -> Playlist:
+ """Update Playlist record in the database."""
+ cur_item = await self.get_db_item(item_id)
+ metadata = merge_dict(cur_item.metadata, playlist.metadata)
+ provider_ids = merge_list(cur_item.provider_ids, playlist.provider_ids)
+ if not playlist.sort_name:
+ playlist.sort_name = create_sort_name(playlist.name)
+
+ match = {"item_id": item_id}
+ await self.mass.database.update(
+ self.db_table,
+ match,
+ {
+ "name": playlist.name,
+ "sort_name": playlist.sort_name,
+ "owner": playlist.owner,
+ "is_editable": playlist.is_editable,
+ "checksum": playlist.checksum,
+ "metadata": json_serializer(metadata),
+ "provider_ids": json_serializer(provider_ids),
+ },
+ )
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.PLAYLIST, playlist.provider_ids
+ )
+ self.logger.debug("updated %s in database: %s", playlist.name, item_id)
+ return await self.get_db_item(item_id)
+
+ async def get_db_playlist_tracks(self, item_id) -> List[Track]:
+ """Get playlist tracks for an in-library playlist."""
+ query = (
+ "SELECT TRACKS.*, PLAYLISTTRACKS.position "
+ "FROM [tracks] TRACKS "
+ "JOIN playlist_tracks PLAYLISTTRACKS ON TRACKS.item_id = PLAYLISTTRACKS.track_id "
+ f"WHERE PLAYLISTTRACKS.playlist_id = {item_id}"
+ )
+ return await self.mass.music.tracks.get_db_items(query)
+
+ async def add_db_playlist_track(
+ self, playlist_id: int, track_id: int, position: int
+ ) -> None:
+ """Add playlist track for an in-library playlist."""
+ return await self.mass.database.insert_or_replace(
+ "playlist_tracks",
+ {"playlist_id": playlist_id, "track_id": track_id, "position": position},
+ )
--- /dev/null
+"""Manage MediaItems of type Radio."""
+from __future__ import annotations
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list
+from music_assistant.helpers.json import json_serializer
+from music_assistant.models.media_controller import MediaControllerBase
+from music_assistant.models.media_items import MediaType, Radio
+
+
+class RadioController(MediaControllerBase[Radio]):
+ """Controller managing MediaItems of type Radio."""
+
+ db_table = "radios"
+ media_type = MediaType.RADIO
+ item_cls = Radio
+
+ async def setup(self):
+ """Async initialize of module."""
+ # prepare database
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {self.db_table}(
+ item_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ sort_name TEXT NOT NULL,
+ in_library BOOLEAN DEFAULT 0,
+ metadata json,
+ provider_ids json
+ );"""
+ )
+
+ async def get_radio_by_name(self, name: str) -> Radio | None:
+ """Get in-library radio by name."""
+ return await self.mass.database.get_row(self.db_table, {"name": name})
+
+ async def add(self, item: Radio) -> Radio:
+ """Add radio to local db and return the new database item."""
+ db_item = await self.add_db_item(item)
+ self.mass.signal_event(EventType.RADIO_ADDED, db_item)
+ return db_item
+
+ async def add_db_item(self, radio: Radio) -> Radio:
+ """Add a new radio record to the database."""
+ if not radio.sort_name:
+ radio.sort_name = create_sort_name(radio.name)
+ match = {"sort_name": radio.sort_name}
+ if cur_item := await self.mass.database.get_row(self.db_table, match):
+ # update existing
+ return await self.update_db_radio(cur_item["item_id"], radio)
+
+ # insert new radio
+ new_item = await self.mass.database.insert_or_replace(
+ self.db_table, radio.to_db_row()
+ )
+ item_id = new_item["item_id"]
+ # store provider mappings
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.RADIO, radio.provider_ids
+ )
+ self.logger.debug("added %s to database", radio.name)
+ # return created object
+ return await self.get_db_item(item_id)
+
+ async def update_db_radio(self, item_id: int, radio: Radio) -> Radio:
+ """Update Radio record in the database."""
+ cur_item = await self.get_db_item(item_id)
+ metadata = merge_dict(cur_item.metadata, radio.metadata)
+ provider_ids = merge_list(cur_item.provider_ids, radio.provider_ids)
+ if not radio.sort_name:
+ radio.sort_name = create_sort_name(radio.name)
+
+ match = {"item_id": item_id}
+ await self.mass.database.update(
+ self.db_table,
+ match,
+ {
+ "name": radio.name,
+ "sort_name": radio.sort_name,
+ "metadata": json_serializer(metadata),
+ "provider_ids": json_serializer(provider_ids),
+ },
+ )
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.RADIO, radio.provider_ids
+ )
+ self.logger.debug("updated %s in database: %s", radio.name, item_id)
+ return await self.get_db_item(item_id)
--- /dev/null
+"""Manage MediaItems of type Track."""
+from __future__ import annotations
+
+import asyncio
+from typing import List
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.compare import (
+ compare_artists,
+ compare_strings,
+ compare_track,
+)
+from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list
+from music_assistant.helpers.json import json_serializer
+from music_assistant.models.media_controller import MediaControllerBase
+from music_assistant.models.media_items import (
+ ItemMapping,
+ MediaType,
+ Track,
+)
+
+
+class TracksController(MediaControllerBase[Track]):
+ """Controller managing MediaItems of type Track."""
+
+ db_table = "tracks"
+ media_type = MediaType.TRACK
+ item_cls = Track
+
+ async def setup(self) -> None:
+ """Async initialize of module."""
+ # prepare database
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {self.db_table}(
+ item_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ sort_name TEXT NOT NULL,
+ version TEXT,
+ duration INTEGER,
+ in_library BOOLEAN DEFAULT 0,
+ isrc TEXT,
+ artists json,
+ metadata json,
+ provider_ids json
+ );"""
+ )
+
+ async def add(self, item: Track) -> Track:
+ """Add track to local db and return the new database item."""
+ # make sure we have artists
+ assert item.artists
+ db_item = await self.add_db_item(item)
+ # also fetch same track on all providers (will also get other quality versions)
+ await self._match(db_item)
+ db_item = await self.get_db_item(db_item.item_id)
+ self.mass.signal_event(EventType.TRACK_ADDED, db_item)
+ return db_item
+
+ async def versions(self, item_id: str, provider_id: str) -> List[Track]:
+ """Return all versions of a track we can find on all providers."""
+ track = await self.get(item_id, provider_id)
+ provider_ids = {item.id for item in self.mass.music.providers}
+ first_artist = next(iter(track.artists))
+ search_query = f"{first_artist.name} {track.name}"
+ return [
+ prov_item
+ for prov_items in await asyncio.gather(
+ *[self.search(search_query, prov_id) for prov_id in provider_ids]
+ )
+ for prov_item in prov_items
+ if compare_artists(prov_item.artists, track.artists)
+ ]
+
+ async def _match(self, db_track: Track) -> None:
+ """
+ Try to find matching track on all providers for the provided (database) track_id.
+
+ This is used to link objects of different providers/qualities together.
+ """
+ if db_track.provider != "database":
+ return # Matching only supported for database items
+ if isinstance(db_track.album, ItemMapping):
+ # matching only works if we have a full track object
+ db_track = await self.get_db_item(db_track.item_id)
+ for provider in self.mass.music.providers:
+ if MediaType.TRACK not in provider.supported_mediatypes:
+ continue
+ self.logger.debug(
+ "Trying to match track %s on provider %s", db_track.name, provider.name
+ )
+ match_found = False
+ for db_track_artist in db_track.artists:
+ if match_found:
+ break
+ searchstr = f"{db_track_artist.name} {db_track.name}"
+ if db_track.version:
+ searchstr += " " + db_track.version
+ search_result = await self.search(searchstr, provider.id)
+ for search_result_item in search_result:
+ if not search_result_item.available:
+ continue
+ if compare_track(search_result_item, db_track):
+ # 100% match, we can simply update the db with additional provider ids
+ match_found = True
+ await self.update_db_track(db_track.item_id, search_result_item)
+ # while we're here, also match the artist
+ if db_track_artist.provider == "database":
+ for artist in search_result_item.artists:
+ if not compare_strings(
+ db_track_artist.name, artist.name
+ ):
+ continue
+ prov_artist = (
+ await self.mass.music.artists.get_provider_item(
+ artist.item_id, artist.provider
+ )
+ )
+ await self.mass.music.artists.update_db_artist(
+ db_track_artist.item_id, prov_artist
+ )
+
+ if not match_found:
+ self.logger.debug(
+ "Could not find match for Track %s on provider %s",
+ db_track.name,
+ provider.name,
+ )
+
+ async def add_db_item(self, track: Track) -> Track:
+ """Add a new track record to the database."""
+ assert track.artists, "Track is missing artist(s)"
+ if not track.sort_name:
+ track.sort_name = create_sort_name(track.name)
+ cur_item = None
+ # always try to grab existing item by external_id
+ if track.isrc:
+ match = {"isrc": track.isrc}
+ cur_item = await self.mass.database.get_row(self.db_table, match)
+ if not cur_item:
+ # fallback to matching
+ match = {"sort_name": track.sort_name}
+ for row in await self.mass.database.get_rows(self.db_table, match):
+ row_track = Track.from_db_row(row)
+ if compare_track(row_track, track):
+ cur_item = row_track
+ break
+ if cur_item:
+ # update existing
+ return await self.update_db_track(cur_item.item_id, track)
+
+ # no existing match found: insert new track
+ track_artists = await self._get_track_artists(track)
+ new_item = await self.mass.database.insert_or_replace(
+ self.db_table,
+ {
+ **track.to_db_row(),
+ "artists": json_serializer(track_artists),
+ },
+ )
+ item_id = new_item["item_id"]
+ # store provider mappings
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.TRACK, track.provider_ids
+ )
+
+ # add track to album_tracks
+ if track.album is not None:
+ album = await self.get_db_item_by_prov_id(
+ track.album.provider, track.album.item_id
+ ) or await self.mass.music.albums.add_db_item(track.album)
+ if (
+ album
+ and track.disc_number is not None
+ and track.track_number is not None
+ ):
+ await self.mass.music.albums.add_db_album_track(
+ album.item_id, item_id, track.disc_number, track.track_number
+ )
+ # return created object
+ return await self.get_db_item(item_id)
+
+ async def update_db_track(self, item_id: int, track: Track) -> Track:
+ """Update Track record in the database."""
+ cur_item = await self.get_db_item(item_id)
+ metadata = merge_dict(cur_item.metadata, track.metadata)
+ provider_ids = merge_list(cur_item.provider_ids, track.provider_ids)
+ # we store a mapping to artists on the track for easier access/listings
+ track_artists = await self._get_track_artists(track, cur_item.artists)
+ match = {"item_id": item_id}
+ await self.mass.database.update(
+ self.db_table,
+ match,
+ {
+ "artists": json_serializer(track_artists),
+ "metadata": json_serializer(metadata),
+ "provider_ids": json_serializer(provider_ids),
+ "isrc": cur_item.isrc or track.isrc,
+ "duration": cur_item.duration or track.duration,
+ },
+ )
+ await self.mass.music.add_provider_mappings(
+ item_id, MediaType.TRACK, track.provider_ids
+ )
+ # add track to album_tracks
+ if (
+ track.album is not None
+ and track.disc_number is not None
+ and track.track_number is not None
+ ):
+ album = await self.get_db_item_by_prov_id(
+ track.album.provider, track.album.item_id
+ ) or await self.mass.music.albums.add_db_item(track.album)
+ if album:
+ await self.mass.music.albums.add_db_album_track(
+ album.item_id, item_id, track.disc_number, track.track_number
+ )
+ self.logger.debug("updated %s in database: %s", track.name, item_id)
+ return await self.get_db_item(item_id)
+
+ async def _get_track_artists(
+ self, track: Track, cur_artists: List[ItemMapping] | None = None
+ ) -> List[ItemMapping]:
+ """Extract all (unique) artists of track as ItemMapping."""
+ if cur_artists is None:
+ cur_artists = []
+ cur_artists.extend(track.artists)
+ track_artists: List[ItemMapping] = []
+ for item in cur_artists:
+ cur_names = {x.name for x in track_artists}
+ cur_ids = {x.item_id for x in track_artists}
+ track_artist = (
+ await self.mass.music.artists.get_db_item_by_prov_id(
+ item.provider, item.item_id
+ )
+ or item
+ )
+ if (
+ track_artist.name not in cur_names
+ and track_artist.item_id not in cur_ids
+ ):
+ track_artists.append(ItemMapping.from_item(track_artist))
+ return track_artists
--- /dev/null
+"""Logic to play music from MusicProviders to supported players."""
+from __future__ import annotations
+
+from typing import Dict, Tuple
+
+from music_assistant.constants import EventType
+from music_assistant.controllers.stream import StreamController
+from music_assistant.models.errors import AlreadyRegisteredError
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.models.player import Player, PlayerGroup
+from music_assistant.models.player_queue import PlayerQueue
+
+PlayerType = Player | PlayerGroup
+
+DB_TABLE = "queue_settings"
+
+
+class PlayerController:
+ """Controller holding all logic to play music from MusicProviders to supported players."""
+
+ def __init__(self, mass: MusicAssistant, stream_port: int) -> None:
+ """Initialize class."""
+ self.mass = mass
+ self.logger = mass.logger.getChild("players")
+ self._players: Dict[str, PlayerType] = {}
+ self._player_queues: Dict[str, PlayerQueue] = {}
+ self.streams = StreamController(mass, stream_port)
+
+ async def setup(self) -> None:
+ """Async initialize of module."""
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ """CREATE TABLE IF NOT EXISTS queue_settings(
+ queue_id TEXT UNIQUE,
+ crossfade_duration INTEGER,
+ shuffle_enabled BOOLEAN,
+ repeat_enabled BOOLEAN,
+ volume_normalization_enabled BOOLEAN,
+ volume_normalization_target INTEGER)"""
+ )
+ await self.streams.setup()
+
+ @property
+ def players(self) -> Tuple[PlayerType]:
+ """Return all available players."""
+ return tuple(x for x in self._players.values() if x.available)
+
+ @property
+ def player_queues(self) -> Tuple[PlayerQueue]:
+ """Return all available PlayerQueue's."""
+ return tuple(x for x in self._player_queues.values() if x.available)
+
+ def __iter__(self):
+ """Iterate over (available) players."""
+ return iter(x for x in self._players.values() if x.available)
+
+ def get_player(
+ self, player_id: str, include_unavailable: bool = False
+ ) -> PlayerType | None:
+ """Return Player by player_id or None if not found/unavailable."""
+ if player := self._players.get(player_id):
+ if player.available or include_unavailable:
+ return player
+ return None
+
+ def get_player_queue(
+ self, queue_id: str, include_unavailable: bool = False
+ ) -> PlayerQueue | None:
+ """Return PlayerQueue by id or None if not found/unavailable."""
+ if player_queue := self._player_queues.get(queue_id):
+ if player_queue.available or include_unavailable:
+ return player_queue
+ return None
+
+ def get_player_by_name(self, name: str) -> PlayerType | None:
+ """Return Player by name or None if no match is found."""
+ return next((x for x in self._players.values() if x.name == name), None)
+
+ async def register_player(self, player: PlayerType) -> None:
+ """Register a new player on the controller."""
+ player_id = player.player_id
+
+ if player_id in self._players:
+ raise AlreadyRegisteredError(f"Player {player_id} is already registered")
+
+ # make sure that the mass instance is set on the player
+ player.mass = self.mass
+ self._players[player_id] = player
+
+ # create playerqueue for this player
+ self._player_queues[player.player_id] = player_queue = PlayerQueue(
+ self.mass, player_id
+ )
+ await player_queue.setup()
+ self.logger.info(
+ "Player registered: %s/%s",
+ player_id,
+ player.name,
+ )
+ self.mass.signal_event(EventType.PLAYER_ADDED, player)
--- /dev/null
+"""Controller to stream audio to players."""
+from __future__ import annotations
+
+import asyncio
+from asyncio import Task
+from dataclasses import dataclass
+
+from typing import AsyncGenerator, Awaitable, Callable, Dict, List
+from time import time
+from uuid import uuid4
+from aiohttp import web
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.audio import (
+ check_audio_support,
+ create_wave_header,
+ crossfade_pcm_parts,
+ get_media_stream,
+ get_sox_args_for_pcm_stream,
+ get_stream_details,
+ strip_silence,
+)
+from music_assistant.helpers.process import AsyncProcess
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import get_ip
+from music_assistant.models.media_items import ContentType
+from music_assistant.models.player_queue import PlayerQueue
+
+
+@dataclass(frozen=True)
+class PCMArgs:
+ """Specify raw pcm audio."""
+
+ sample_rate: int
+ bit_depth: int
+ channels: int
+
+
+class StreamController:
+ """Controller to stream audio to players."""
+
+ def __init__(self, mass: MusicAssistant, port: int = 8095):
+ """Initialize instance."""
+ self.mass = mass
+ self.logger = mass.logger.getChild("stream")
+ self._port = port
+ self._ip: str = get_ip()
+ self._subscribers: Dict[str, Dict[str, List[Callable]]] = {}
+ self._stream_tasks: Dict[str, Task] = {}
+ self._pcmargs: Dict[str, PCMArgs] = {}
+
+ def get_stream_url(self, queue_id: str) -> str:
+ """Return the full stream url for the PlayerQueue Stream."""
+ return f"http://{self._ip}:{self._port}/{queue_id}.flac"
+
+ async def setup(self) -> None:
+ """Async initialize of module."""
+ app = web.Application()
+
+ app.router.add_get("/{queue_id}.wav", self.serve_stream_client_pcm)
+ app.router.add_get("/{queue_id}.{format}", self.serve_stream_client)
+ app.router.add_get("/{queue_id}", self.serve_stream_client)
+
+ runner = web.AppRunner(app, access_log=None)
+ await runner.setup()
+ # set host to None to bind to all addresses on both IPv4 and IPv6
+ http_site = web.TCPSite(
+ runner, host=None, port=self._port, reuse_address=True, reuse_port=True
+ )
+ await http_site.start()
+
+ async def on_shutdown_event(*args, **kwargs):
+ """Handle shutdown event."""
+ for subscribers in self._subscribers.values():
+ for callback in subscribers.values():
+ await callback(b"")
+ for task in self._stream_tasks.values():
+ task.cancel()
+ await http_site.stop()
+ await runner.shutdown()
+ await app.shutdown()
+ await app.cleanup()
+ self.logger.info("Streamserver exited.")
+
+ self.mass.subscribe(on_shutdown_event, EventType.SHUTDOWN)
+
+ sox_present, ffmpeg_present = await check_audio_support(True)
+ if not ffmpeg_present:
+ self.logger.error(
+ "The FFmpeg binary was not found on your system, "
+ "you might have issues with playback. "
+ "Please install FFmpeg with your OS package manager.",
+ )
+ elif not sox_present:
+ self.logger.warning(
+ "The SoX binary was not found on your system so FFmpeg is used as fallback. "
+ "For best audio quality, please install SoX with your OS package manager.",
+ )
+
+ self.logger.info("Started stream server on port %s", self._port)
+
+ async def serve_stream_client(self, request: web.Request):
+ """Serve queue audio stream to client (encoded to FLAC or MP3)."""
+ queue_id = request.match_info["queue_id"]
+ clientid = f'{request.remote}_{request.query.get("playerid", str(uuid4()))}'
+ fmt = request.match_info.get("format", "flac")
+
+ if self.mass.players.get_player_queue(queue_id) is None:
+ return web.Response(status=404)
+
+ # prepare request
+ resp = web.StreamResponse(
+ status=200, reason="OK", headers={"Content-Type": f"audio/{fmt}"}
+ )
+ await resp.prepare(request)
+
+ pcmargs = await self._get_queue_stream_pcm_args(queue_id)
+ output_fmt = ContentType(fmt)
+ sox_args = await get_sox_args_for_pcm_stream(
+ pcmargs.sample_rate,
+ pcmargs.bit_depth,
+ pcmargs.channels,
+ output_format=output_fmt,
+ )
+ try:
+ # get the raw pcm bytes from the queue stream and on the fly encode as flac
+ # send the flac endoded stream to the subscribers.
+ async with AsyncProcess(sox_args, True) as sox_proc:
+
+ async def reader():
+ # task that reads flac endoded chunks from the subprocess
+ self.logger.debug("start reader task")
+ chunksize = 32000 if output_fmt == ContentType.MP3 else 256000
+ async for audio_chunk in sox_proc.iterate_chunks(chunksize):
+ await resp.write(audio_chunk)
+ self.logger.debug("reader task finished")
+
+ # feed raw pcm chunks into sox/ffmpeg to encode to flac
+ async def audio_callback(audio_chunk):
+ if audio_chunk == b"":
+ self.logger.debug("last chunk received from stream")
+ sox_proc.write_eof()
+ return
+ await sox_proc.write(audio_chunk)
+
+ # wait for the output task to complete
+ await self.subscribe(queue_id, clientid, audio_callback)
+ await reader()
+
+ finally:
+ await self.unsubscribe(queue_id, clientid)
+ return resp
+
+ async def serve_stream_client_pcm(self, request: web.Request):
+ """Serve queue audio stream to client in the raw PCM format."""
+ queue_id = request.match_info["queue_id"]
+ queue = self.mass.players.get_player_queue(queue_id)
+ clientid = f'{request.remote}_{request.query.get("playerid", str(uuid4()))}'
+
+ if queue is None:
+ return web.Response(status=404)
+
+ # prepare request
+ pcmargs = await self._get_queue_stream_pcm_args(queue_id, 32)
+ fmt = f"x-wav;codec=pcm;rate={pcmargs.sample_rate};bitrate={pcmargs.bit_depth};channels={pcmargs.channels}"
+ resp = web.StreamResponse(
+ status=200,
+ reason="OK",
+ headers={"Content-Type": f"audio/{fmt}"},
+ )
+ await resp.prepare(request)
+
+ # write wave header
+ wav_header = create_wave_header(
+ pcmargs.sample_rate,
+ pcmargs.channels,
+ pcmargs.bit_depth,
+ )
+ await resp.write(wav_header)
+
+ # start delivering audio chunks
+ last_chunk_received = asyncio.Event()
+ try:
+
+ async def audio_callback(audio_chunk):
+ if audio_chunk == b"":
+ last_chunk_received.set()
+ return
+ try:
+ await resp.write(audio_chunk)
+ except BrokenPipeError:
+ pass # race condition
+
+ await self.subscribe(queue_id, clientid, audio_callback)
+ await last_chunk_received.wait()
+ finally:
+ await self.unsubscribe(queue_id, clientid)
+ return resp
+
+ async def subscribe(
+ self, queue_id: str, clientid: str, callback: Awaitable
+ ) -> None:
+ """Subscribe client to queue stream."""
+ self._subscribers.setdefault(queue_id, {})
+ if queue_id in self._subscribers[queue_id]:
+ # client is already subscribed ?
+ await self.unsubscribe(queue_id, clientid)
+ self._subscribers[queue_id][clientid] = callback
+ stream_task = self._stream_tasks.get(queue_id)
+ if not stream_task or stream_task.cancelled():
+ # first connect, start the stream task
+ task = asyncio.create_task(self.start_queue_stream(queue_id))
+
+ def task_done_callback(*args, **kwargs):
+ self._stream_tasks.pop(queue_id, None)
+
+ task.add_done_callback(task_done_callback)
+ self._stream_tasks[queue_id] = task
+
+ self.logger.debug("Subscribed client %s to queue stream %s", clientid, queue_id)
+
+ async def unsubscribe(self, queue_id: str, clientid: str):
+ """Unsubscribe client from queue stream."""
+ self._subscribers[queue_id].pop(clientid, None)
+ self.logger.debug(
+ "Unsubscribed client %s from queue stream %s", clientid, queue_id
+ )
+ if len(self._subscribers[queue_id]) == 0:
+ # no more clients, cancel stream task
+ self.logger.debug(
+ "Aborted queue stream %s due to no more clients", queue_id
+ )
+ if task := self._stream_tasks.pop(queue_id, None):
+ task.cancel()
+ self._pcmargs.pop(queue_id, None)
+
+ async def start_queue_stream(self, queue_id: str) -> None:
+ """Start the Queue stream feeding callbacks of listeners.."""
+ queue = self.mass.players.get_player_queue(queue_id)
+ pcmargs = await self._get_queue_stream_pcm_args(queue_id)
+
+ self.logger.info(
+ "Starting Queue stream for Queue %s with args: %s", queue_id, pcmargs
+ )
+ async for chunk in self._get_queue_stream(
+ queue,
+ pcmargs.sample_rate,
+ pcmargs.bit_depth,
+ pcmargs.channels,
+ ):
+ if len(self._subscribers[queue_id].values()) == 0:
+ self.logger.info("Queue stream for Queue %s aborted", queue_id)
+ return
+ await asyncio.gather(
+ *[cb(chunk) for cb in list(self._subscribers[queue_id].values())]
+ )
+ self.logger.info("Queue stream for Queue %s finished.", queue_id)
+ # send empty chunk to inform EOF
+ await asyncio.gather(
+ *[cb(b"") for cb in list(self._subscribers[queue_id].values())]
+ )
+
+ async def _get_queue_stream_pcm_args(
+ self, queue_id: str, forced_bit_depth: int = None
+ ) -> PCMArgs:
+ """Return the current/ext PCM args for the queue stream."""
+ if queue_id in self._pcmargs:
+ return self._pcmargs[queue_id]
+ queue = self.mass.players.get_player_queue(queue_id)
+ next_streamdetails = await queue.queue_stream_prepare()
+ pcmargs = PCMArgs(
+ sample_rate=min(next_streamdetails.sample_rate, queue.max_sample_rate),
+ bit_depth=forced_bit_depth or next_streamdetails.bit_depth,
+ channels=2,
+ )
+ self._pcmargs[queue_id] = pcmargs
+ return pcmargs
+
+ async def _get_queue_stream(
+ self, queue: PlayerQueue, sample_rate: int, bit_depth: int, channels: int = 2
+ ) -> AsyncGenerator[None, bytes]:
+ """Stream the PlayerQueue's tracks as constant feed of PCM raw audio."""
+ last_fadeout_data = b""
+ queue_index = None
+ track_count = 0
+ start_timestamp = time()
+
+ # stream queue tracks one by one
+ while True:
+ # get the (next) track in queue
+ track_count += 1
+ if track_count == 1:
+ # report start of queue playback so we can calculate current track/duration etc.
+ queue_index = await queue.queue_stream_start()
+ else:
+ queue_index = await queue.queue_stream_next(queue_index)
+ queue_track = queue.get_item(queue_index)
+ if not queue_track:
+ self.logger.debug("no (more) tracks in queue %s", queue.queue_id)
+ break
+ # get streamdetails
+ streamdetails = await get_stream_details(
+ self.mass, queue_track, queue.queue_id, lazy=track_count == 1
+ )
+ if not streamdetails:
+ self.logger.warning("Skip track due to missing streamdetails")
+ continue
+
+ # get the PCM samplerate/bitrate
+ if streamdetails.bit_depth > bit_depth:
+ await queue.queue_stream_signal_next()
+ self.logger.debug("Abort queue stream due to bit depth mismatch")
+ await queue.queue_stream_signal_next()
+ break
+ if (
+ streamdetails.sample_rate > sample_rate
+ and streamdetails.sample_rate <= queue.max_sample_rate
+ ):
+ self.logger.debug("Abort queue stream due to sample rate mismatch")
+ await queue.queue_stream_signal_next()
+ break
+
+ pcm_fmt = ContentType.from_bit_depth(bit_depth)
+ sample_size = int(sample_rate * (bit_depth / 8) * channels) # 1 second
+ buffer_size = sample_size * (
+ queue.crossfade_duration or 1
+ ) # 1...10 seconds
+
+ self.logger.debug(
+ "Start Streaming queue track: %s (%s) for player %s - PCM format: %s - rate: %s",
+ queue_track.item_id,
+ queue_track.name,
+ queue.player.name,
+ pcm_fmt.value,
+ sample_rate,
+ )
+ fade_in_part = b""
+ cur_chunk = 0
+ prev_chunk = None
+ bytes_written = 0
+ # handle incoming audio chunks
+ async for is_last_chunk, chunk in get_media_stream(
+ self.mass,
+ streamdetails,
+ pcm_fmt,
+ resample=sample_rate,
+ chunk_size=buffer_size,
+ ):
+ cur_chunk += 1
+
+ # HANDLE FIRST PART OF TRACK
+ if not chunk and bytes_written == 0:
+ # stream error: got empy first chunk
+ self.logger.error("Stream error on track %s", queue_track.item_id)
+ # prevent player queue get stuck by just skipping to the next track
+ queue_track.duration = 0
+ continue
+ if cur_chunk <= 2 and not last_fadeout_data:
+ # no fadeout_part available so just pass it to the output directly
+ yield chunk
+ bytes_written += len(chunk)
+ del chunk
+ elif cur_chunk == 1 and last_fadeout_data:
+ prev_chunk = chunk
+ del chunk
+ # HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN
+ elif cur_chunk == 2 and last_fadeout_data:
+ # combine the first 2 chunks and strip off silence
+ first_part = await strip_silence(
+ prev_chunk + chunk, pcm_fmt, sample_rate
+ )
+ if len(first_part) < buffer_size:
+ # part is too short after the strip action?!
+ # so we just use the full first part
+ first_part = prev_chunk + chunk
+ fade_in_part = first_part[:buffer_size]
+ remaining_bytes = first_part[buffer_size:]
+ del first_part
+ # do crossfade
+ crossfade_part = await crossfade_pcm_parts(
+ fade_in_part,
+ last_fadeout_data,
+ queue.crossfade_duration,
+ pcm_fmt,
+ sample_rate,
+ )
+ # send crossfade_part
+ yield crossfade_part
+ bytes_written += len(crossfade_part)
+ del crossfade_part
+ del fade_in_part
+ last_fadeout_data = b""
+ # also write the leftover bytes from the strip action
+ yield remaining_bytes
+ bytes_written += len(remaining_bytes)
+ del remaining_bytes
+ del chunk
+ prev_chunk = None # needed to prevent this chunk being sent again
+ # HANDLE LAST PART OF TRACK
+ elif prev_chunk and is_last_chunk:
+ # last chunk received so create the last_part
+ # with the previous chunk and this chunk
+ # and strip off silence
+ last_part = await strip_silence(
+ prev_chunk + chunk, pcm_fmt, sample_rate, True
+ )
+ if len(last_part) < buffer_size:
+ # part is too short after the strip action
+ # so we just use the entire original data
+ last_part = prev_chunk + chunk
+ if not queue.crossfade_duration or len(last_part) < buffer_size:
+ # crossfading is not enabled or not enough data,
+ # so just pass the (stripped) audio data
+ if queue.crossfade_duration:
+ self.logger.warning(
+ "Not enough data for crossfade: %s", len(last_part)
+ )
+ yield last_part
+ bytes_written += len(last_part)
+ del last_part
+ del chunk
+ else:
+ # handle crossfading support
+ # store fade section to be picked up for next track
+ last_fadeout_data = last_part[-buffer_size:]
+ remaining_bytes = last_part[:-buffer_size]
+ # write remaining bytes
+ if remaining_bytes:
+ yield remaining_bytes
+ bytes_written += len(remaining_bytes)
+ del last_part
+ del remaining_bytes
+ del chunk
+ # MIDDLE PARTS OF TRACK
+ else:
+ # middle part of the track
+ # keep previous chunk in memory so we have enough
+ # samples to perform the crossfade
+ if prev_chunk:
+ yield prev_chunk
+ bytes_written += len(prev_chunk)
+ prev_chunk = chunk
+ else:
+ prev_chunk = chunk
+ del chunk
+ # guard for clients buffering too much
+ seconds_streamed = bytes_written / sample_size
+ seconds_per_chunk = buffer_size / sample_size
+ seconds_needed = int(time() - start_timestamp + seconds_per_chunk)
+ if (seconds_streamed) > seconds_needed:
+ self.logger.debug("cooldown %s seconds", seconds_per_chunk / 2)
+ await asyncio.sleep(seconds_per_chunk / 2)
+ # end of the track reached
+ # update actual duration to the queue for more accurate now playing info
+ accurate_duration = bytes_written / sample_size
+ queue_track.duration = accurate_duration
+ self.logger.debug(
+ "Finished Streaming queue track: %s (%s) on queue %s",
+ queue_track.item_id,
+ queue_track.name,
+ queue.player.name,
+ )
+ # end of queue reached, pass last fadeout bits to final output
+ yield last_fadeout_data
+ # END OF QUEUE STREAM
import logging
import struct
from io import BytesIO
-from typing import List, Optional, Tuple
+from typing import AsyncGenerator, List, Optional, Tuple
-from music_assistant.helpers.process import AsyncProcess
+import aiofiles
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.process import AsyncProcess, check_output
from music_assistant.helpers.typing import MusicAssistant, QueueItem
from music_assistant.helpers.util import create_tempfile
-from music_assistant.models.media_types import MediaType
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
+from music_assistant.models.errors import AudioError
+from music_assistant.models.media_items import (
+ ContentType,
+ MediaType,
+ StreamDetails,
+ StreamType,
+)
LOGGER = logging.getLogger("audio")
+# pylint:disable=consider-using-f-string
+
async def crossfade_pcm_parts(
- fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int
+ fade_in_part: bytes,
+ fade_out_part: bytes,
+ fade_length: int,
+ fmt: ContentType,
+ sample_rate: int,
) -> bytes:
"""Crossfade two chunks of pcm/raw audio using sox."""
+ _, ffmpeg_present = await check_audio_support()
+
+ # prefer ffmpeg implementation (due to simplicity)
+ if ffmpeg_present:
+ fadeoutfile = create_tempfile()
+ async with aiofiles.open(fadeoutfile.name, "wb") as outfile:
+ await outfile.write(fade_out_part)
+ # input args
+ args = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
+ args += [
+ "-f",
+ fmt.value,
+ "-ac",
+ "2",
+ "-ar",
+ str(sample_rate),
+ "-i",
+ fadeoutfile.name,
+ ]
+ args += ["-f", fmt.value, "-ac", "2", "-ar", str(sample_rate), "-i", "-"]
+ # filter args
+ args += ["-filter_complex", f"[0][1]acrossfade=d={fade_length}"]
+ # output args
+ args += ["-f", fmt.value, "-"]
+ async with AsyncProcess(args, True) as proc:
+ crossfade_data, _ = await proc.communicate(fade_in_part)
+ return crossfade_data
+
+ # sox based implementation
+ sox_args = [fmt.sox_format(), "-c", "2", "-r", str(sample_rate)]
# create fade-in part
fadeinfile = create_tempfile()
- args = ["sox", "--ignore-length", "-t"] + pcm_args
- args += ["-", "-t"] + pcm_args + [fadeinfile.name, "fade", "t", str(fade_length)]
+ args = ["sox", "--ignore-length", "-t"] + sox_args
+ args += ["-", "-t"] + sox_args + [fadeinfile.name, "fade", "t", str(fade_length)]
async with AsyncProcess(args, enable_write=True) as sox_proc:
await sox_proc.communicate(fade_in_part)
# create fade-out part
fadeoutfile = create_tempfile()
- args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args
+ args = ["sox", "--ignore-length", "-t"] + sox_args + ["-", "-t"] + sox_args
args += [fadeoutfile.name, "reverse", "fade", "t", str(fade_length), "reverse"]
async with AsyncProcess(args, enable_write=True) as sox_proc:
await sox_proc.communicate(fade_out_part)
# create crossfade using sox and some temp files
# TODO: figure out how to make this less complex and without the tempfiles
- args = ["sox", "-m", "-v", "1.0", "-t"] + pcm_args + [fadeoutfile.name, "-v", "1.0"]
- args += ["-t"] + pcm_args + [fadeinfile.name, "-t"] + pcm_args + ["-"]
+ args = ["sox", "-m", "-v", "1.0", "-t"] + sox_args + [fadeoutfile.name, "-v", "1.0"]
+ args += ["-t"] + sox_args + [fadeinfile.name, "-t"] + sox_args + ["-"]
async with AsyncProcess(args, enable_write=False) as sox_proc:
crossfade_part, _ = await sox_proc.communicate()
fadeinfile.close()
return crossfade_part
-async def strip_silence(audio_data: bytes, pcm_args: List[str], reverse=False) -> bytes:
+async def strip_silence(
+ audio_data: bytes, fmt: ContentType, sample_rate: int, reverse=False
+) -> bytes:
"""Strip silence from (a chunk of) pcm audio."""
- args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"]
+ _, ffmpeg_present = await check_audio_support()
+ # prefer ffmpeg implementation
+ if ffmpeg_present:
+ # input args
+ args = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
+ args += ["-f", fmt.value, "-ac", "2", "-ar", str(sample_rate), "-i", "-"]
+ # filter args
+ if reverse:
+ args += ["-af", "areverse,silenceremove=1:0:-50dB:detection=peak,areverse"]
+ else:
+ args += ["-af", "silenceremove=1:0:-50dB:detection=peak"]
+ # output args
+ args += ["-f", fmt.value, "-"]
+ async with AsyncProcess(args, True) as proc:
+ stripped_data, _ = await proc.communicate(audio_data)
+ return stripped_data
+
+ # sox implementation
+ sox_args = [fmt.sox_format(), "-c", "2", "-r", str(sample_rate)]
+ args = ["sox", "--ignore-length", "-t"] + sox_args + ["-", "-t"] + sox_args + ["-"]
if reverse:
args.append("reverse")
args += ["silence", "1", "0.1", "1%"]
)
value, _ = await proc.communicate(audio_data or None)
loudness = float(value.decode().strip())
- await mass.database.set_track_loudness(
+ await mass.music.set_track_loudness(
streamdetails.item_id, streamdetails.provider, loudness
)
LOGGER.debug(
async def get_stream_details(
- mass: MusicAssistant, queue_item: QueueItem, player_id: str = ""
-) -> StreamDetails:
+ mass: MusicAssistant, queue_item: QueueItem, queue_id: str = "", lazy: bool = True
+) -> StreamDetails | None:
"""
- Get streamdetails for the given media_item.
+ Get streamdetails for the given QueueItem.
- This is called just-in-time when a player/queue wants a MediaItem to be played.
+ This is called just-in-time when a PlayerQueue wants a MediaItem to be played.
Do not try to request streamdetails in advance as this is expiring data.
param media_item: The MediaItem (track/radio) for which to request the streamdetails for.
- param player_id: Optionally provide the player_id which will play this stream.
+ param queue_id: Optionally provide the queue_id which will play this stream.
"""
- if queue_item.provider == "url":
+ if not queue_item.is_media_item:
# special case: a plain url was added to the queue
streamdetails = StreamDetails(
type=StreamType.URL,
provider="url",
item_id=queue_item.item_id,
- path=queue_item.uri if queue_item.uri else queue_item.item_id,
+ path=queue_item.uri,
content_type=ContentType(queue_item.uri.split(".")[-1]),
)
else:
# always request the full db track as there might be other qualities available
- # except for radio
- if queue_item.media_type == MediaType.RADIO:
- full_track = await mass.music.get_radio(
- queue_item.item_id, queue_item.provider
- )
- else:
- full_track = await mass.music.get_track(
- queue_item.item_id, queue_item.provider
- )
- if not full_track:
+ full_item = await mass.music.get_item_by_uri(
+ queue_item.uri, force_refresh=not lazy, lazy=lazy
+ )
+ if not full_item:
return None
# sort by quality and check track availability
for prov_media in sorted(
- full_track.provider_ids, key=lambda x: x.quality, reverse=True
+ full_item.provider_ids, key=lambda x: x.quality, reverse=True
):
if not prov_media.available:
continue
# get streamdetails from provider
- music_prov = mass.get_provider(prov_media.provider)
+ music_prov = mass.music.get_provider(prov_media.provider)
if not music_prov or not music_prov.available:
continue # provider temporary unavailable ?
if streamdetails:
# set player_id on the streamdetails so we know what players stream
- streamdetails.player_id = player_id
+ streamdetails.queue_id = queue_id
# get gain correct / replaygain
- if queue_item.name == "alert":
- loudness = 5
- gain_correct = 0
- else:
- loudness, gain_correct = await get_gain_correct(
- mass, player_id, streamdetails.item_id, streamdetails.provider
- )
+ loudness, gain_correct = await get_gain_correct(
+ mass, queue_id, streamdetails.item_id, streamdetails.provider
+ )
streamdetails.gain_correct = gain_correct
streamdetails.loudness = loudness
# set streamdetails as attribute on the media_item
async def get_gain_correct(
- mass: MusicAssistant, player_id: str, item_id: str, provider_id: str
+ mass: MusicAssistant, queue_id: str, item_id: str, provider_id: str
) -> Tuple[float, float]:
- """Get gain correction for given player / track combination."""
- player_conf = mass.config.get_player_config(player_id)
- if not player_conf["volume_normalisation"]:
+ """Get gain correction for given queue / track combination."""
+ queue = mass.players.get_player_queue(queue_id, True)
+ if not queue or not queue.volume_normalization_enabled:
return 0
- target_gain = int(player_conf["target_volume"])
- track_loudness = await mass.database.get_track_loudness(item_id, provider_id)
+ target_gain = queue.volume_normalization_target
+ track_loudness = await mass.music.get_track_loudness(item_id, provider_id)
if track_loudness is None:
# fallback to provider average
- fallback_track_loudness = await mass.database.get_provider_loudness(provider_id)
+ fallback_track_loudness = await mass.music.get_provider_loudness(provider_id)
if fallback_track_loudness is None:
# fallback to some (hopefully sane) average value for now
fallback_track_loudness = -8.5
return (track_loudness, gain_correct)
-def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=3600):
+def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=1800):
"""Generate a wave header from given params."""
# pylint: disable=no-member
file = BytesIO()
return file.getvalue()
-def get_sox_args(
+async def get_sox_args(
streamdetails: StreamDetails,
output_format: Optional[ContentType] = None,
resample: Optional[int] = None,
-):
+) -> List[str]:
"""Collect all args to send to the sox (or ffmpeg) process."""
stream_path = streamdetails.path
stream_type = StreamType(streamdetails.type)
if output_format is None:
output_format = streamdetails.content_type
+ sox_present, ffmpeg_present = await check_audio_support()
+
# use ffmpeg if content not supported by SoX (e.g. AAC radio streams)
- if not streamdetails.content_type.sox_supported():
+ if not sox_present or not streamdetails.content_type.sox_supported():
+ if not ffmpeg_present:
+ raise AudioError(
+ "FFmpeg binary is missing from system."
+ "Please install ffmpeg on your OS to enable playback.",
+ )
# collect input args
- input_args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", stream_path]
+ if stream_type == StreamType.EXECUTABLE:
+ # stream from executable
+ input_args = [
+ stream_path,
+ "|",
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ content_type.value,
+ "-i",
+ "-",
+ ]
+ else:
+ input_args = [
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ stream_path,
+ ]
# collect output args
if output_format.is_pcm():
output_args = [
# collect filter args
filter_args = []
if streamdetails.gain_correct:
- filter_args += ["-filter:a", "volume=%sdB" % streamdetails.gain_correct]
+ filter_args += ["-filter:a", f"volume={streamdetails.gain_correct}dB"]
if resample:
filter_args += ["-ar", str(resample)]
return input_args + filter_args + output_args
filter_args = []
if streamdetails.gain_correct:
filter_args += ["vol", str(streamdetails.gain_correct), "dB"]
- if resample and streamdetails.content_type == ContentType.FLAC:
+ if resample and streamdetails.media_type != MediaType.RADIO:
# use extra high quality resampler only if it makes sense
filter_args += ["rate", "-v", str(resample)]
elif resample:
filter_args += ["rate", str(resample)]
return input_args + output_args + filter_args
+
+
+async def get_media_stream(
+ mass: MusicAssistant,
+ streamdetails: StreamDetails,
+ output_format: Optional[ContentType] = None,
+ resample: Optional[int] = None,
+ chunk_size: Optional[int] = None,
+) -> AsyncGenerator[Tuple[bool, bytes], None]:
+ """Get the audio stream for the given streamdetails."""
+
+ mass.signal_event(EventType.STREAM_STARTED, streamdetails)
+ args = await get_sox_args(streamdetails, output_format, resample)
+ async with AsyncProcess(args) as sox_proc:
+
+ LOGGER.debug(
+ "start media stream for: %s/%s (%s)",
+ streamdetails.provider,
+ streamdetails.item_id,
+ streamdetails.type,
+ )
+
+ # yield chunks from stdout
+ # we keep 1 chunk behind to detect end of stream properly
+ try:
+ prev_chunk = b""
+ async for chunk in sox_proc.iterate_chunks(chunk_size):
+ if prev_chunk:
+ yield (False, prev_chunk)
+ prev_chunk = chunk
+ # send last chunk
+ yield (True, prev_chunk)
+ except (asyncio.CancelledError, GeneratorExit) as err:
+ LOGGER.debug(
+ "media stream aborted for: %s/%s",
+ streamdetails.provider,
+ streamdetails.item_id,
+ )
+ raise err
+ else:
+ LOGGER.debug(
+ "finished media stream for: %s/%s",
+ streamdetails.provider,
+ streamdetails.item_id,
+ )
+ await mass.music.mark_item_played(
+ streamdetails.item_id, streamdetails.provider
+ )
+ finally:
+ mass.signal_event(EventType.STREAM_ENDED, streamdetails)
+ # send analyze job to background worker
+ if streamdetails.loudness is None:
+ uri = f"{streamdetails.provider}://{streamdetails.media_type.value}/{streamdetails.item_id}"
+ mass.add_job(
+ analyze_audio(mass, streamdetails), f"Analyze audio for {uri}"
+ )
+
+
+async def check_audio_support(try_install: bool = False) -> Tuple[bool, bool, bool]:
+ """Check if sox and/or ffmpeg are present."""
+ cache_key = "audio_support_cache"
+ if cache := globals().get(cache_key):
+ return cache
+ # check for SoX presence
+ returncode, output = await check_output("sox --version")
+ sox_present = returncode == 0 and "SoX" in output.decode()
+ if not sox_present and try_install:
+ # try a few common ways to install SoX
+ # this all assumes we have enough rights and running on a linux based platform (or docker)
+ await check_output("apt-get update && apt-get install sox libsox-fmt-all")
+ await check_output("apk add sox")
+ # test again
+ returncode, output = await check_output("sox --version")
+ sox_present = returncode == 0 and "SoX" in output.decode()
+
+ # check for FFmpeg presence
+ returncode, output = await check_output("ffmpeg -version")
+ ffmpeg_present = returncode == 0 and "FFmpeg" in output.decode()
+ if not ffmpeg_present and try_install:
+ # try a few common ways to install SoX
+ # this all assumes we have enough rights and running on a linux based platform (or docker)
+ await check_output("apt-get update && apt-get install ffmpeg")
+ await check_output("apk add ffmpeg")
+ # test again
+ returncode, output = await check_output("ffmpeg -version")
+ ffmpeg_present = returncode == 0 and "FFmpeg" in output.decode()
+
+ # use globals as in-memory cache
+ result = (sox_present, ffmpeg_present)
+ globals()[cache_key] = result
+ return result
+
+
+async def get_sox_args_for_pcm_stream(
+ sample_rate: int,
+ bit_depth: int,
+ channels: int,
+ floating_point: bool = False,
+ output_format: ContentType = ContentType.FLAC,
+) -> List[str]:
+ """Collect args for aox (or ffmpeg) when converting from raw pcm to another contenttype."""
+
+ sox_present, ffmpeg_present = await check_audio_support()
+ input_format = ContentType.from_bit_depth(bit_depth, floating_point)
+ sox_present = True
+
+ # use ffmpeg if sox is not present
+ if not sox_present:
+ if not ffmpeg_present:
+ raise AudioError(
+ "FFmpeg binary is missing from system."
+ "Please install ffmpeg on your OS to enable playback.",
+ )
+ # collect input args
+ input_args = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
+ input_args += [
+ "-f",
+ input_format.value,
+ "-ac",
+ str(channels),
+ "-ar",
+ str(sample_rate),
+ "-i",
+ "-",
+ ]
+ # collect output args
+ output_args = ["-f", output_format.value, "-"]
+ return input_args + output_args
+
+ # Prefer SoX for all other (=highest quality)
+
+ # collect input args
+ input_args = [
+ "sox",
+ "-t",
+ input_format.sox_format(),
+ "-r",
+ str(sample_rate),
+ "-b",
+ str(bit_depth),
+ "-c",
+ str(channels),
+ "-",
+ ]
+ # collect output args
+ if output_format == ContentType.FLAC:
+ output_args = ["-t", "flac", "-C", "0", "-"]
+ else:
+ output_args = ["-t", output_format.sox_format(), "-"]
+ return input_args + output_args
import asyncio
import functools
-import logging
-import os
import pickle
import time
from typing import Awaitable
-import aiosqlite
from music_assistant.helpers.typing import MusicAssistant
from music_assistant.helpers.util import create_task
-LOGGER = logging.getLogger("cache")
+DB_TABLE = "cache"
class Cache:
- """Basic stateless caching system."""
-
- _db = None
+ """Basic cache using both memory and database."""
def __init__(self, mass: MusicAssistant) -> None:
"""Initialize our caching class."""
self.mass = mass
- self._dbfile = os.path.join(mass.config.data_path, ".cache.db")
+ self.logger = mass.logger.getChild("cache")
self._mem_cache = {}
async def setup(self) -> None:
"""Async initialize of cache module."""
- async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS simplecache(
- id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)"""
+ # prepare database
+ async with self.mass.database.get_db() as _db:
+ await _db.execute(
+ f"""CREATE TABLE IF NOT EXISTS {DB_TABLE}(
+ key TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)"""
)
- await db_conn.commit()
- await db_conn.execute("VACUUM;")
- await db_conn.commit()
- self.mass.tasks.add("Cleanup cache", self.auto_cleanup, periodic=3600)
+ self.__schedule_cleanup_task()
async def get(self, cache_key, checksum="", default=None):
"""
):
return cache_data[0]
# fall back to db cache
- sql_query = "SELECT data, checksum, expires FROM simplecache WHERE id = ?"
- async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
- async with db_conn.execute(sql_query, (cache_key,)) as cursor:
- cache_data = await cursor.fetchone()
- if (
- cache_data
- and (not checksum or cache_data[1] == checksum)
- and cache_data[2] >= cur_time
- ):
+ if db_row := await self.mass.database.get_row(DB_TABLE, {"key": cache_key}):
+ if (
+ not checksum
+ or db_row["checksum"] == checksum
+ and db_row["expires"] >= cur_time
+ ):
+ try:
data = await asyncio.get_running_loop().run_in_executor(
- None, pickle.loads, cache_data[0]
+ None, pickle.loads, db_row["data"]
)
+ except Exception: # pylint: disable=broad-except
+ self.logger.warning("Error parsing cache data for %s", cache_key)
+ else:
# also store in memory cache for faster access
if cache_key not in self._mem_cache:
self._mem_cache[cache_key] = (
data,
- cache_data[1],
- cache_data[2],
+ db_row["checksum"],
+ db_row["expires"],
)
return data
- LOGGER.debug("no cache data for %s", cache_key)
+ self.logger.debug("no cache data for %s", cache_key)
return default
async def set(self, cache_key, data, checksum="", expiration=(86400 * 30)):
data = await asyncio.get_running_loop().run_in_executor(
None, pickle.dumps, data
)
- sql_query = """INSERT OR REPLACE INTO simplecache
- (id, expires, data, checksum) VALUES (?, ?, ?, ?)"""
- async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
- await db_conn.execute(sql_query, (cache_key, expires, data, checksum))
- await db_conn.commit()
+ await self.mass.database.insert_or_replace(
+ DB_TABLE,
+ {"key": cache_key, "expires": expires, "checksum": checksum, "data": data},
+ )
async def delete(self, cache_key):
"""Delete data from cache."""
self._mem_cache.pop(cache_key, None)
- sql_query = "DELETE FROM simplecache WHERE id = ?"
- async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
- await db_conn.execute(sql_query, (cache_key,))
- await db_conn.commit()
+ await self.mass.database.delete(DB_TABLE, {"key": cache_key})
async def auto_cleanup(self):
"""Sceduled auto cleanup task."""
- # for now we simply rest the memory cache
+ # for now we simply reset the memory cache
self._mem_cache = {}
cur_timestamp = int(time.time())
- sql_query = "SELECT id, expires FROM simplecache"
- async with aiosqlite.connect(self._dbfile, timeout=600) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- async with db_conn.execute(sql_query) as cursor:
- cache_objects = await cursor.fetchall()
- for cache_data in cache_objects:
- cache_id = cache_data["id"]
- # clean up db cache object only if expired
- if cache_data["expires"] < cur_timestamp:
- sql_query = "DELETE FROM simplecache WHERE id = ?"
- await db_conn.execute(sql_query, (cache_id,))
- # compact db
- await db_conn.commit()
+ for db_row in await self.mass.database.get_rows(DB_TABLE):
+ # clean up db cache object only if expired
+ if db_row["expires"] < cur_timestamp:
+ await self.delete(db_row["key"])
+ # compact db
+ async with self.mass.database.get_db() as _db:
+ await _db.execute("VACUUM")
+
+ def __schedule_cleanup_task(self):
+ """Schedule the cleanup task."""
+ self.mass.add_job(self.auto_cleanup(), "Cleanup cache")
+ # reschedule self
+ self.mass.loop.call_later(3600, self.__schedule_cleanup_task)
@staticmethod
def _get_checksum(stringinput):
coro_func: Awaitable,
*args,
expires: int = (86400 * 30),
- checksum=None
+ checksum=None,
):
"""Return helper method to store results of a coroutine in the cache."""
cache_result = await cache.get(cache_key, checksum)
if cache_result is not None:
return cache_result
- result = await coro_func(*args)
+ if asyncio.iscoroutine(coro_func):
+ result = await coro_func
+ else:
+ result = await coro_func(*args)
create_task(cache.set(cache_key, result, checksum, expires))
return result
-
-
-def use_cache(cache_days=14, cache_checksum=None):
- """Return decorator that can be used to cache a method's result."""
-
- def wrapper(func):
- @functools.wraps(func)
- async def wrapped(*args, **kwargs):
- method_class = args[0]
- method_class_name = method_class.__class__.__name__
- cache_str = "%s.%s" % (method_class_name, func.__name__)
- cache_str += __cache_id_from_args(*args, **kwargs)
- cache_str = cache_str.lower()
- cachedata = await method_class.cache.get(cache_str)
- if cachedata is not None:
- return cachedata
- result = await func(*args, **kwargs)
- create_task(
- method_class.cache.set(
- cache_str,
- result,
- checksum=cache_checksum,
- expiration=(86400 * cache_days),
- )
- )
- return result
-
- return wrapped
-
- return wrapper
-
-
-def __cache_id_from_args(*args, **kwargs):
- """Parse arguments to build cache id."""
- cache_str = ""
- # append args to cache identifier
- for item in args[1:]:
- if isinstance(item, dict):
- for subkey in sorted(list(item.keys())):
- subvalue = item[subkey]
- cache_str += ".%s%s" % (subkey, subvalue)
- else:
- cache_str += ".%s" % item
- # append kwargs to cache identifier
- for key in sorted(list(kwargs.keys())):
- value = kwargs[key]
- if isinstance(value, dict):
- for subkey in sorted(list(value.keys())):
- subvalue = value[subkey]
- cache_str += ".%s%s" % (subkey, subvalue)
- else:
- cache_str += ".%s%s" % (key, value)
- return cache_str
"""Several helper/utils to compare objects."""
import re
-from typing import List
+from typing import TYPE_CHECKING, List
import unidecode
-from music_assistant.models.media_types import Album, Artist, Track
+
+if TYPE_CHECKING:
+ from music_assistant.models.media_items import Album, Artist, Track
def get_compare_string(input_str):
return left_versions == right_versions
-def compare_artists(left_artists: List[Artist], right_artists: List[Artist]):
+def compare_artists(left_artists: List["Artist"], right_artists: List["Artist"]):
"""Compare two lists of artist and return True if both lists match."""
matches = 0
for left_artist in left_artists:
return len(left_artists) == matches
-def compare_albums(left_albums: List[Album], right_albums: List[Album]):
+def compare_albums(left_albums: List["Album"], right_albums: List["Album"]):
"""Compare two lists of albums and return True if a match was found."""
for left_album in left_albums:
for right_album in right_albums:
return False
-def compare_album(left_album: Album, right_album: Album):
+def compare_album(left_album: "Album", right_album: "Album"):
"""Compare two album items and return True if they match."""
+ if left_album is None or right_album is None:
+ return False
# do not match on year and albumtype as this info is often inaccurate on providers
if (
left_album.provider == right_album.provider
return True
-def compare_track(left_track: Track, right_track: Track):
+def compare_track(left_track: "Track", right_track: "Track"):
"""Compare two track items and return True if they match."""
if (
left_track.provider == right_track.provider
if not compare_artists(left_track.artists, right_track.artists):
return False
# album match OR near exact duration match
- left_albums = left_track.albums or [left_track.album]
- right_albums = right_track.albums or [right_track.album]
if (
- compare_albums(left_albums, right_albums)
- and abs(left_track.duration - right_track.duration) < 3
+ compare_album(left_track.album, right_track.album)
+ and abs(left_track.duration - right_track.duration) < 5
) or abs(left_track.duration - right_track.duration) < 1:
# 100% match, all criteria passed
return True
--- /dev/null
+"""Database logic."""
+from __future__ import annotations
+import asyncio
+from contextlib import asynccontextmanager
+from typing import Any, Dict, List, Mapping
+
+from databases import Database as Db
+from databases import DatabaseURL
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.typing import EventDetails, MusicAssistant
+
+# pylint: disable=invalid-name
+
+
+class Database:
+ """Class that holds the (logic to the) database."""
+
+ def __init__(self, mass: MusicAssistant, url: DatabaseURL):
+ """Initialize class."""
+ self.url = url
+ self.mass = mass
+ self.logger = mass.logger.getChild("db")
+ self._lock = asyncio.Lock()
+ mass.subscribe(self.__on_shutdown_event, EventType.SHUTDOWN)
+
+ async def setup(self):
+ """Async initialize of module."""
+ # await self.connect()
+
+ @asynccontextmanager
+ async def get_db(self, db: Db | None = None) -> Db:
+ """Context manager helper to get the active db connection."""
+ if db is not None:
+ yield db
+ else:
+ async with Db(self.url) as _db:
+ yield _db
+
+ async def get_rows(
+ self, table: str, match: dict = None, order_by: str = None, db: Db | None = None
+ ) -> List[Mapping]:
+ """Get all rows for given table."""
+ async with self.get_db(db) as _db:
+ sql_query = f"SELECT * FROM {table}"
+ if match is not None:
+ sql_query += " WHERE " + " AND ".join((f"{x} = :{x}" for x in match))
+ if order_by is not None:
+ sql_query += f"ORDER BY {order_by}"
+ return await _db.fetch_all(sql_query, match)
+
+ async def get_rows_from_query(
+ self, query: str, db: Db | None = None
+ ) -> List[Mapping]:
+ """Get all rows for given custom query."""
+ async with self.get_db(db) as _db:
+ return await _db.fetch_all(query)
+
+ async def search(
+ self, table: str, search: str, column: str = "name", db: Db | None = None
+ ) -> List[Mapping]:
+ """Search table by column."""
+ async with self.get_db(db) as _db:
+ sql_query = f'SELECT * FROM {table} WHERE {column} LIKE "{search}"'
+ return await _db.fetch_all(sql_query)
+
+ async def get_row(
+ self, table: str, match: Dict[str, Any] = None, db: Db | None = None
+ ) -> Mapping | None:
+ """Get single row for given table where column matches keys/values."""
+ async with self.get_db(db) as _db:
+ sql_query = f"SELECT * FROM {table} WHERE "
+ sql_query += " AND ".join((f"{x} = :{x}" for x in match))
+ return await _db.fetch_one(sql_query, match)
+
+ async def insert_or_replace(
+ self, table: str, values: Dict[str, Any], db: Db | None = None
+ ) -> Mapping:
+ """Insert or replace data in given table."""
+ async with self.get_db(db) as _db:
+ keys = tuple(values.keys())
+ sql_query = f'INSERT OR REPLACE INTO {table}({",".join(keys)})'
+ sql_query += f' VALUES ({",".join((f":{x}" for x in keys))})'
+ await _db.execute(sql_query, values)
+ # return inserted/replaced item
+ lookup_vals = {
+ key: value
+ for key, value in values.items()
+ if value is not None and value != ""
+ }
+ return await self.get_row(table, lookup_vals, db=_db)
+
+ async def update(
+ self,
+ table: str,
+ match: Dict[str, Any],
+ values: Dict[str, Any],
+ db: Db | None = None,
+ ) -> Mapping:
+ """Update record."""
+ async with self.get_db(db) as _db:
+ keys = tuple(values.keys())
+ sql_query = (
+ f'UPDATE {table} SET {",".join((f"{x}=:{x}" for x in keys))} WHERE '
+ )
+ sql_query += " AND ".join((f"{x} = :{x}" for x in match))
+ await _db.execute(sql_query, {**match, **values})
+ # return updated item
+ return await self.get_row(table, match, db=_db)
+
+ async def delete(
+ self, table: str, match: Dict[str, Any], db: Db | None = None
+ ) -> None:
+ """Delete data in given table."""
+ async with self.get_db(db) as _db:
+ sql_query = f"DELETE FROM {table}"
+ sql_query += " WHERE " + " AND ".join((f"{x} = :{x}" for x in match))
+ await _db.execute(sql_query)
+
+ async def __on_shutdown_event(
+ self, event: EventType, details: EventDetails
+ ) -> None:
+ """Handle shutdown event."""
+ # await self.disconnect()
+ self.logger.info("database closed")
+++ /dev/null
-"""Various helpers for data encryption/decryption."""
-
-import asyncio
-
-from cryptography.fernet import Fernet, InvalidToken
-from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all
-
-
-async def encrypt_string(str_value: str) -> str:
- """Encrypt a string with Fernet."""
- return await asyncio.get_running_loop().run_in_executor(
- None, _encrypt_string, str_value
- )
-
-
-def _encrypt_string(str_value: str) -> str:
- """Encrypt a string with Fernet."""
- return Fernet(get_app_var(3)).encrypt(str_value.encode()).decode()
-
-
-async def encrypt_bytes(bytes_value: bytes) -> bytes:
- """Encrypt bytes with Fernet."""
- return await asyncio.get_running_loop().run_in_executor(
- None, _encrypt_bytes, bytes_value
- )
-
-
-def _encrypt_bytes(bytes_value: bytes) -> bytes:
- """Encrypt bytes with Fernet."""
- return Fernet(get_app_var(3)).encrypt(bytes_value)
-
-
-async def decrypt_string(encrypted_str: str) -> str:
- """Decrypt a string with Fernet."""
- return await asyncio.get_running_loop().run_in_executor(
- None, _decrypt_string, encrypted_str
- )
-
-
-def _decrypt_string(encrypted_str: str) -> str:
- """Decrypt a string with Fernet."""
- try:
- return Fernet(get_app_var(3)).decrypt(encrypted_str.encode()).decode()
- except (InvalidToken, AttributeError):
- return None
-
-
-async def decrypt_bytes(bytes_value: bytes) -> bytes:
- """Decrypt bytes with Fernet."""
- return await asyncio.get_running_loop().run_in_executor(
- None, _decrypt_bytes, bytes_value
- )
-
-
-def _decrypt_bytes(bytes_value):
- """Decrypt bytes with Fernet."""
- try:
- return Fernet(get_app_var(3)).decrypt(bytes_value)
- except (InvalidToken, AttributeError):
- return None
+++ /dev/null
-"""Custom errors and exceptions."""
-
-
-class AuthenticationError(Exception):
- """Custom Exception for all authentication errors."""
"""Utilities for image manipulation and retrieval."""
-import os
from io import BytesIO
from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.models.media_types import MediaType
+from music_assistant.models.media_items import ItemMapping, MediaType, MediaItemType
from PIL import Image
-async def get_thumb_file(mass: MusicAssistant, url, size: int = 150):
- """Get path to (resized) thumbnail image for given image url."""
- assert url
- cache_folder = os.path.join(mass.config.data_path, ".thumbs")
- cache_id = await mass.database.get_thumbnail_id(url, size)
- cache_file = os.path.join(cache_folder, f"{cache_id}.png")
- if os.path.isfile(cache_file):
- # return file from cache
- return cache_file
- # no file in cache so we should get it
- os.makedirs(cache_folder, exist_ok=True)
- # download base image
+async def create_thumbnail(mass: MusicAssistant, url, size: int = 150) -> bytes:
+ """Create thumbnail from image url."""
async with mass.http_session.get(url, verify_ssl=False) as response:
assert response.status == 200
img_data = BytesIO(await response.read())
-
- # save resized image
- if size:
- basewidth = size
img = Image.open(img_data)
- wpercent = basewidth / float(img.size[0])
- hsize = int((float(img.size[1]) * float(wpercent)))
- img = img.resize((basewidth, hsize), Image.ANTIALIAS)
- img.save(cache_file, format="png")
- else:
- with open(cache_file, "wb") as _file:
- _file.write(img_data.getvalue())
- # return file from cache
- return cache_file
+ img.thumbnail((size, size), Image.ANTIALIAS)
+ img.save(format="png")
+ return img_data.getvalue()
-async def get_image_url(
- mass: MusicAssistant, item_id: str, provider_id: str, media_type: MediaType
-):
- """Get url to image for given media item."""
- item = await mass.music.get_item(item_id, provider_id, media_type)
- if not item:
+async def get_image_url(mass: MusicAssistant, media_item: MediaItemType):
+ """Get url to image for given media media_item."""
+ if not media_item:
return None
- if item and item.metadata.get("image"):
- return item.metadata["image"]
+ if isinstance(media_item, ItemMapping):
+ media_item = await mass.music.get_item_by_uri(media_item.uri)
+ if media_item and media_item.metadata.get("image"):
+ return media_item.metadata["image"]
if (
- hasattr(item, "album")
- and hasattr(item.album, "metadata")
- and item.album.metadata.get("image")
+ hasattr(media_item, "album")
+ and hasattr(media_item.album, "metadata")
+ and media_item.album.metadata.get("image")
):
- return item.album.metadata["image"]
- if hasattr(item, "albums"):
- for album in item.albums:
+ return media_item.album.metadata["image"]
+ if hasattr(media_item, "albums"):
+ for album in media_item.albums:
if hasattr(album, "metadata") and album.metadata.get("image"):
return album.metadata["image"]
if (
- hasattr(item, "artist")
- and hasattr(item.artist, "metadata")
- and item.artist.metadata.get("image")
+ hasattr(media_item, "artist")
+ and hasattr(media_item.artist, "metadata")
+ and media_item.artist.metadata.get("image")
):
- return item.artist.metadata["image"]
- if media_type == MediaType.TRACK and item.album:
+ return media_item.artist.metadata["image"]
+ if media_item.media_type == MediaType.TRACK and media_item.album:
# try album instead for tracks
- return await get_image_url(
- mass, item.album.item_id, item.album.provider, MediaType.ALBUM
- )
- if media_type == MediaType.ALBUM and item.artist:
+ return await get_image_url(mass, media_item.album)
+ if media_item.media_type == MediaType.ALBUM and media_item.artist:
# try artist instead for albums
- return await get_image_url(
- mass, item.artist.item_id, item.artist.provider, MediaType.ARTIST
- )
+ return await get_image_url(mass, media_item.artist)
return None
--- /dev/null
+"""Various helpers for web requests."""
+
+import asyncio
+
+try:
+ import ujson as json
+except ImportError:
+ import json
+
+
+def serialize_values(obj):
+ """Recursively create serializable values for (custom) data types."""
+
+ def get_val(val):
+ if (
+ isinstance(val, (list, set, filter, tuple))
+ or val.__class__ == "dict_valueiterator"
+ ):
+ return [get_val(x) for x in val] if val else []
+ if isinstance(val, dict):
+ return {key: get_val(value) for key, value in val.items()}
+ try:
+ return val.to_dict()
+ except AttributeError:
+ return val
+ except Exception: # pylint: disable=broad-except
+ return val
+
+ return get_val(obj)
+
+
+def json_serializer(data):
+ """Json serializer to recursively create serializable values for custom data types."""
+ return json.dumps(serialize_values(data))
+
+
+async def async_json_serializer(data):
+ """Run json serializer in executor for large data."""
+ if isinstance(data, list) and len(data) > 100:
+ return await asyncio.get_running_loop().run_in_executor(
+ None, json_serializer, data
+ )
+ return json_serializer(data)
+++ /dev/null
-"""Initialize logger."""
-import logging
-import os
-import random
-from logging.handlers import TimedRotatingFileHandler
-
-from .util import LimitedList
-
-
-def setup_logger(data_path):
- """Initialize logger."""
- logs_dir = os.path.join(data_path, "logs")
- if not os.path.isdir(logs_dir):
- os.mkdir(logs_dir)
- logger = logging.getLogger()
- log_formatter = logging.Formatter(
- "%(asctime)-15s %(levelname)-5s %(name)s -- %(message)s"
- )
- consolehandler = logging.StreamHandler()
- consolehandler.setFormatter(log_formatter)
- consolehandler.setLevel(logging.DEBUG)
- logger.addHandler(consolehandler)
- log_filename = os.path.join(logs_dir, "musicassistant.log")
- file_handler = TimedRotatingFileHandler(
- log_filename, when="midnight", interval=1, backupCount=10
- )
- file_handler.setLevel(logging.INFO)
- file_handler.setFormatter(log_formatter)
- logger.addHandler(file_handler)
-
- html_handler = HistoryLogHandler()
- html_handler.setLevel(logging.DEBUG)
- html_handler.setFormatter(log_formatter)
- logger.addHandler(html_handler)
-
- # global level is debug
- logger.setLevel(logging.DEBUG)
-
- # silence some loggers
- logging.getLogger("asyncio").setLevel(logging.WARNING)
- logging.getLogger("aiosqlite").setLevel(logging.WARNING)
- logging.getLogger("databases").setLevel(logging.WARNING)
- logging.getLogger("multipart.multipart").setLevel(logging.WARNING)
- logging.getLogger("passlib.handlers.bcrypt").setLevel(logging.WARNING)
-
- return logger
-
-
-class HistoryLogHandler(logging.Handler):
- """A logging handler that keeps the last X records in memory."""
-
- def __init__(self, max_len: int = 200):
- """Initialize instance."""
- logging.Handler.__init__(self)
- # Our custom argument
- self._history = LimitedList(max_len=max_len)
- self._max_len = max_len
-
- @property
- def max_len(self) -> int:
- """Return the max size of the log list."""
- return self._max_len
-
- def emit(self, record):
- """Emit log record."""
- self._history.append(
- {
- "id": f"{record.asctime}.{random.randint(0, 9)}",
- "time": record.asctime,
- "name": record.name,
- "level": record.levelname,
- "message": record.message,
- }
- )
-
- def get_history(self):
- """Get all log lines in history."""
- return self._history
+++ /dev/null
-"""Logic to handle database/configuration changes and creation."""
-
-import os
-import shutil
-import uuid
-
-from pkg_resources import packaging
-
-import aiosqlite
-from music_assistant.constants import __version__ as app_version
-from music_assistant.helpers.encryption import encrypt_string
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import get_hostname
-
-
-async def check_migrations(mass: MusicAssistant):
- """Check for any migrations that need to be done."""
-
- is_fresh_setup = len(mass.config.stored_config.keys()) == 0
- prev_version = packaging.version.parse(mass.config.stored_config.get("version", ""))
-
- # perform version specific migrations
- if not is_fresh_setup and prev_version < packaging.version.parse("0.1.1"):
- await run_migration_1(mass)
-
- # store version in config
- mass.config.stored_config["version"] = app_version
- # create unique server id from machine id
- if "server_id" not in mass.config.stored_config:
- mass.config.stored_config["server_id"] = str(uuid.getnode())
- if "jwt_key" not in mass.config.stored_config:
- mass.config.stored_config["jwt_key"] = await encrypt_string(str(uuid.uuid4()))
- if "initialized" not in mass.config.stored_config:
- mass.config.stored_config["initialized"] = False
- if "friendly_name" not in mass.config.stored_config:
- mass.config.stored_config["friendly_name"] = get_hostname()
- mass.config.save()
-
- # create default db tables (if needed)
- await create_db_tables(mass.database.db_file)
-
-
-async def run_migration_1(mass: MusicAssistant):
- """Run migration for version 0.1.1."""
- # 0.1.0 introduced major changes to all data models and db structure
- # a full refresh of data is unavoidable
- data_path = mass.config.data_path
- tracks_loudness = []
-
- for dbname in ["mass.db", "database.db", "music_assistant.db"]:
- filename = os.path.join(data_path, dbname)
- if os.path.isfile(filename):
- # we try to backup the loudness measurements
- async with aiosqlite.connect(filename, timeout=120) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- sql_query = "SELECT * FROM track_loudness"
- for db_row in await db_conn.execute_fetchall(sql_query, ()):
- if "provider_track_id" in db_row.keys():
- track_id = db_row["provider_track_id"]
- else:
- track_id = db_row["item_id"]
- tracks_loudness.append(
- (
- track_id,
- db_row["provider"],
- db_row["loudness"],
- )
- )
- # remove old db file
- os.remove(filename)
-
- # remove old cache db
- for dbname in ["cache.db", ".cache.db"]:
- filename = os.path.join(data_path, dbname)
- if os.path.isfile(filename):
- os.remove(filename)
-
- # remove old thumbs db
- for dirname in ["thumbs", ".thumbs", ".thumbnails"]:
- dirname = os.path.join(data_path, dirname)
- if os.path.isdir(dirname):
- shutil.rmtree(dirname, True)
-
- # create default db tables (if needed)
- await create_db_tables(mass.database.db_file)
-
- # restore loudness measurements
- if tracks_loudness:
- async with aiosqlite.connect(mass.database.db_file, timeout=120) as db_conn:
- sql_query = """INSERT or REPLACE INTO track_loudness
- (item_id, provider, loudness) VALUES(?,?,?);"""
- for item in tracks_loudness:
- await db_conn.execute(sql_query, item)
- await db_conn.commit()
-
-
-async def create_db_tables(db_file):
- """Async initialization."""
- async with aiosqlite.connect(db_file, timeout=120) as db_conn:
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS provider_mappings(
- item_id INTEGER NOT NULL,
- media_type TEXT NOT NULL,
- prov_item_id TEXT NOT NULL,
- provider TEXT NOT NULL,
- quality INTEGER NOT NULL,
- details TEXT NULL,
- UNIQUE(item_id, media_type, prov_item_id, provider, quality)
- );"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS artists(
- item_id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- sort_name TEXT,
- musicbrainz_id TEXT NOT NULL UNIQUE,
- in_library BOOLEAN DEFAULT 0,
- metadata json,
- provider_ids json
- );"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS albums(
- item_id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- sort_name TEXT,
- album_type TEXT,
- year INTEGER,
- version TEXT,
- in_library BOOLEAN DEFAULT 0,
- upc TEXT,
- artist json,
- metadata json,
- provider_ids json
- );"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS tracks(
- item_id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- sort_name TEXT,
- version TEXT,
- duration INTEGER,
- in_library BOOLEAN DEFAULT 0,
- isrc TEXT,
- albums json,
- artists json,
- metadata json,
- provider_ids json
- );"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS playlists(
- item_id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- sort_name TEXT,
- owner TEXT NOT NULL,
- is_editable BOOLEAN NOT NULL,
- checksum TEXT NOT NULL,
- in_library BOOLEAN DEFAULT 0,
- metadata json,
- provider_ids json,
- UNIQUE(name, owner)
- );"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS radios(
- item_id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE,
- sort_name TEXT,
- in_library BOOLEAN DEFAULT 0,
- metadata json,
- provider_ids json
- );"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS track_loudness(
- item_id INTEGER NOT NULL,
- provider TEXT NOT NULL,
- loudness REAL,
- UNIQUE(item_id, provider));"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS playlog(
- item_id INTEGER NOT NULL,
- provider TEXT NOT NULL,
- timestamp REAL,
- UNIQUE(item_id, provider));"""
- )
-
- await db_conn.execute(
- """CREATE TABLE IF NOT EXISTS thumbs(
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- url TEXT NOT NULL,
- size INTEGER,
- UNIQUE(url, size));"""
- )
-
- await db_conn.commit()
- await db_conn.execute("VACUUM;")
- await db_conn.commit()
+++ /dev/null
-"""Special queue-like to process items in different states."""
-import asyncio
-from collections import deque
-from typing import Any, List, Type
-
-
-class MultiStateQueue:
- """Special queue-like to process items in different states."""
-
- QUEUE_ITEM_TYPE: Type = Any
-
- def __init__(self, max_finished_items: int = 50) -> None:
- """Initialize class."""
- self._pending_items = asyncio.Queue()
- self._progress_items = deque()
- self._finished_items = deque(maxlen=max_finished_items)
-
- @property
- def pending_items(self) -> List[QUEUE_ITEM_TYPE]:
- """Return all pending items."""
- # pylint: disable=protected-access
- return list(self._pending_items._queue)
-
- @property
- def progress_items(self) -> List[QUEUE_ITEM_TYPE]:
- """Return all in-progress items."""
- return list(self._progress_items)
-
- @property
- def finished_items(self) -> List[QUEUE_ITEM_TYPE]:
- """Return all finished items."""
- return list(self._finished_items)
-
- @property
- def all_items(self) -> List[QUEUE_ITEM_TYPE]:
- """Return all items."""
- return list(self.pending_items + self.progress_items + self.finished_items)
-
- def put_nowait(self, item: QUEUE_ITEM_TYPE) -> None:
- """Put item in the queue to progress."""
- if item in self._finished_items:
- self._finished_items.remove(item)
- return self._pending_items.put_nowait(item)
-
- async def put(self, item: QUEUE_ITEM_TYPE) -> None:
- """Put item on the queue to progress."""
- if item in self._finished_items:
- self._finished_items.remove(item)
- return await self._pending_items.put(item)
-
- async def get_nowait(self) -> QUEUE_ITEM_TYPE:
- """Get next item in Queue, raises QueueEmpty if no items in Queue."""
- next_item = self._pending_items.get_nowait()
- self._progress_items.append(next_item)
- return next_item
-
- async def get(self) -> QUEUE_ITEM_TYPE:
- """Get next item in Queue, waits until item is available."""
- next_item = await self._pending_items.get()
- self._progress_items.append(next_item)
- return next_item
-
- def mark_finished(self, item: QUEUE_ITEM_TYPE) -> None:
- """Mark item as finished."""
- self._progress_items.remove(item)
- self._finished_items.append(item)
+++ /dev/null
-"""Handle getting Id's from MusicBrainz."""
-
-import logging
-import re
-from json.decoder import JSONDecodeError
-from typing import Optional
-
-import aiohttp
-from asyncio_throttle import Throttler
-from music_assistant.helpers.cache import use_cache
-from music_assistant.helpers.compare import compare_strings, get_compare_string
-
-LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
-
-LOGGER = logging.getLogger("musicbrainz")
-
-
-class MusicBrainz:
- """Handle getting Id's from MusicBrainz."""
-
- def __init__(self, mass):
- """Initialize class."""
- self.mass = mass
- self.cache = mass.cache
- self.throttler = Throttler(rate_limit=1, period=1)
-
- async def get_mb_artist_id(
- self,
- artistname,
- albumname=None,
- album_upc=None,
- trackname=None,
- track_isrc=None,
- ):
- """Retrieve musicbrainz artist id for the given details."""
- LOGGER.debug(
- "searching musicbrainz for %s \
- (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)",
- artistname,
- albumname,
- album_upc,
- trackname,
- track_isrc,
- )
- mb_artist_id = None
- if album_upc:
- mb_artist_id = await self.search_artist_by_album(
- artistname, None, album_upc
- )
- if mb_artist_id:
- LOGGER.debug(
- "Got MusicbrainzArtistId for %s after search on upc %s --> %s",
- artistname,
- album_upc,
- mb_artist_id,
- )
- if not mb_artist_id and track_isrc:
- mb_artist_id = await self.search_artist_by_track(
- artistname, None, track_isrc
- )
- if mb_artist_id:
- LOGGER.debug(
- "Got MusicbrainzArtistId for %s after search on isrc %s --> %s",
- artistname,
- track_isrc,
- mb_artist_id,
- )
- if not mb_artist_id and albumname:
- mb_artist_id = await self.search_artist_by_album(artistname, albumname)
- if mb_artist_id:
- LOGGER.debug(
- "Got MusicbrainzArtistId for %s after search on albumname %s --> %s",
- artistname,
- albumname,
- mb_artist_id,
- )
- if not mb_artist_id and trackname:
- mb_artist_id = await self.search_artist_by_track(artistname, trackname)
- if mb_artist_id:
- LOGGER.debug(
- "Got MusicbrainzArtistId for %s after search on trackname %s --> %s",
- artistname,
- trackname,
- mb_artist_id,
- )
- return mb_artist_id
-
- async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
- """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
- for searchartist in [
- re.sub(LUCENE_SPECIAL, r"\\\1", artistname),
- get_compare_string(artistname),
- ]:
- if album_upc:
- endpoint = "release"
- params = {"query": "barcode:%s" % album_upc}
- else:
- searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname)
- endpoint = "release"
- params = {
- "query": 'artist:"%s" AND release:"%s"'
- % (searchartist, searchalbum)
- }
- result = await self.get_data(endpoint, params)
- if result and "releases" in result:
- for strictness in [True, False]:
- for item in result["releases"]:
- if album_upc or compare_strings(
- item["title"], albumname, strictness
- ):
- for artist in item["artist-credit"]:
- if compare_strings(
- artist["artist"]["name"], artistname, strictness
- ):
- return artist["artist"]["id"]
- for alias in artist.get("aliases", []):
- if compare_strings(
- alias["name"], artistname, strictness
- ):
- return artist["id"]
- return ""
-
- async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
- """Retrieve artist id by providing the artist name and trackname or track isrc."""
- endpoint = "recording"
- searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname)
- # searchartist = searchartist.replace('/','').replace('\\','').replace('-', '')
- if track_isrc:
- endpoint = "isrc/%s" % track_isrc
- params = {"inc": "artist-credits"}
- else:
- searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname)
- endpoint = "recording"
- params = {"query": '"%s" AND artist:"%s"' % (searchtrack, searchartist)}
- result = await self.get_data(endpoint, params)
- if result and "recordings" in result:
- for strictness in [True, False]:
- for item in result["recordings"]:
- if track_isrc or compare_strings(
- item["title"], trackname, strictness
- ):
- for artist in item["artist-credit"]:
- if compare_strings(
- artist["artist"]["name"], artistname, strictness
- ):
- return artist["artist"]["id"]
- for alias in artist.get("aliases", []):
- if compare_strings(
- alias["name"], artistname, strictness
- ):
- return artist["id"]
- return ""
-
- @use_cache(2)
- async def get_data(self, endpoint: str, params: Optional[dict] = None):
- """Get data from api."""
- if params is None:
- params = {}
- url = "http://musicbrainz.org/ws/2/%s" % endpoint
- headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"}
- params["fmt"] = "json"
- async with self.throttler:
- async with self.mass.http_session.get(
- url, headers=headers, params=params, verify_ssl=False
- ) as response:
- try:
- result = await response.json()
- except (
- aiohttp.client_exceptions.ContentTypeError,
- JSONDecodeError,
- ) as exc:
- msg = await response.text()
- LOGGER.error("%s - %s", str(exc), msg)
- result = None
- return result
import asyncio
import logging
-from typing import AsyncGenerator, List, Optional, Union
+from typing import AsyncGenerator, List, Optional, Tuple, Union
-from async_timeout import timeout
+from async_timeout import timeout as _timeout
LOGGER = logging.getLogger("AsyncProcess")
self._proc.terminate()
await self._proc.stdout.read()
self._proc.kill()
- except (ProcessLookupError, BrokenPipeError):
+ except (ProcessLookupError, BrokenPipeError, RuntimeError):
pass
del self._proc
async def iterate_chunks(
- self, chunk_size: int = DEFAULT_CHUNKSIZE
+ self, chunk_size: int = DEFAULT_CHUNKSIZE, timeout: int = DEFAULT_TIMEOUT
) -> AsyncGenerator[bytes, None]:
"""Yield chunks from the process stdout. Generator."""
while True:
- chunk = await self.read(chunk_size)
+ chunk = await self.read(chunk_size, timeout)
if not chunk:
break
yield chunk
if chunk_size is not None and len(chunk) < chunk_size:
break
- async def read(self, chunk_size: int = DEFAULT_CHUNKSIZE) -> bytes:
+ async def read(
+ self, chunk_size: int = DEFAULT_CHUNKSIZE, timeout: int = DEFAULT_TIMEOUT
+ ) -> bytes:
"""Read x bytes from the process stdout."""
try:
- async with timeout(DEFAULT_TIMEOUT):
+ async with _timeout(timeout):
if chunk_size is None:
return await self._proc.stdout.read(DEFAULT_CHUNKSIZE)
return await self._proc.stdout.readexactly(chunk_size)
return err.partial
except AttributeError as exc:
raise asyncio.CancelledError() from exc
+ except asyncio.TimeoutError:
+ return b""
async def write(self, data: bytes) -> None:
"""Write data to process stdin."""
await self._proc.stdin.drain()
except BrokenPipeError:
pass
- except AttributeError:
- raise asyncio.CancelledError()
+ except (AttributeError, AssertionError) as err:
+ raise asyncio.CancelledError() from err
+
+ def write_eof(self) -> None:
+ """Write end of file to to process stdin."""
+ if self._proc.stdin.can_write_eof():
+ self._proc.stdin.write_eof()
async def communicate(self, input_data: Optional[bytes] = None) -> bytes:
"""Write bytes to process and read back results."""
return await self._proc.communicate(input_data)
+
+
+async def check_output(shell_cmd: str) -> Tuple[int, bytes]:
+ """Run shell subprocess and return output."""
+ proc = await asyncio.create_subprocess_shell(
+ shell_cmd,
+ stderr=asyncio.subprocess.STDOUT,
+ stdout=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await proc.communicate()
+ return (proc.returncode, stdout)
"""Typing helper."""
-from typing import TYPE_CHECKING, Optional, Set
+from typing import TYPE_CHECKING, Any, Optional, List
+
# pylint: disable=invalid-name
if TYPE_CHECKING:
- from music_assistant.mass import MusicAssistant
- from music_assistant.models.player_queue import (
- QueueItem,
+ from music_assistant.mass import (
+ MusicAssistant,
+ EventDetails,
+ EventCallBackType,
+ EventSubscriptionType,
+ )
+ from music_assistant.models.media_items import MediaType
+ from music_assistant.models.player import (
PlayerQueue,
+ QueueItem,
)
- from music_assistant.models.streamdetails import StreamDetails, StreamType
from music_assistant.models.player import Player
- from music_assistant.managers.config import ConfigSubItem
- from music_assistant.models.media_types import MediaType
else:
MusicAssistant = "MusicAssistant"
PlayerQueue = "PlayerQueue"
StreamDetails = "StreamDetails"
Player = "Player"
- ConfigSubItem = "ConfigSubItem"
MediaType = "MediaType"
- StreamType = "StreamType"
+ EventDetails = Any | None
+ EventCallBackType = "EventCallBackType"
+ EventSubscriptionType = "EventSubscriptionType"
-QueueItems = Set[QueueItem]
-Players = Set[Player]
+QueueItems = List[QueueItem]
+Players = List[Player]
OptionalInt = Optional[int]
OptionalStr = Optional[str]
"""Helper and utility functions."""
import asyncio
import functools
-import logging
import os
import platform
import socket
import tempfile
import threading
-import urllib.request
from asyncio.events import AbstractEventLoop
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union
import memory_tempfile
-import ujson
-
-from .typing import MediaType
# pylint: disable=invalid-name
T = TypeVar("T")
DEFAULT_LOOP = None
-def callback(func: CALLABLE_T) -> CALLABLE_T:
- """Annotation to mark method as safe to call from within the event loop."""
- setattr(func, "_mass_callback", True)
- return func
-
-
-def is_callback(func: Callable[..., Any]) -> bool:
- """Check if function is safe to be called in the event loop."""
- return getattr(func, "_mass_callback", False) is True
-
-
def create_task(
target: Callable[..., Any],
*args: Any,
while isinstance(check_target, functools.partial):
check_target = check_target.func
- async def cb_wrapper(_target: Callable, *_args, **_kwargs):
- return _target(*_args, **_kwargs)
-
async def executor_wrapper(_target: Callable, *_args, **_kwargs):
return await loop.run_in_executor(None, _target, *_args, **_kwargs)
return asyncio.run_coroutine_threadsafe(target, loop)
if asyncio.iscoroutinefunction(check_target):
return asyncio.run_coroutine_threadsafe(target(*args), loop)
- if is_callback(check_target):
- return asyncio.run_coroutine_threadsafe(
- cb_wrapper(target, *args, **kwargs), loop
- )
return asyncio.run_coroutine_threadsafe(
executor_wrapper(target, *args, **kwargs), loop
)
return loop.create_task(target)
if asyncio.iscoroutinefunction(check_target):
return loop.create_task(target(*args))
- if is_callback(check_target):
- return loop.create_task(cb_wrapper(target, *args, **kwargs))
return loop.create_task(executor_wrapper(target, *args, **kwargs))
return scheduler
-def get_external_ip():
- """Try to get the external (WAN) IP address."""
- # pylint: disable=broad-except
- try:
- return urllib.request.urlopen("https://ident.me").read().decode("utf8")
- except Exception:
- return None
-
-
def filename_from_string(string):
"""Create filename from unsafe string."""
keepcharacters = (" ", ".", "_")
return possible_bool in ["true", "True", "1", "on", "ON", 1]
+def create_sort_name(name):
+ """Return sort name."""
+ sort_name = name
+ for item in ["The ", "De ", "de ", "Les "]:
+ if name.startswith(item):
+ sort_name = "".join(name.split(item)[1:])
+ return sort_name.lower()
+
+
def parse_title_and_version(track_title, track_version=None):
"""Try to parse clean track title and version from the title."""
title = track_title.lower()
return _ip
-def get_ip_pton():
- """Return socket pton for local ip."""
- # pylint:disable=no-member
- try:
- return socket.inet_pton(socket.AF_INET, get_ip())
- except OSError:
- return socket.inet_pton(socket.AF_INET6, get_ip())
-
-
-# pylint: enable=broad-except
-
-
-def get_hostname():
- """Get hostname for this machine."""
- # pylint:disable=no-member
- return socket.gethostname()
-
-
def get_folder_size(folderpath):
"""Return folder size in gb."""
total_size = 0
return final_dict
-def merge_list(base_list: list, new_list: list) -> Set:
+def merge_list(base_list: list, new_list: list) -> List:
"""Merge 2 lists."""
final_list = set(base_list)
for item in new_list:
prov_item = item
if item not in final_list:
final_list.add(item)
- return final_list
-
-
-def try_load_json_file(jsonfile):
- """Try to load json from file."""
- try:
- with open(jsonfile, "r") as _file:
- return ujson.loads(_file.read())
- except (FileNotFoundError, ValueError) as exc:
- logging.getLogger().debug(
- "Could not load json from file %s", jsonfile, exc_info=exc
- )
- return None
+ return list(final_list)
def create_tempfile():
return tempfile.NamedTemporaryFile(buffering=0)
-async def yield_chunks(_obj, chunk_size):
- """Yield successive n-sized chunks from list/str/bytes."""
- chunk_size = int(chunk_size)
- for i in range(0, len(_obj), chunk_size):
- yield _obj[i : i + chunk_size]
-
-
def get_changed_keys(
- dict1: Dict[str, Any], dict2: Dict[str, Any], ignore_keys: Optional[Set[str]] = None
+ dict1: Dict[str, Any],
+ dict2: Dict[str, Any],
+ ignore_keys: Optional[List[str]] = None,
) -> Set[str]:
"""Compare 2 dicts and return set of changed keys."""
if not dict2:
elif dict1[key] != value:
changed_keys.add(key)
return changed_keys
-
-
-def create_uri(media_type: MediaType, provider: str, item_id: str):
- """Create uri for mediaitem."""
- return f"{provider}://{media_type.value}/{item_id}"
-
-
-class LimitedList(list):
- """Implementation of a size limited list."""
-
- @property
- def max_len(self):
- """Return list's max length."""
- return self._max_len
-
- def __init__(self, lst: Optional[List] = None, max_len=500):
- """Initialize instance."""
- self._max_len = max_len
- if lst is not None:
- super().__init__(lst)
- else:
- super().__init__()
-
- def _truncate(self):
- """Call by various methods to reinforce the maximum length."""
- dif = len(self) - self._max_len
- if dif > 0:
- self[:dif] = []
-
- def append(self, x):
- """Append item x to the list."""
- super().append(x)
- self._truncate()
-
- def insert(self, *args):
- """Insert items at position x to the list."""
- super().insert(*args)
- self._truncate()
-
- def extend(self, x):
- """Extend the list."""
- super().extend(x)
- self._truncate()
-
- def __setitem__(self, *args):
- """Internally set."""
- super().__setitem__(*args)
- self._truncate()
-
- # def __setslice__(self, *args):
- # """Internally set slice."""
- # super().__setslice__(*args)
- # self._truncate()
+++ /dev/null
-"""Various helpers for web requests."""
-
-import asyncio
-import inspect
-import ipaddress
-import re
-from dataclasses import dataclass
-from functools import wraps
-from typing import Any, Callable, Dict, Optional, Tuple, Union, get_args, get_origin
-
-import ujson
-from aiohttp import web
-from music_assistant.helpers.typing import MusicAssistant
-
-
-def require_local_subnet(func):
- """Return decorator to specify web method as available locally only."""
-
- @wraps(func)
- async def wrapped(*args, **kwargs):
- request = args[-1]
-
- if isinstance(request, web.View):
- request = request.request
-
- if not isinstance(request, web.BaseRequest): # pragma: no cover
- raise RuntimeError(
- "Incorrect usage of decorator." "Expect web.BaseRequest as an argument"
- )
-
- if not ipaddress.ip_address(request.remote).is_private:
- raise web.HTTPUnauthorized(reason="Not remote available")
-
- return await func(*args, **kwargs)
-
- return wrapped
-
-
-def serialize_values(obj):
- """Recursively create serializable values for (custom) data types."""
-
- def get_val(val):
- if (
- isinstance(val, (list, set, filter, tuple))
- or val.__class__ == "dict_valueiterator"
- ):
- return [get_val(x) for x in val] if val else []
- if isinstance(val, dict):
- return {key: get_val(value) for key, value in val.items()}
- try:
- return val.to_dict()
- except AttributeError:
- return val
- except Exception:
- return val
-
- return get_val(obj)
-
-
-def json_serializer(data):
- """Json serializer to recursively create serializable values for custom data types."""
- return ujson.dumps(serialize_values(data))
-
-
-async def async_json_serializer(data):
- """Run json serializer in executor for large data."""
- if isinstance(data, list) and len(data) > 100:
- return await asyncio.get_running_loop().run_in_executor(
- None, json_serializer, data
- )
- return json_serializer(data)
-
-
-def json_response(data: Any, status: int = 200):
- """Return json in web request."""
- return web.Response(
- body=json_serializer(data), status=200, content_type="application/json"
- )
-
-
-async def async_json_response(data: Any, status: int = 200):
- """Return json in web request."""
- return web.Response(
- body=await async_json_serializer(data),
- status=200,
- content_type="application/json",
- )
-
-
-def api_route(api_path, method="GET"):
- """Decorate a function as API route/command."""
-
- def decorate(func):
- func.api_path = api_path
- func.api_method = method
- return func
-
- return decorate
-
-
-def get_match_pattern(api_path: str) -> Optional[re.Pattern]:
- """Return match pattern for given path."""
- if "{" in api_path and "}" in api_path:
- regex_parts = []
- for part in api_path.split("/"):
- if part.startswith("{") and part.endswith("}"):
- # path variable, create named capture group
- regex_parts.append(part.replace("{", "(?P<").replace("}", ">[^{}/]+)"))
- else:
- # literal string
- regex_parts.append(r"\b" + part + r"\b")
- path_regex = "/" if api_path.startswith("/") else ""
- path_regex += "/".join(regex_parts)
- if api_path.endswith("/"):
- path_regex += "/"
- return re.compile(path_regex)
- return None
-
-
-def create_api_route(
- api_path: str,
- handler: Callable,
- method: str = "GET",
-):
- """Create APIRoute instance from given params."""
- return APIRoute(
- path=api_path,
- method=method,
- pattern=get_match_pattern(api_path),
- part_count=api_path.count("/"),
- signature=get_typed_signature(handler),
- target=handler,
- )
-
-
-def get_typed_signature(call: Callable) -> inspect.Signature:
- """Parse signature of function to do type validation and/or api spec generation."""
- signature = inspect.signature(call)
- return signature
-
-
-def parse_arguments(mass: MusicAssistant, func_sig: inspect.Signature, args: dict):
- """Parse (and convert) incoming arguments to correct types."""
- final_args = {}
- for key, value in args.items():
- if key not in func_sig.parameters:
- raise KeyError("Invalid parameter: '%s'" % key)
- arg_type = func_sig.parameters[key].annotation
- final_args[key] = convert_value(key, value, arg_type)
- # check for missing args
- for key, value in func_sig.parameters.items():
- if key == "mass":
- final_args[key] = mass
- elif value.default is inspect.Parameter.empty:
- if key not in final_args:
- raise KeyError("Missing parameter: '%s'" % key)
- return final_args
-
-
-def convert_value(arg_key, value, arg_type):
- """Convert dict value to one of our models."""
- if arg_type == inspect.Parameter.empty:
- return value
- if get_origin(arg_type) is list:
- return [
- convert_value(arg_key, subval, get_args(arg_type)[0]) for subval in value
- ]
- if get_origin(arg_type) is Union:
- # try all possible types
- for sub_arg_type in get_args(arg_type):
- try:
- return convert_value(arg_key, value, sub_arg_type)
- except Exception: # pylint: disable=broad-except
- pass
- raise ValueError("Error parsing '%s', possibly wrong type?" % arg_key)
- if hasattr(arg_type, "from_dict"):
- return arg_type.from_dict(value)
- if value is None:
- return value
- if arg_type is Any:
- return value
- return arg_type(value)
-
-
-@dataclass
-class APIRoute:
- """Model for an API route."""
-
- path: str
- method: str
- pattern: Optional[re.Pattern]
- part_count: int
- signature: inspect.Signature
- target: Callable
-
- def match(
- self, matchpath: str, method: str
- ) -> Optional[Tuple["APIRoute", Dict[str, str]]]:
- """Match this route with given path and return the route and resolved params."""
- if matchpath.endswith("/"):
- matchpath = matchpath[0:-1]
- if self.method.upper() != method.upper():
- return None
- if self.part_count != matchpath.count("/"):
- return None
- if self.pattern is not None:
- match = re.match(self.pattern, matchpath)
- if match:
- return self, match.groupdict()
- match = self.path.lower() == matchpath.lower()
- if match:
- return self, {}
- return None
+++ /dev/null
-"""Controllers/managers for Music Assistant entities."""
+++ /dev/null
-"""All classes and helpers for the Configuration."""
-
-import copy
-import json
-import logging
-import os
-import pathlib
-import shutil
-from typing import Any, List
-
-from music_assistant.constants import (
- CONF_CROSSFADE_DURATION,
- CONF_ENABLED,
- CONF_GROUP_DELAY,
- CONF_KEY_BASE,
- CONF_KEY_METADATA_PROVIDERS,
- CONF_KEY_MUSIC_PROVIDERS,
- CONF_KEY_PLAYER_PROVIDERS,
- CONF_KEY_PLAYER_SETTINGS,
- CONF_KEY_PLUGINS,
- CONF_KEY_SECURITY,
- CONF_KEY_SECURITY_APP_TOKENS,
- CONF_KEY_SECURITY_LOGIN,
- CONF_MAX_SAMPLE_RATE,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_POWER_CONTROL,
- CONF_TARGET_VOLUME,
- CONF_USERNAME,
- CONF_VOLUME_CONTROL,
- CONF_VOLUME_NORMALISATION,
- EVENT_CONFIG_CHANGED,
-)
-from music_assistant.helpers.datetime import utc_timestamp
-from music_assistant.helpers.encryption import _decrypt_string, _encrypt_string
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task, try_load_json_file
-from music_assistant.helpers.web import api_route
-from music_assistant.models.config_entry import (
- ConfigEntry,
- ConfigEntryType,
- ConfigValueOption,
-)
-from music_assistant.models.player import PlayerControlType
-from music_assistant.models.provider import ProviderType
-from passlib.hash import pbkdf2_sha256
-
-RESOURCES_DIR = (
- pathlib.Path(__file__).parent.resolve().parent.resolve().joinpath("resources")
-)
-
-LOGGER = logging.getLogger("config_manager")
-
-SAMPLERATE_OPTIONS = [
- ConfigValueOption(text=str(val), value=val)
- for val in (41000, 48000, 96000, 176000, 192000, 384000)
-]
-
-DEFAULT_PLAYER_CONFIG_ENTRIES = [
- ConfigEntry(
- entry_key=CONF_ENABLED,
- entry_type=ConfigEntryType.BOOL,
- default_value=True,
- label="enable_player",
- ),
- ConfigEntry(
- entry_key=CONF_NAME,
- entry_type=ConfigEntryType.STRING,
- default_value=None,
- label=CONF_NAME,
- description="desc_player_name",
- ),
- ConfigEntry(
- entry_key=CONF_MAX_SAMPLE_RATE,
- entry_type=ConfigEntryType.INT,
- options=SAMPLERATE_OPTIONS,
- default_value=96000,
- label=CONF_MAX_SAMPLE_RATE,
- description="desc_sample_rate",
- ),
- ConfigEntry(
- entry_key=CONF_VOLUME_NORMALISATION,
- entry_type=ConfigEntryType.BOOL,
- default_value=True,
- label=CONF_VOLUME_NORMALISATION,
- description="desc_volume_normalisation",
- ),
- ConfigEntry(
- entry_key=CONF_TARGET_VOLUME,
- entry_type=ConfigEntryType.INT,
- range=(-40, 0),
- default_value=-23,
- label=CONF_TARGET_VOLUME,
- description="desc_target_volume",
- depends_on=CONF_VOLUME_NORMALISATION,
- ),
- ConfigEntry(
- entry_key=CONF_CROSSFADE_DURATION,
- entry_type=ConfigEntryType.INT,
- range=(0, 10),
- default_value=0,
- label=CONF_CROSSFADE_DURATION,
- description="desc_crossfade",
- ),
-]
-
-DEFAULT_PROVIDER_CONFIG_ENTRIES = [
- ConfigEntry(
- entry_key=CONF_ENABLED,
- entry_type=ConfigEntryType.BOOL,
- default_value=True,
- label=CONF_ENABLED,
- description="desc_enable_provider",
- )
-]
-
-DEFAULT_BASE_CONFIG_ENTRIES = {}
-
-DEFAULT_SECURITY_CONFIG_ENTRIES = {
- CONF_KEY_SECURITY_LOGIN: [
- ConfigEntry(
- entry_key=CONF_USERNAME,
- entry_type=ConfigEntryType.STRING,
- default_value="admin",
- label=CONF_USERNAME,
- description="desc_base_username",
- ),
- ConfigEntry(
- entry_key=CONF_PASSWORD,
- entry_type=ConfigEntryType.PASSWORD,
- default_value="",
- label=CONF_PASSWORD,
- description="desc_base_password",
- store_hashed=True,
- ),
- ],
- CONF_KEY_SECURITY_APP_TOKENS: [],
-}
-
-
-PROVIDER_TYPE_MAPPINGS = {
- CONF_KEY_MUSIC_PROVIDERS: ProviderType.MUSIC_PROVIDER,
- CONF_KEY_PLAYER_PROVIDERS: ProviderType.PLAYER_PROVIDER,
- CONF_KEY_METADATA_PROVIDERS: ProviderType.METADATA_PROVIDER,
- CONF_KEY_PLUGINS: ProviderType.PLUGIN,
-}
-
-
-class ConfigManager:
- """Class which holds our configuration."""
-
- def __init__(self, mass: MusicAssistant, data_path: str):
- """Initialize class."""
- self._data_path = data_path
- self._stored_config = {}
- self._strings = {}
- self.loading = False
- self.mass = mass
- if not os.path.isdir(data_path):
- raise FileNotFoundError(f"data directory {data_path} does not exist!")
- self.__load()
-
- async def setup(self):
- """Async initialize of module."""
- self._strings = await self._fetch_strings()
-
- @api_route("config/{conf_base}")
- def base_items(self, conf_base: str) -> dict:
- """Return config items."""
- obj = getattr(self, conf_base)
- if isinstance(obj, dict):
- return obj
- return obj.all_items()
-
- @api_route("config/{conf_base}/{conf_key}")
- def sub_items(self, conf_base: str, conf_key: str) -> dict:
- """Return specific config entries."""
- obj = getattr(self, conf_base)[conf_key]
- if isinstance(obj, dict):
- return obj
- return obj.all_items()
-
- @api_route("config")
- def all_items(self) -> dict:
- """Return entire config as dict."""
- return {
- key: getattr(self, key).all_items()
- for key in [
- CONF_KEY_BASE,
- CONF_KEY_SECURITY,
- CONF_KEY_MUSIC_PROVIDERS,
- CONF_KEY_PLAYER_PROVIDERS,
- CONF_KEY_METADATA_PROVIDERS,
- CONF_KEY_PLUGINS,
- CONF_KEY_PLAYER_SETTINGS,
- ]
- }
-
- @api_route("config/{conf_base}/{conf_key}/{conf_val}", method="PUT")
- def set_config(
- self, conf_base: str, conf_key: str, conf_val: str, new_value: Any
- ) -> dict:
- """Set value of the given config item."""
- if new_value is None:
- return self[conf_base][conf_key].pop(conf_val)
- self[conf_base][conf_key][conf_val] = new_value
- return self[conf_base][conf_key].all_items()
-
- @api_route("config/{conf_base}/{conf_key}", method="DELETE")
- def delete_config(self, conf_base: str, conf_key: str) -> dict:
- """Delete value from stored configuration."""
- return self[conf_base].pop(conf_key)
-
- @property
- def data_path(self):
- """Return the path where all (configuration) data is stored."""
- return self._data_path
-
- @property
- def server_id(self):
- """Return the unique identifier for this server."""
- return self.stored_config["server_id"]
-
- @property
- def base(self):
- """Return base config."""
- return BaseSettings(self)
-
- @property
- def security(self):
- """Return security config."""
- return SecuritySettings(self)
-
- @property
- def player_settings(self):
- """Return all player configs."""
- return PlayerSettings(self)
-
- @property
- def music_providers(self):
- """Return all music provider configs."""
- return ProviderSettings(self, CONF_KEY_MUSIC_PROVIDERS)
-
- @property
- def player_providers(self):
- """Return all player provider configs."""
- return ProviderSettings(self, CONF_KEY_PLAYER_PROVIDERS)
-
- @property
- def metadata_providers(self):
- """Return all metadata provider configs."""
- return ProviderSettings(self, CONF_KEY_METADATA_PROVIDERS)
-
- @property
- def plugins(self):
- """Return all plugin configs."""
- return ProviderSettings(self, CONF_KEY_PLUGINS)
-
- @property
- def stored_config(self):
- """Return the config that is actually stored on disk."""
- return self._stored_config
-
- @api_route("strings")
- def all_strings(self):
- """Return all strings for all languages."""
- return self._strings
-
- @api_route("strings/{language}")
- def language_strings(self, language: str):
- """Return all strings for given language."""
- return self._strings[language]
-
- def get_provider_config(self, provider_id: str, provider_type: ProviderType = None):
- """Return config for given provider."""
- if not provider_type:
- provider = self.mass.get_provider(provider_id)
- if provider:
- provider_type = provider.type
- if provider_type == ProviderType.METADATA_PROVIDER:
- return self.metadata_providers[provider_id]
- if provider_type == ProviderType.MUSIC_PROVIDER:
- return self.music_providers[provider_id]
- if provider_type == ProviderType.PLAYER_PROVIDER:
- return self.player_providers[provider_id]
- if provider_type == ProviderType.PLUGIN:
- return self.plugins[provider_id]
- raise RuntimeError("Invalid provider type")
-
- def get_player_config(self, player_id: str):
- """Return config for given player."""
- return self.player_settings[player_id]
-
- def __getitem__(self, item_key):
- """Return item value by key."""
- return getattr(self, item_key)
-
- async def close(self):
- """Save config on exit."""
- self.save()
-
- def save(self):
- """Save config to file."""
- if self.loading:
- LOGGER.warning("save already running")
- return
- self.loading = True
- # backup existing file
- conf_file = os.path.join(self.data_path, "config.json")
- conf_file_backup = os.path.join(self.data_path, "config.json.backup")
- if os.path.isfile(conf_file):
- shutil.move(conf_file, conf_file_backup)
- # write current config to file
- with open(conf_file, "w") as _file:
- _file.write(json.dumps(self._stored_config, indent=4))
- LOGGER.info("Config saved!")
- self.loading = False
-
- @staticmethod
- async def _fetch_strings() -> dict:
- """Build a list of all strings/translations."""
- strings = {}
- for _file in os.listdir(RESOURCES_DIR.joinpath("strings")):
- if not _file.endswith(".json"):
- continue
- language = _file.replace(".json", "")
- lang_file = RESOURCES_DIR.joinpath("strings", _file)
- strings[language] = try_load_json_file(lang_file)
- return strings
-
- def __load(self):
- """Load stored config from file."""
- self.loading = True
- conf_file = os.path.join(self.data_path, "config.json")
- data = try_load_json_file(conf_file)
- if data is None:
- # might be a corrupt config file, retry with backup file
- conf_file_backup = os.path.join(self.data_path, "config.json.backup")
- data = try_load_json_file(conf_file_backup)
- if data:
- self._stored_config = data
- self.loading = False
-
-
-class ConfigBaseItem:
- """Configuration class that holds the ConfigSubItem items."""
-
- def __init__(self, conf_mgr: ConfigManager, conf_key: str):
- """Initialize class."""
- self.conf_mgr = conf_mgr
- self.mass = conf_mgr.mass
- self.conf_key = conf_key
-
- def all_keys(self):
- """Return all possible keys of this Config object."""
- return self.conf_mgr.stored_config.get(self.conf_key, {}).keys()
-
- def __getitem__(self, item_key: str):
- """Return ConfigSubItem for given key."""
- return ConfigSubItem(self, item_key)
-
- def all_items(self) -> dict:
- """Return entire config as dict."""
- return {
- key: copy.deepcopy(ConfigSubItem(self, key).all_items())
- for key in self.all_keys()
- }
-
-
-class BaseSettings(ConfigBaseItem):
- """Configuration class that holds the base settings."""
-
- def __init__(self, conf_mgr: ConfigManager):
- """Initialize class."""
- super().__init__(conf_mgr, CONF_KEY_BASE)
-
- def all_keys(self):
- """Return all possible keys of this Config object."""
- return list(DEFAULT_BASE_CONFIG_ENTRIES.keys())
-
- @staticmethod
- def get_config_entries(child_key) -> List[ConfigEntry]:
- """Return all base config entries."""
- return list(DEFAULT_BASE_CONFIG_ENTRIES[child_key])
-
-
-class SecuritySettings(ConfigBaseItem):
- """Configuration class that holds the security settings."""
-
- def __init__(self, conf_mgr: ConfigManager):
- """Initialize class."""
- super().__init__(conf_mgr, CONF_KEY_SECURITY)
- # make sure the keys exist in config dict
- if CONF_KEY_SECURITY not in conf_mgr.stored_config:
- conf_mgr.stored_config[CONF_KEY_SECURITY] = {}
- if (
- CONF_KEY_SECURITY_APP_TOKENS
- not in conf_mgr.stored_config[CONF_KEY_SECURITY]
- ):
- conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS] = {}
-
- def all_keys(self):
- """Return all possible keys of this Config object."""
- return DEFAULT_SECURITY_CONFIG_ENTRIES.keys()
-
- def add_app_token(self, token_info: dict):
- """Add token to config."""
- client_id = token_info["client_id"]
- self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][
- client_id
- ] = token_info
- self.conf_mgr.save()
-
- def set_last_login(self, client_id: str):
- """Set last login to client."""
- if (
- client_id
- not in self.conf_mgr.stored_config[CONF_KEY_SECURITY][
- CONF_KEY_SECURITY_APP_TOKENS
- ]
- ):
- return
- self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][
- client_id
- ]["last_login"] = utc_timestamp()
- self.conf_mgr.save()
-
- def revoke_app_token(self, client_id):
- """Revoke a token registered for an app."""
- return_info = self.conf_mgr.stored_config[CONF_KEY_SECURITY][
- CONF_KEY_SECURITY_APP_TOKENS
- ].pop(client_id)
- self.conf_mgr.save()
- self.conf_mgr.mass.eventbus.signal(
- EVENT_CONFIG_CHANGED, (CONF_KEY_SECURITY, CONF_KEY_SECURITY_APP_TOKENS)
- )
- return return_info
-
- def is_token_revoked(self, token_info: dict):
- """Return bool if token is revoked."""
- if not token_info.get("app_id"):
- # short lived token does not have app_id and is not stored so can't be revoked
- return False
- return self[CONF_KEY_SECURITY_APP_TOKENS].get(token_info["client_id"]) is None
-
- def validate_credentials(self, username: str, password: str) -> bool:
- """Check if credentials matches."""
- if username != self[CONF_KEY_SECURITY_LOGIN][CONF_USERNAME]:
- return False
- try:
- return pbkdf2_sha256.verify(
- password, self[CONF_KEY_SECURITY_LOGIN][CONF_PASSWORD]
- )
- except ValueError:
- return False
-
- def get_config_entries(self, child_key) -> List[ConfigEntry]:
- """Return all base config entries."""
- if child_key == CONF_KEY_SECURITY_LOGIN:
- return list(DEFAULT_SECURITY_CONFIG_ENTRIES[CONF_KEY_SECURITY_LOGIN])
- if child_key == CONF_KEY_SECURITY_APP_TOKENS:
- return [
- ConfigEntry(
- entry_key=client_id,
- entry_type=ConfigEntryType.DICT,
- default_value={},
- label=token_info["app_id"],
- description="App connected to MusicAssistant API",
- store_hashed=False,
- value={
- "expires": token_info.get("exp"),
- "last_login": token_info.get("last_login"),
- },
- )
- for client_id, token_info in self.conf_mgr.stored_config[
- CONF_KEY_SECURITY
- ][CONF_KEY_SECURITY_APP_TOKENS].items()
- ]
- return []
-
-
-class PlayerSettings(ConfigBaseItem):
- """Configuration class that holds the player settings."""
-
- def __init__(self, conf_mgr: ConfigManager):
- """Initialize class."""
- super().__init__(conf_mgr, CONF_KEY_PLAYER_SETTINGS)
-
- def all_keys(self):
- """Return all possible keys of this Config object."""
- return {player.player_id for player in self.mass.players}
-
- def get_config_entries(self, child_key: str) -> List[ConfigEntry]:
- """Return all config entries for the given child entry."""
- entries = []
- entries += DEFAULT_PLAYER_CONFIG_ENTRIES
- player = self.mass.players.get_player(child_key)
- if player:
- entries += player.config_entries
- # append power control config entries
- power_controls = self.mass.players.get_player_controls(
- PlayerControlType.POWER
- )
- if power_controls:
- controls = [
- ConfigValueOption(
- text=f"{item.provider}: {item.name}", value=item.control_id
- )
- for item in power_controls
- ]
- entries.append(
- ConfigEntry(
- entry_key=CONF_POWER_CONTROL,
- entry_type=ConfigEntryType.STRING,
- label=CONF_POWER_CONTROL,
- description="desc_power_control",
- options=controls,
- )
- )
- # append volume control config entries
- volume_controls = self.mass.players.get_player_controls(
- PlayerControlType.VOLUME
- )
- if volume_controls:
- controls = [
- ConfigValueOption(
- text=f"{item.provider}: {item.name}", value=item.control_id
- )
- for item in volume_controls
- ]
- entries.append(
- ConfigEntry(
- entry_key=CONF_VOLUME_CONTROL,
- entry_type=ConfigEntryType.STRING,
- label=CONF_VOLUME_CONTROL,
- description="desc_volume_control",
- options=controls,
- )
- )
- # append special group player entries
- for parent_id in player.group_parents:
- parent_player = self.mass.players.get_player(parent_id)
- if parent_player and parent_player.provider_id == "group_player":
- entries.append(
- ConfigEntry(
- entry_key=CONF_GROUP_DELAY,
- entry_type=ConfigEntryType.INT,
- default_value=0,
- range=(0, 500),
- label=CONF_GROUP_DELAY,
- description="desc_group_delay",
- )
- )
- break
- return entries
-
-
-class ProviderSettings(ConfigBaseItem):
- """Configuration class that holds the provider settings."""
-
- def all_keys(self):
- """Return all possible keys of this Config object."""
- prov_type = PROVIDER_TYPE_MAPPINGS[self.conf_key]
- return (
- item.id
- for item in self.mass.get_providers(prov_type, include_unavailable=True)
- )
-
- def get_config_entries(self, child_key: str) -> List[ConfigEntry]:
- """Return all config entries for the given provider."""
- provider = self.mass.get_provider(child_key)
- if provider:
- return DEFAULT_PROVIDER_CONFIG_ENTRIES + provider.config_entries
- return DEFAULT_PROVIDER_CONFIG_ENTRIES
-
-
-class ConfigSubItem:
- """
- Configuration Item connected to Config Entries.
-
- Returns default value from config entry if no value present.
- """
-
- def __init__(self, conf_parent: ConfigBaseItem, conf_key: str):
- """Initialize class."""
- self.conf_parent = conf_parent
- self.conf_key = conf_key
- self.conf_mgr = conf_parent.conf_mgr
- self.parent_conf_key = conf_parent.conf_key
-
- def all_items(self) -> dict:
- """Return entire config as dict."""
- return {
- item.entry_key: self.get_entry(item.entry_key)
- for item in self.conf_parent.get_config_entries(self.conf_key)
- }
-
- def get(self, key, default=None):
- """Return value if key exists, default if not."""
- try:
- return self[key]
- except KeyError:
- return default
-
- def __getitem__(self, key) -> ConfigEntry:
- """Get value for ConfigEntry."""
- # always lookup the config entry because config entries are dynamic
- # and values may be transformed (e.g. encrypted)
- entry = self.get_entry(key)
- if entry.entry_type == ConfigEntryType.PASSWORD:
- # decrypted password is only returned if explicitly asked for this key
- decrypted_value = _decrypt_string(entry.value)
- if decrypted_value:
- return decrypted_value
- return entry.value
-
- def get_entry(self, key):
- """Return complete ConfigEntry for specified key."""
- stored_config = self.conf_mgr.stored_config.get(self.conf_parent.conf_key, {})
- stored_config = stored_config.get(self.conf_key, {})
- for conf_entry in self.conf_parent.get_config_entries(self.conf_key):
- if conf_entry.entry_key == key:
- if key in stored_config:
- # use stored value
- conf_entry.value = stored_config[key]
- else:
- # use default value for config entry
- conf_entry.value = conf_entry.default_value
- return conf_entry
- raise KeyError(
- "%s\\%s has no key %s!" % (self.conf_parent.conf_key, self.conf_key, key)
- )
-
- def __setitem__(self, key, value):
- """Store value and validate."""
- assert isinstance(key, str)
- for entry in self.conf_parent.get_config_entries(self.conf_key):
- if entry.entry_key != key:
- continue
- # do some simple type checking
- if entry.multi_value:
- # multi value item
- if value is None:
- value = []
- if not isinstance(value, list):
- raise ValueError
- else:
- # single value item
- if entry.entry_type == ConfigEntryType.STRING and not isinstance(
- value, str
- ):
- if value is None:
- value = ""
- else:
- raise ValueError
- if entry.entry_type == ConfigEntryType.BOOL and not isinstance(
- value, bool
- ):
- if value is None:
- value = False
- else:
- raise ValueError
- if entry.entry_type == ConfigEntryType.FLOAT and not isinstance(
- value, (float, int)
- ):
- if value is None:
- value = 0
- else:
- raise ValueError
- if value != self[key]:
- if entry.store_hashed:
- value = pbkdf2_sha256.hash(value)
- if entry.entry_type == ConfigEntryType.PASSWORD:
- value = _encrypt_string(value)
-
- # write value to stored config
- stored_conf = self.conf_mgr.stored_config
- if self.parent_conf_key not in stored_conf:
- stored_conf[self.parent_conf_key] = {}
- if self.conf_key not in stored_conf[self.parent_conf_key]:
- stored_conf[self.parent_conf_key][self.conf_key] = {}
- stored_conf[self.parent_conf_key][self.conf_key][key] = value
-
- self.conf_mgr.mass.tasks.add("Save configuration", self.conf_mgr.save)
- # reload provider/plugin if value changed
- if self.parent_conf_key in PROVIDER_TYPE_MAPPINGS:
- create_task(self.conf_mgr.mass.reload_provider(self.conf_key))
- if self.parent_conf_key == CONF_KEY_PLAYER_SETTINGS:
- # force update of player if it's config changed
- create_task(
- self.conf_mgr.mass.players.trigger_player_update(self.conf_key)
- )
- # signal config changed event
- self.conf_mgr.mass.eventbus.signal(
- EVENT_CONFIG_CHANGED, (self.parent_conf_key, self.conf_key)
- )
- return
- # raise KeyError if we're trying to set a value not defined as ConfigEntry
- raise KeyError
-
- def pop(self, key):
- """Delete ConfigEntry for specified key if exists."""
- stored_config = self.conf_mgr.stored_config.get(self.conf_parent.conf_key, {})
- stored_config = stored_config.get(self.conf_key, {})
- cur_val = stored_config.get(key, None)
- if cur_val:
- del stored_config[key]
- self.conf_mgr.save()
- return cur_val
+++ /dev/null
-"""Database logic."""
-# pylint: disable=too-many-lines
-import logging
-import os
-import statistics
-from typing import List, Optional, Set, Union
-
-import aiosqlite
-from music_assistant.helpers.compare import compare_album, compare_track
-from music_assistant.helpers.datetime import utc_timestamp
-from music_assistant.helpers.util import merge_dict, merge_list, try_parse_int
-from music_assistant.helpers.web import json_serializer
-from music_assistant.models.media_types import (
- Album,
- AlbumType,
- Artist,
- FullAlbum,
- FullTrack,
- ItemMapping,
- MediaItem,
- MediaItemProviderId,
- MediaType,
- Playlist,
- Radio,
- SearchResult,
- Track,
-)
-
-LOGGER = logging.getLogger("database")
-
-
-class DatabaseManager:
- """Class that holds the (logic to the) database."""
-
- def __init__(self, mass):
- """Initialize class."""
- self.mass = mass
- self._dbfile = os.path.join(mass.config.data_path, "music_assistant.db")
-
- @property
- def db_file(self):
- """Return location of database on disk."""
- return self._dbfile
-
- async def get_item_by_prov_id(
- self,
- provider_id: str,
- prov_item_id: str,
- media_type: MediaType,
- ) -> Optional[MediaItem]:
- """Get the database item for the given prov_id."""
- if media_type == MediaType.ARTIST:
- return await self.get_artist_by_prov_id(provider_id, prov_item_id)
- if media_type == MediaType.ALBUM:
- return await self.get_album_by_prov_id(provider_id, prov_item_id)
- if media_type == MediaType.TRACK:
- return await self.get_track_by_prov_id(provider_id, prov_item_id)
- if media_type == MediaType.PLAYLIST:
- return await self.get_playlist_by_prov_id(provider_id, prov_item_id)
- if media_type == MediaType.RADIO:
- return await self.get_radio_by_prov_id(provider_id, prov_item_id)
- return None
-
- async def get_track_by_prov_id(
- self,
- provider_id: str,
- prov_item_id: str,
- ) -> Optional[FullTrack]:
- """Get the database track for the given prov_id."""
- if provider_id == "database":
- return await self.get_track(prov_item_id)
- sql_query = f"""WHERE item_id in
- (SELECT item_id FROM provider_mappings
- WHERE prov_item_id = '{prov_item_id}'
- AND provider = '{provider_id}' AND media_type = 'track')"""
- for item in await self.get_tracks(sql_query):
- return item
- return None
-
- async def get_album_by_prov_id(
- self,
- provider_id: str,
- prov_item_id: str,
- ) -> Optional[FullAlbum]:
- """Get the database album for the given prov_id."""
- if provider_id == "database":
- return await self.get_album(prov_item_id)
- sql_query = f"""WHERE item_id in
- (SELECT item_id FROM provider_mappings
- WHERE prov_item_id = '{prov_item_id}'
- AND provider = '{provider_id}' AND media_type = 'album')"""
- for item in await self.get_albums(sql_query):
- return item
- return None
-
- async def get_artist_by_prov_id(
- self,
- provider_id: str,
- prov_item_id: str,
- ) -> Optional[Artist]:
- """Get the database artist for the given prov_id."""
- if provider_id == "database":
- return await self.get_artist(prov_item_id)
- sql_query = f"""WHERE item_id in
- (SELECT item_id FROM provider_mappings
- WHERE prov_item_id = '{prov_item_id}'
- AND provider = '{provider_id}' AND media_type = 'artist')"""
- for item in await self.get_artists(sql_query):
- return item
- return None
-
- async def get_playlist_by_prov_id(
- self, provider_id: str, prov_item_id: str
- ) -> Optional[Playlist]:
- """Get the database playlist for the given prov_id."""
- if provider_id == "database":
- return await self.get_playlist(prov_item_id)
- sql_query = f"""WHERE item_id in
- (SELECT item_id FROM provider_mappings
- WHERE prov_item_id = '{prov_item_id}'
- AND provider = '{provider_id}' AND media_type = 'playlist')"""
- for item in await self.get_playlists(sql_query):
- return item
- return None
-
- async def get_radio_by_prov_id(
- self,
- provider_id: str,
- prov_item_id: str,
- ) -> Optional[Radio]:
- """Get the database radio for the given prov_id."""
- if provider_id == "database":
- return await self.get_radio(prov_item_id)
- sql_query = f"""WHERE item_id in
- (SELECT item_id FROM provider_mappings
- WHERE prov_item_id = '{prov_item_id}'
- AND provider = '{provider_id}' AND media_type = 'radio')"""
- for item in await self.get_radios(sql_query):
- return item
- return None
-
- async def search(
- self, searchquery: str, media_types: List[MediaType]
- ) -> SearchResult:
- """Search library for the given searchphrase."""
- result = SearchResult([], [], [], [], [])
- searchquery = "%" + searchquery + "%"
- if media_types is None or MediaType.ARTIST in media_types:
- sql_query = ' WHERE name LIKE "%s"' % searchquery
- result.artists = await self.get_artists(sql_query)
- if media_types is None or MediaType.ALBUM in media_types:
- sql_query = ' WHERE name LIKE "%s"' % searchquery
- result.albums = await self.get_albums(sql_query)
- if media_types is None or MediaType.TRACK in media_types:
- sql_query = ' WHERE name LIKE "%s"' % searchquery
- result.tracks = await self.get_tracks(sql_query)
- if media_types is None or MediaType.PLAYLIST in media_types:
- sql_query = ' WHERE name LIKE "%s"' % searchquery
- result.playlists = await self.get_playlists(sql_query)
- if media_types is None or MediaType.RADIO in media_types:
- sql_query = ' WHERE name LIKE "%s"' % searchquery
- result.radios = await self.get_radios(sql_query)
- return result
-
- async def get_library_artists(self, orderby: str = "name") -> List[Artist]:
- """Get all library artists."""
- sql_query = "WHERE in_library = 1"
- return await self.get_artists(sql_query, orderby=orderby)
-
- async def get_library_albums(self, orderby: str = "name") -> List[Album]:
- """Get all library albums."""
- sql_query = "WHERE in_library = 1"
- return await self.get_albums(sql_query, orderby=orderby)
-
- async def get_library_tracks(self, orderby: str = "name") -> List[Track]:
- """Get all library tracks."""
- sql_query = "WHERE in_library = 1"
- return await self.get_tracks(sql_query, orderby=orderby)
-
- async def get_library_playlists(self, orderby: str = "name") -> List[Playlist]:
- """Fetch all playlist records from table."""
- sql_query = "WHERE in_library = 1"
- return await self.get_playlists(sql_query, orderby=orderby)
-
- async def get_library_radios(
- self, provider_id: str = None, orderby: str = "name"
- ) -> List[Radio]:
- """Fetch all radio records from table."""
- sql_query = "WHERE in_library = 1"
- return await self.get_radios(sql_query, orderby=orderby)
-
- async def get_playlists(
- self,
- filter_query: str = None,
- orderby: str = "name",
- ) -> List[Playlist]:
- """Get all playlists from database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- sql_query = "SELECT * FROM playlists"
- if filter_query:
- sql_query += " " + filter_query
- sql_query += " ORDER BY %s" % orderby
- return [
- Playlist.from_db_row(db_row)
- for db_row in await db_conn.execute_fetchall(sql_query, ())
- ]
-
- async def get_playlist(self, item_id: int) -> Playlist:
- """Get playlist record by id."""
- item_id = try_parse_int(item_id)
- for item in await self.get_playlists(f"WHERE item_id = {item_id}"):
- return item
- return None
-
- async def get_radios(
- self,
- filter_query: str = None,
- orderby: str = "name",
- db_conn: aiosqlite.Connection = None,
- ) -> List[Radio]:
- """Fetch radio records from database."""
- sql_query = "SELECT * FROM radios"
- if filter_query:
- sql_query += " " + filter_query
- sql_query += " ORDER BY %s" % orderby
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- return [
- Radio.from_db_row(db_row)
- for db_row in await db_conn.execute_fetchall(sql_query, ())
- ]
-
- async def get_radio(self, item_id: int) -> Playlist:
- """Get radio record by id."""
- item_id = try_parse_int(item_id)
- for item in await self.get_radios(f"WHERE item_id = {item_id}"):
- return item
- return None
-
- async def add_playlist(self, playlist: Playlist):
- """Add a new playlist record to the database."""
- assert playlist.name
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM playlists WHERE name=? AND owner=?;",
- (playlist.name, playlist.owner),
- db_conn,
- )
-
- if cur_item:
- # update existing
- return await self.update_playlist(cur_item[0], playlist)
- # insert playlist
- sql_query = """INSERT INTO playlists
- (name, sort_name, owner, is_editable, checksum, metadata, provider_ids)
- VALUES(?,?,?,?,?,?,?);"""
- async with db_conn.execute(
- sql_query,
- (
- playlist.name,
- playlist.sort_name,
- playlist.owner,
- playlist.is_editable,
- playlist.checksum,
- json_serializer(playlist.metadata),
- json_serializer(playlist.provider_ids),
- ),
- ) as cursor:
- last_row_id = cursor.lastrowid
- new_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM playlists WHERE ROWID=?;",
- (last_row_id,),
- db_conn,
- )
- await self._add_prov_ids(
- new_item[0], MediaType.PLAYLIST, playlist.provider_ids, db_conn=db_conn
- )
- await db_conn.commit()
- LOGGER.debug("added playlist %s to database", playlist.name)
- # return created object
- return await self.get_playlist(new_item[0])
-
- async def update_playlist(self, item_id: int, playlist: Playlist):
- """Update a playlist record in the database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = Playlist.from_db_row(
- await self.__execute_fetchone(
- "SELECT * FROM playlists WHERE item_id=?;", (item_id,), db_conn
- )
- )
- metadata = merge_dict(cur_item.metadata, playlist.metadata)
- provider_ids = merge_list(cur_item.provider_ids, playlist.provider_ids)
- sql_query = """UPDATE playlists
- SET name=?,
- sort_name=?,
- owner=?,
- is_editable=?,
- checksum=?,
- metadata=?,
- provider_ids=?
- WHERE item_id=?;"""
- await db_conn.execute(
- sql_query,
- (
- playlist.name,
- playlist.sort_name,
- playlist.owner,
- playlist.is_editable,
- playlist.checksum,
- json_serializer(metadata),
- json_serializer(provider_ids),
- item_id,
- ),
- )
- await self._add_prov_ids(
- item_id, MediaType.PLAYLIST, playlist.provider_ids, db_conn=db_conn
- )
- LOGGER.debug("updated playlist %s in database: %s", playlist.name, item_id)
- await db_conn.commit()
- # return updated object
- return await self.get_playlist(item_id)
-
- async def add_radio(self, radio: Radio):
- """Add a new radio record to the database."""
- assert radio.name
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM radios WHERE name=?;", (radio.name,), db_conn
- )
- if cur_item:
- # update existing
- return await self.update_radio(cur_item[0], radio)
- # insert radio
- sql_query = """INSERT INTO radios (name, sort_name, metadata, provider_ids)
- VALUES(?,?,?,?);"""
- async with db_conn.execute(
- sql_query,
- (
- radio.name,
- radio.sort_name,
- json_serializer(radio.metadata),
- json_serializer(radio.provider_ids),
- ),
- ) as cursor:
- last_row_id = cursor.lastrowid
- new_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM radios WHERE ROWID=?;",
- (last_row_id,),
- db_conn,
- )
- await self._add_prov_ids(
- new_item[0], MediaType.RADIO, radio.provider_ids, db_conn=db_conn
- )
- await db_conn.commit()
- LOGGER.debug("added radio %s to database", radio.name)
- # return created object
- return await self.get_radio(new_item[0])
-
- async def update_radio(self, item_id: int, radio: Radio):
- """Update a radio record in the database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = Radio.from_db_row(
- await self.__execute_fetchone(
- "SELECT * FROM radios WHERE item_id=?;", (item_id,), db_conn
- )
- )
- metadata = merge_dict(cur_item.metadata, radio.metadata)
- provider_ids = merge_list(cur_item.provider_ids, radio.provider_ids)
- sql_query = """UPDATE radios
- SET name=?,
- sort_name=?,
- metadata=?,
- provider_ids=?
- WHERE item_id=?;"""
- await db_conn.execute(
- sql_query,
- (
- radio.name,
- radio.sort_name,
- json_serializer(metadata),
- json_serializer(provider_ids),
- item_id,
- ),
- )
- await self._add_prov_ids(
- item_id, MediaType.RADIO, radio.provider_ids, db_conn=db_conn
- )
- LOGGER.debug("updated radio %s in database: %s", radio.name, item_id)
- await db_conn.commit()
- # return updated object
- return await self.get_radio(item_id)
-
- async def add_to_library(self, item_id: int, media_type: MediaType):
- """Add an item to the library (item must already be present in the db!)."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- item_id = try_parse_int(item_id)
- db_name = media_type.value + "s"
- sql_query = f"UPDATE {db_name} SET in_library=1 WHERE item_id=?;"
- await db_conn.execute(sql_query, (item_id,))
- await db_conn.commit()
-
- async def remove_from_library(
- self,
- item_id: int,
- media_type: MediaType,
- ):
- """Remove item from the library."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- item_id = try_parse_int(item_id)
- db_name = media_type.value + "s"
- sql_query = f"UPDATE {db_name} SET in_library=0 WHERE item_id=?;"
- await db_conn.execute(sql_query, (item_id,))
- await db_conn.commit()
-
- async def get_artists(
- self,
- filter_query: str = None,
- orderby: str = "name",
- db_conn: aiosqlite.Connection = None,
- ) -> List[Artist]:
- """Fetch artist records from database."""
- sql_query = "SELECT * FROM artists"
- if filter_query:
- sql_query += " " + filter_query
- sql_query += " ORDER BY %s" % orderby
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- return [
- Artist.from_db_row(db_row)
- for db_row in await db_conn.execute_fetchall(sql_query, ())
- ]
-
- async def get_artist(self, item_id: int) -> Artist:
- """Get artist record by id."""
- item_id = try_parse_int(item_id)
- for item in await self.get_artists("WHERE item_id = %d" % item_id):
- return item
- return None
-
- async def add_artist(self, artist: Artist):
- """Add a new artist record to the database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM artists WHERE musicbrainz_id=?;",
- (artist.musicbrainz_id,),
- db_conn,
- )
- if cur_item:
- # update existing
- return await self.update_artist(cur_item[0], artist)
- # insert artist
- sql_query = """INSERT INTO artists
- (name, sort_name, musicbrainz_id, metadata, provider_ids)
- VALUES(?,?,?,?,?);"""
- async with db_conn.execute(
- sql_query,
- (
- artist.name,
- artist.sort_name,
- artist.musicbrainz_id,
- json_serializer(artist.metadata),
- json_serializer(artist.provider_ids),
- ),
- ) as cursor:
- last_row_id = cursor.lastrowid
- new_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM artists WHERE ROWID=?;",
- (last_row_id,),
- db_conn,
- )
- await self._add_prov_ids(
- new_item[0], MediaType.ARTIST, artist.provider_ids, db_conn=db_conn
- )
- await db_conn.commit()
- LOGGER.debug("added artist %s to database", artist.name)
- # return created object
- return await self.get_artist(new_item[0])
-
- async def update_artist(self, item_id: int, artist: Artist):
- """Update a artist record in the database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- db_row = await self.__execute_fetchone(
- "SELECT * FROM artists WHERE item_id=?;", (item_id,), db_conn
- )
- cur_item = Artist.from_db_row(db_row)
- metadata = merge_dict(cur_item.metadata, artist.metadata)
- provider_ids = merge_list(cur_item.provider_ids, artist.provider_ids)
- sql_query = """UPDATE artists
- SET musicbrainz_id=?,
- metadata=?,
- provider_ids=?
- WHERE item_id=?;"""
- await db_conn.execute(
- sql_query,
- (
- artist.musicbrainz_id or cur_item.musicbrainz_id,
- json_serializer(metadata),
- json_serializer(provider_ids),
- item_id,
- ),
- )
- await self._add_prov_ids(
- item_id, MediaType.ARTIST, artist.provider_ids, db_conn=db_conn
- )
- LOGGER.debug("updated artist %s in database: %s", artist.name, item_id)
- await db_conn.commit()
- # return updated object
- return await self.get_artist(item_id)
-
- async def get_albums(
- self,
- filter_query: str = None,
- orderby: str = "name",
- db_conn: aiosqlite.Connection = None,
- ) -> List[Album]:
- """Fetch all album records from the database."""
- sql_query = "SELECT * FROM albums"
- if filter_query:
- sql_query += " " + filter_query
- sql_query += " ORDER BY %s" % orderby
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- return [
- Album.from_db_row(db_row)
- for db_row in await db_conn.execute_fetchall(sql_query, ())
- ]
-
- async def get_album(self, item_id: int) -> FullAlbum:
- """Get album record by id."""
- item_id = try_parse_int(item_id)
- # get from db
- for item in await self.get_albums("WHERE item_id = %d" % item_id):
- item.artist = (
- await self.get_artist_by_prov_id(
- item.artist.provider, item.artist.item_id
- )
- or item.artist
- )
- return item
- return None
-
- async def get_albums_from_provider_ids(
- self, provider_id: Union[str, List[str]], prov_item_ids: List[str]
- ) -> dict:
- """Get album records for the given prov_ids."""
- provider_ids = provider_id if isinstance(provider_id, list) else [provider_id]
- prov_id_str = ",".join([f'"{x}"' for x in provider_ids])
- prov_item_id_str = ",".join([f'"{x}"' for x in prov_item_ids])
- sql_query = f"""WHERE item_id in
- (SELECT item_id FROM provider_mappings
- WHERE provider in ({prov_id_str}) AND media_type = 'album'
- AND prov_item_id in ({prov_item_id_str})
- )"""
- return await self.get_albums(sql_query)
-
- async def add_album(self, album: Album):
- """Add a new album record to the database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = None
- # always try to grab existing item by external_id
- if album.upc:
- for item in await self.get_albums(f"WHERE upc='{album.upc}'"):
- cur_item = item
- # fallback to matching
- if not cur_item:
- sql_query = "SELECT item_id from albums WHERE sort_name LIKE ?"
- for db_row in await db_conn.execute_fetchall(
- sql_query, (album.sort_name,)
- ):
- item = await self.get_album(db_row["item_id"])
- if compare_album(item, album):
- cur_item = item
- break
- if cur_item:
- # update existing
- return await self.update_album(cur_item.item_id, album)
-
- # insert album
- assert album.artist
- album_artist = ItemMapping.from_item(
- await self.get_artist_by_prov_id(
- album.artist.provider, album.artist.item_id
- )
- or album.artist
- )
- sql_query = """INSERT INTO albums
- (name, sort_name, album_type, year, version, upc, artist, metadata, provider_ids)
- VALUES(?,?,?,?,?,?,?,?,?);"""
- async with db_conn.execute(
- sql_query,
- (
- album.name,
- album.sort_name,
- album.album_type.value,
- album.year,
- album.version,
- album.upc,
- json_serializer(album_artist),
- json_serializer(album.metadata),
- json_serializer(album.provider_ids),
- ),
- ) as cursor:
- last_row_id = cursor.lastrowid
- new_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM albums WHERE ROWID=?;",
- (last_row_id,),
- db_conn,
- )
- await self._add_prov_ids(
- new_item[0], MediaType.ALBUM, album.provider_ids, db_conn=db_conn
- )
- await db_conn.commit()
- LOGGER.debug("added album %s to database", album.name)
- # return created object
- return await self.get_album(new_item[0])
-
- async def update_album(self, item_id: int, album: Album):
- """Update a album record in the database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = await self.get_album(item_id)
- album_artist = ItemMapping.from_item(
- await self.get_artist_by_prov_id(
- cur_item.artist.provider, cur_item.artist.item_id
- )
- or await self.get_artist_by_prov_id(
- album.artist.provider, album.artist.item_id
- )
- or cur_item.artist
- )
- metadata = merge_dict(cur_item.metadata, album.metadata)
- provider_ids = merge_list(cur_item.provider_ids, album.provider_ids)
- if cur_item.album_type == AlbumType.UNKNOWN:
- album_type = album.album_type
- else:
- album_type = cur_item.album_type
- sql_query = """UPDATE albums
- SET upc=?,
- artist=?,
- metadata=?,
- provider_ids=?,
- album_type=?
- WHERE item_id=?;"""
- await db_conn.execute(
- sql_query,
- (
- album.upc or cur_item.upc,
- json_serializer(album_artist),
- json_serializer(metadata),
- json_serializer(provider_ids),
- album_type.value,
- item_id,
- ),
- )
- await self._add_prov_ids(
- item_id, MediaType.ALBUM, album.provider_ids, db_conn=db_conn
- )
- LOGGER.debug("updated album %s in database: %s", album.name, item_id)
- await db_conn.commit()
- # return updated object
- return await self.get_album(item_id)
-
- async def get_tracks(
- self,
- filter_query: str = None,
- orderby: str = "name",
- db_conn: aiosqlite.Connection = None,
- ) -> List[Track]:
- """Return all track records from the database."""
- sql_query = "SELECT * FROM tracks"
- if filter_query:
- sql_query += " " + filter_query
- sql_query += " ORDER BY %s" % orderby
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- return [
- Track.from_db_row(db_row)
- for db_row in await db_conn.execute_fetchall(sql_query, ())
- ]
-
- async def get_tracks_from_provider_ids(
- self,
- provider_id: Union[str, List[str], Set[str]],
- prov_item_ids: Union[List[str], Set[str]],
- ) -> List[Track]:
- """Get track records for the given prov_ids."""
- provider_ids = provider_id if isinstance(provider_id, list) else [provider_id]
- prov_id_str = ",".join([f'"{x}"' for x in provider_ids])
- prov_item_id_str = ",".join([f'"{x}"' for x in prov_item_ids])
- sql_query = f"""WHERE item_id in
- (SELECT item_id FROM provider_mappings
- WHERE provider in ({prov_id_str}) AND media_type = 'track'
- AND prov_item_id in ({prov_item_id_str})
- )"""
- return await self.get_tracks(sql_query)
-
- async def get_track(self, item_id: int) -> FullTrack:
- """Get full track record by id."""
- item_id = try_parse_int(item_id)
- for item in await self.get_tracks("WHERE item_id = %d" % item_id):
- # include full album info
- item.albums = set(
- filter(
- None,
- [
- await self.get_album_by_prov_id(album.provider, album.item_id)
- for album in item.albums
- ],
- )
- )
- item.album = next(iter(item.albums))
- # include full artist info
- item.artists = {
- await self.get_artist_by_prov_id(artist.provider, artist.item_id)
- or artist
- for artist in item.artists
- }
- return item
- return None
-
- async def add_track(self, track: Track):
- """Add a new track record to the database."""
- assert track.album, "Track is missing album"
- assert track.artists, "Track is missing artist(s)"
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = None
- # always try to grab existing item by matching
- if track.isrc:
- for item in await self.get_tracks(f"WHERE isrc='{track.isrc}'"):
- cur_item = item
- # fallback to matching
- if not cur_item:
- sql_query = "SELECT item_id FROM tracks WHERE sort_name LIKE ?"
- for db_row in await db_conn.execute_fetchall(
- sql_query, (track.sort_name,)
- ):
- item = await self.get_track(db_row["item_id"])
- if compare_track(item, track):
- cur_item = item
- break
- if cur_item:
- # update existing
- return await self.update_track(cur_item.item_id, track)
- # Item does not yet exist: Insert track
- sql_query = """INSERT INTO tracks
- (name, sort_name, albums, artists, duration, version, isrc, metadata, provider_ids)
- VALUES(?,?,?,?,?,?,?,?,?);"""
- # we store a mapping to artists and albums on the track for easier access/listings
- track_artists = await self._get_track_artists(track)
- track_albums = await self._get_track_albums(track)
-
- async with db_conn.execute(
- sql_query,
- (
- track.name,
- track.sort_name,
- json_serializer(track_albums),
- json_serializer(track_artists),
- track.duration,
- track.version,
- track.isrc,
- json_serializer(track.metadata),
- json_serializer(track.provider_ids),
- ),
- ) as cursor:
- last_row_id = cursor.lastrowid
- new_item = await self.__execute_fetchone(
- "SELECT (item_id) FROM tracks WHERE ROWID=?;",
- (last_row_id,),
- db_conn,
- )
- await self._add_prov_ids(
- new_item[0], MediaType.TRACK, track.provider_ids, db_conn=db_conn
- )
- await db_conn.commit()
- LOGGER.debug("added track %s to database", track.name)
- # return created object
- return await self.get_track(new_item[0])
-
- async def update_track(self, item_id: int, track: Track):
- """Update a track record in the database."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- db_conn.row_factory = aiosqlite.Row
- cur_item = await self.get_track(item_id)
-
- # we store a mapping to artists and albums on the track for easier access/listings
- track_artists = await self._get_track_artists(track, cur_item.artists)
- track_albums = await self._get_track_albums(track, cur_item.albums)
- # merge metadata and provider id's
- metadata = merge_dict(cur_item.metadata, track.metadata)
- provider_ids = merge_list(cur_item.provider_ids, track.provider_ids)
- sql_query = """UPDATE tracks
- SET isrc=?,
- metadata=?,
- provider_ids=?,
- artists=?,
- albums=?
- WHERE item_id=?;"""
- await db_conn.execute(
- sql_query,
- (
- track.isrc or cur_item.isrc,
- json_serializer(metadata),
- json_serializer(provider_ids),
- json_serializer(track_artists),
- json_serializer(track_albums),
- item_id,
- ),
- )
- await self._add_prov_ids(
- item_id, MediaType.TRACK, track.provider_ids, db_conn=db_conn
- )
- LOGGER.debug("updated track %s in database: %s", track.name, item_id)
- await db_conn.commit()
- # return updated object
- return await self.get_track(item_id)
-
- async def set_track_loudness(self, item_id: str, provider: str, loudness: int):
- """Set integrated loudness for a track in db."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- sql_query = """INSERT or REPLACE INTO track_loudness
- (item_id, provider, loudness) VALUES(?,?,?);"""
- await db_conn.execute(sql_query, (item_id, provider, loudness))
- await db_conn.commit()
-
- async def get_track_loudness(self, provider_item_id, provider):
- """Get integrated loudness for a track in db."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- sql_query = """SELECT loudness FROM track_loudness WHERE
- item_id = ? AND provider = ?"""
- async with db_conn.execute(
- sql_query, (provider_item_id, provider)
- ) as cursor:
- result = await cursor.fetchone()
- if result:
- return result[0]
- return None
-
- async def get_provider_loudness(self, provider) -> Optional[float]:
- """Get average integrated loudness for tracks of given provider."""
- all_items = []
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- sql_query = """SELECT loudness FROM track_loudness WHERE provider = ?"""
- async with db_conn.execute(sql_query, (provider,)) as cursor:
- result = await cursor.fetchone()
- if result:
- return result[0]
- sql_query = """SELECT loudness FROM track_loudness WHERE provider = ?"""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- for db_row in await db_conn.execute_fetchall(sql_query, (provider,)):
- all_items.append(db_row[0])
- if all_items:
- return statistics.fmean(all_items)
- return None
-
- async def mark_item_played(self, item_id: str, provider: str):
- """Mark item as played in playlog."""
- timestamp = utc_timestamp()
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- sql_query = """INSERT or REPLACE INTO playlog
- (item_id, provider, timestamp) VALUES(?,?,?);"""
- await db_conn.execute(sql_query, (item_id, provider, timestamp))
- await db_conn.commit()
-
- async def get_thumbnail_id(self, url, size):
- """Get/create id for thumbnail."""
- async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
- sql_query = """SELECT id FROM thumbs WHERE
- url = ? AND size = ?"""
- async with db_conn.execute(sql_query, (url, size)) as cursor:
- result = await cursor.fetchone()
- if result:
- return result[0]
- # create if it doesnt exist
- sql_query = """INSERT INTO thumbs
- (url, size) VALUES(?,?);"""
- async with db_conn.execute(
- sql_query,
- (url, size),
- ) as cursor:
- last_row_id = cursor.lastrowid
- new_item = await self.__execute_fetchone(
- "SELECT id FROM thumbs WHERE ROWID=?;", (last_row_id,), db_conn
- )
- await db_conn.commit()
- return new_item[0]
-
- async def _add_prov_ids(
- self,
- item_id: int,
- media_type: MediaType,
- provider_ids: Set[MediaItemProviderId],
- db_conn: aiosqlite.Connection,
- ):
- """Add provider ids for media item to database."""
-
- for prov in provider_ids:
- sql_query = """INSERT OR REPLACE INTO provider_mappings
- (item_id, media_type, prov_item_id, provider, quality, details)
- VALUES(?,?,?,?,?,?);"""
- await db_conn.execute(
- sql_query,
- (
- item_id,
- media_type.value,
- prov.item_id,
- prov.provider,
- prov.quality,
- prov.details,
- ),
- )
-
- async def __execute_fetchone(
- self, query: str, query_params: tuple, db_conn: aiosqlite.Connection
- ):
- """Return first row of given query."""
- for item in await db_conn.execute_fetchall(query, query_params):
- return item
- return None
-
- async def _get_track_albums(
- self, track: Track, cur_albums: Optional[Set[ItemMapping]] = None
- ) -> Set[ItemMapping]:
- """Extract all (unique) albums of track as ItemMapping."""
- if not track.albums:
- track.albums.add(track.album)
- if cur_albums is None:
- cur_albums = set()
- cur_albums.update(track.albums)
- track_albums = set()
- for album in cur_albums:
- cur_ids = {x.item_id for x in track_albums}
- if isinstance(album, ItemMapping):
- track_album = await self.get_album_by_prov_id(album.provider_id, album)
- else:
- track_album = await self.add_album(album)
- if track_album.item_id not in cur_ids:
- track_albums.add(ItemMapping.from_item(album))
- return track_albums
-
- async def _get_track_artists(
- self, track: Track, cur_artists: Optional[Set[ItemMapping]] = None
- ) -> Set[ItemMapping]:
- """Extract all (unique) artists of track as ItemMapping."""
- if cur_artists is None:
- cur_artists = set()
- cur_artists.update(track.artists)
- track_artists = set()
- for item in cur_artists:
- cur_names = {x.name for x in track_artists}
- cur_ids = {x.item_id for x in track_artists}
- track_artist = (
- await self.get_artist_by_prov_id(item.provider, item.item_id) or item
- )
- if (
- track_artist.name not in cur_names
- and track_artist.item_id not in cur_ids
- ):
- track_artists.add(ItemMapping.from_item(track_artist))
- return track_artists
+++ /dev/null
-"""Logic to process events throughout the application."""
-
-
-import logging
-from typing import Any, Awaitable, Callable, Tuple, Union
-
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import callback, create_task
-
-LOGGER = logging.getLogger("eventbus")
-
-
-class EventBus:
- """Global EventBus handling listening for and forwarding of events."""
-
- def __init__(self, mass: MusicAssistant):
- """Initialize EventBus instance."""
- self.mass = mass
- self._listeners = []
-
- @callback
- def signal(self, event_msg: str, event_details: Any = None) -> None:
- """
- Signal (systemwide) event.
-
- :param event_msg: the eventmessage to signal
- :param event_details: optional details to send with the event.
- """
- if self.mass.debug:
- LOGGER.debug("%s: %s", event_msg, str(event_details))
- for cb_func, event_filter in self._listeners:
- if not event_filter or event_msg in event_filter:
- create_task(cb_func, event_msg, event_details)
-
- @callback
- def add_listener(
- self,
- cb_func: Callable[..., Union[None, Awaitable]],
- event_filter: Union[None, str, Tuple] = None,
- ) -> Callable:
- """
- Add callback to event listeners.
-
- Returns function to remove the listener.
- :param cb_func: callback function or coroutine
- :param event_filter: Optionally only listen for these events
- """
- listener = (cb_func, event_filter)
- self._listeners.append(listener)
-
- def remove_listener():
- self._listeners.remove(listener)
-
- return remove_listener
+++ /dev/null
-"""LibraryManager: Orchestrates synchronisation of music providers into the library."""
-
-import logging
-import time
-from typing import Any, List, Optional
-
-from music_assistant.constants import EVENT_PROVIDER_REGISTERED
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.web import api_route
-from music_assistant.managers.tasks import TaskInfo
-from music_assistant.models.media_types import (
- Album,
- Artist,
- MediaItem,
- MediaType,
- Playlist,
- Radio,
- Track,
-)
-from music_assistant.models.provider import ProviderType
-
-LOGGER = logging.getLogger("music_manager")
-
-
-class LibraryManager:
- """Manage sync of musicproviders to library."""
-
- def __init__(self, mass: MusicAssistant):
- """Initialize class."""
- self.running_sync_jobs = set()
- self.mass = mass
- self.cache = mass.cache
- self._sync_tasks = set()
- self.mass.eventbus.add_listener(self.mass_event, EVENT_PROVIDER_REGISTERED)
-
- async def setup(self):
- """Async initialize of module."""
- # schedule sync task for each provider that is already registered at startup
- for prov in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
- if prov.id not in self._sync_tasks:
- self._sync_tasks.add(prov.id)
- await self.music_provider_sync(prov.id)
-
- async def mass_event(self, msg: str, msg_details: Any):
- """Handle message on eventbus."""
- if msg == EVENT_PROVIDER_REGISTERED:
- # schedule the sync task when a new provider registers
- provider = self.mass.get_provider(msg_details)
- if provider.type == ProviderType.MUSIC_PROVIDER:
- if msg_details not in self._sync_tasks:
- self._sync_tasks.add(msg_details)
- await self.music_provider_sync(msg_details, periodic=3 * 3600)
-
- ################ GET MediaItems that are added in the library ################
-
- @api_route("library/artists")
- async def get_library_artists(self, orderby: str = "name") -> List[Artist]:
- """Return all library artists, optionally filtered by provider."""
- return await self.mass.database.get_library_artists(orderby=orderby)
-
- @api_route("library/albums")
- async def get_library_albums(self, orderby: str = "name") -> List[Album]:
- """Return all library albums, optionally filtered by provider."""
- return await self.mass.database.get_library_albums(orderby=orderby)
-
- @api_route("library/tracks")
- async def get_library_tracks(self, orderby: str = "name") -> List[Track]:
- """Return all library tracks, optionally filtered by provider."""
- return await self.mass.database.get_library_tracks(orderby=orderby)
-
- @api_route("library/playlists")
- async def get_library_playlists(self, orderby: str = "name") -> List[Playlist]:
- """Return all library playlists, optionally filtered by provider."""
- return await self.mass.database.get_library_playlists(orderby=orderby)
-
- @api_route("library/radios")
- async def get_library_radios(self, orderby: str = "name") -> List[Playlist]:
- """Return all library radios, optionally filtered by provider."""
- return await self.mass.database.get_library_radios(orderby=orderby)
-
- async def get_library_playlist_by_name(self, name: str) -> Playlist:
- """Get in-library playlist by name."""
- for playlist in await self.mass.music.get_library_playlists():
- if playlist.name == name:
- return playlist
- return None
-
- async def get_radio_by_name(self, name: str) -> Radio:
- """Get in-library radio by name."""
- for radio in await self.mass.music.get_library_radios():
- if radio.name == name:
- return radio
- return None
-
- @api_route("library", method="POST")
- async def library_add_items(self, items: List[MediaItem]) -> List[TaskInfo]:
- """
- Add media item(s) to the library.
-
- Creates background tasks to process the action.
- """
- result = []
- for media_item in items:
- job_desc = f"Add {media_item.uri} to library"
- result.append(
- self.mass.tasks.add(job_desc, self.library_add_item, media_item)
- )
- return result
-
- async def library_add_item(self, item: MediaItem):
- """Add media item to the library."""
- # make sure we have a valid full item
- item = await self.mass.music.get_item(
- item.item_id, item.provider, item.media_type, lazy=False
- )
- # add to provider's libraries
- for prov in item.provider_ids:
- provider = self.mass.get_provider(prov.provider)
- if provider:
- await provider.library_add(prov.item_id, item.media_type)
- # mark as library item in internal db
- await self.mass.database.add_to_library(item.item_id, item.media_type)
-
- @api_route("library", method="DELETE")
- async def library_remove_items(self, items: List[MediaItem]) -> List[TaskInfo]:
- """
- Remove media item(s) from the library.
-
- Creates background tasks to process the action.
- """
- result = []
- for media_item in items:
- job_desc = f"Remove {media_item.uri} from library"
- result.append(
- self.mass.tasks.add(job_desc, self.library_remove_item, media_item)
- )
- return result
-
- async def library_remove_item(self, item: MediaItem) -> None:
- """Remove media item(s) from the library."""
- # remove from provider's libraries
- for prov in item.provider_ids:
- provider = self.mass.get_provider(prov.provider)
- if provider:
- await provider.library_remove(prov.item_id, item.media_type)
- # mark as library item in internal db
- if item.provider == "database":
- await self.mass.database.remove_from_library(item.item_id, item.media_type)
-
- @api_route("library/playlists/{db_playlist_id}/tracks", method="POST")
- async def add_playlist_tracks(
- self, db_playlist_id: int, tracks: List[Track]
- ) -> List[TaskInfo]:
- """Add multiple tracks to playlist. Creates background tasks to process the action."""
- result = []
- playlist = await self.mass.music.get_playlist(db_playlist_id, "database")
- if not playlist:
- raise RuntimeError("Playlist %s not found" % db_playlist_id)
- if not playlist.is_editable:
- raise RuntimeError("Playlist %s is not editable" % playlist.name)
- for track in tracks:
- job_desc = f"Add track {track.uri} to playlist {playlist.uri}"
- result.append(
- self.mass.tasks.add(
- job_desc, self.add_playlist_track, db_playlist_id, track
- )
- )
- return result
-
- async def add_playlist_track(self, db_playlist_id: int, track: Track) -> None:
- """Add track to playlist - make sure we dont add duplicates."""
- # we can only edit playlists that are in the database (marked as editable)
- playlist = await self.mass.music.get_playlist(db_playlist_id, "database")
- if not playlist:
- raise RuntimeError("Playlist %s not found" % db_playlist_id)
- if not playlist.is_editable:
- raise RuntimeError("Playlist %s is not editable" % playlist.name)
- # make sure we have recent full track details
- track = await self.mass.music.get_track(
- track.item_id, track.provider, refresh=True, lazy=False
- )
- # a playlist can only have one provider (for now)
- playlist_prov = next(iter(playlist.provider_ids))
- # grab all existing track ids in the playlist so we can check for duplicates
- cur_playlist_track_ids = set()
- for item in await self.mass.music.get_playlist_tracks(
- playlist_prov.item_id, playlist_prov.provider
- ):
- cur_playlist_track_ids.update(
- {
- i.item_id
- for i in item.provider_ids
- if i.provider == playlist_prov.provider
- }
- )
- # check for duplicates
- for track_prov in track.provider_ids:
- if (
- track_prov.provider == playlist_prov.provider
- and track_prov.item_id in cur_playlist_track_ids
- ):
- raise RuntimeError(
- "Track already exists in playlist %s" % playlist.name
- )
- # add track to playlist
- # we can only add a track to a provider playlist if track is available on that provider
- # a track can contain multiple versions on the same provider
- # simply sort by quality and just add the first one (assuming track is still available)
- track_id_to_add = None
- for track_version in sorted(
- track.provider_ids, key=lambda x: x.quality, reverse=True
- ):
- if not track.available:
- continue
- if track_version.provider == playlist_prov.provider:
- track_id_to_add = track_version.item_id
- break
- if playlist_prov.provider == "file":
- # the file provider can handle uri's from all providers so simply add the uri
- track_id_to_add = track.uri
- break
- if not track_id_to_add:
- raise RuntimeError(
- "Track is not available on provider %s" % playlist_prov.provider
- )
- # actually add the tracks to the playlist on the provider
- # invalidate cache
- playlist.checksum = str(time.time())
- await self.mass.database.update_playlist(playlist.item_id, playlist)
- # return result of the action on the provider
- provider = self.mass.get_provider(playlist_prov.provider)
- return await provider.add_playlist_tracks(
- playlist_prov.item_id, [track_id_to_add]
- )
-
- @api_route("library/playlists/{db_playlist_id}/tracks", method="DELETE")
- async def remove_playlist_tracks(
- self, db_playlist_id: int, tracks: List[Track]
- ) -> List[TaskInfo]:
- """Remove multiple tracks from playlist. Creates background tasks to process the action."""
- result = []
- playlist = await self.mass.music.get_playlist(db_playlist_id, "database")
- if not playlist:
- raise RuntimeError("Playlist %s not found" % db_playlist_id)
- if not playlist.is_editable:
- raise RuntimeError("Playlist %s is not editable" % playlist.name)
- for track in tracks:
- job_desc = f"Remove track {track.uri} from playlist {playlist.uri}"
- result.append(
- self.mass.tasks.add(
- job_desc, self.remove_playlist_track, db_playlist_id, track
- )
- )
- return result
-
- async def remove_playlist_track(self, db_playlist_id, track: Track) -> None:
- """Remove track from playlist."""
- # we can only edit playlists that are in the database (marked as editable)
- playlist = await self.mass.music.get_playlist(db_playlist_id, "database")
- if not playlist or not playlist.is_editable:
- return False
- # playlist can only have one provider (for now)
- prov_playlist = next(iter(playlist.provider_ids))
- track_ids_to_remove = set()
- # a track can contain multiple versions on the same provider, remove all
- for track_provider in track.provider_ids:
- if track_provider.provider == prov_playlist.provider:
- track_ids_to_remove.add(track_provider.item_id)
- # actually remove the tracks from the playlist on the provider
- if track_ids_to_remove:
- # invalidate cache
- playlist.checksum = str(time.time())
- await self.mass.database.update_playlist(playlist.item_id, playlist)
- provider = self.mass.get_provider(prov_playlist.provider)
- return await provider.remove_playlist_tracks(
- prov_playlist.item_id, track_ids_to_remove
- )
-
- async def music_provider_sync(self, prov_id: str, periodic: Optional[int] = None):
- """
- Sync a music provider.
-
- param prov_id: {string} -- provider id to sync
- """
- provider = self.mass.get_provider(prov_id)
- if not provider:
- return
- if MediaType.ALBUM in provider.supported_mediatypes:
- self.mass.tasks.add(
- f"Library sync of albums for provider {provider.name}",
- self.library_albums_sync,
- prov_id,
- periodic=periodic,
- )
- if MediaType.TRACK in provider.supported_mediatypes:
- self.mass.tasks.add(
- f"Library sync of tracks for provider {provider.name}",
- self.library_tracks_sync,
- prov_id,
- periodic=periodic,
- )
- if MediaType.ARTIST in provider.supported_mediatypes:
- self.mass.tasks.add(
- f"Library sync of artists for provider {provider.name}",
- self.library_artists_sync,
- prov_id,
- periodic=periodic,
- )
- if MediaType.PLAYLIST in provider.supported_mediatypes:
- self.mass.tasks.add(
- f"Library sync of playlists for provider {provider.name}",
- self.library_playlists_sync,
- prov_id,
- periodic=periodic,
- )
- if MediaType.RADIO in provider.supported_mediatypes:
- self.mass.tasks.add(
- f"Library sync of radio for provider {provider.name}",
- self.library_radios_sync,
- prov_id,
- periodic=periodic,
- )
-
- async def library_artists_sync(self, provider_id: str):
- """Sync library artists for given provider."""
- music_provider = self.mass.get_provider(provider_id)
- cache_key = f"library_artists_{provider_id}"
- prev_db_ids = await self.mass.cache.get(cache_key, default=[])
- cur_db_ids = set()
- for item in await music_provider.get_library_artists():
- db_item = await self.mass.music.get_artist(
- item.item_id, provider_id, lazy=False
- )
- cur_db_ids.add(db_item.item_id)
- if not db_item.in_library:
- await self.mass.database.add_to_library(
- db_item.item_id, MediaType.ARTIST
- )
- # process deletions
- for db_id in prev_db_ids:
- if db_id not in cur_db_ids:
- await self.mass.database.remove_from_library(db_id, MediaType.ARTIST)
- # store ids in cache for next sync
- await self.mass.cache.set(cache_key, cur_db_ids)
-
- async def library_albums_sync(self, provider_id: str):
- """Sync library albums for given provider."""
- music_provider = self.mass.get_provider(provider_id)
- cache_key = f"library_albums_{provider_id}"
- prev_db_ids = await self.mass.cache.get(cache_key, default=[])
- cur_db_ids = set()
- for item in await music_provider.get_library_albums():
- db_album = await self.mass.music.get_album(
- item.item_id, provider_id, lazy=False
- )
- if not db_album.available and not item.available:
- # album availability changed, sort this out with auto matching magic
- db_album = await self.mass.music.match_album(db_album)
- cur_db_ids.add(db_album.item_id)
- if not db_album.in_library:
- await self.mass.database.add_to_library(
- db_album.item_id, MediaType.ALBUM
- )
- # precache album tracks
- await self.mass.music.get_album_tracks(item.item_id, provider_id)
- # process album deletions
- for db_id in prev_db_ids:
- if db_id not in cur_db_ids:
- await self.mass.database.remove_from_library(db_id, MediaType.ALBUM)
- # store ids in cache for next sync
- await self.mass.cache.set(cache_key, cur_db_ids)
-
- async def library_tracks_sync(self, provider_id: str):
- """Sync library tracks for given provider."""
- music_provider = self.mass.get_provider(provider_id)
- cache_key = f"library_tracks_{provider_id}"
- prev_db_ids = await self.mass.cache.get(cache_key, default=[])
- cur_db_ids = set()
- for item in await music_provider.get_library_tracks():
- db_item = await self.mass.music.get_track(
- item.item_id, provider_id, track_details=item, lazy=False
- )
- if not db_item.available and not item.available:
- # track availability changed, sort this out with auto matching magic
- db_item = await self.mass.music.add_track(item)
- cur_db_ids.add(db_item.item_id)
- if not db_item.in_library:
- await self.mass.database.add_to_library(
- db_item.item_id, MediaType.TRACK
- )
- # process deletions
- for db_id in prev_db_ids:
- if db_id not in cur_db_ids:
- await self.mass.database.remove_from_library(db_id, MediaType.TRACK)
- # store ids in cache for next sync
- await self.mass.cache.set(cache_key, cur_db_ids)
-
- async def library_playlists_sync(self, provider_id: str):
- """Sync library playlists for given provider."""
- music_provider = self.mass.get_provider(provider_id)
- cache_key = f"library_playlists_{provider_id}"
- prev_db_ids = await self.mass.cache.get(cache_key, default=[])
- cur_db_ids = set()
- for playlist in await music_provider.get_library_playlists():
- db_item = await self.mass.music.get_playlist(
- playlist.item_id, provider_id, lazy=False
- )
- if db_item.checksum != playlist.checksum:
- db_item = await self.mass.database.add_playlist(playlist)
- cur_db_ids.add(db_item.item_id)
- await self.mass.database.add_to_library(db_item.item_id, MediaType.PLAYLIST)
-
- # process playlist deletions
- for db_id in prev_db_ids:
- if db_id not in cur_db_ids:
- await self.mass.database.remove_from_library(db_id, MediaType.PLAYLIST)
- # store ids in cache for next sync
- await self.mass.cache.set(cache_key, cur_db_ids)
-
- async def library_radios_sync(self, provider_id: str):
- """Sync library radios for given provider."""
- music_provider = self.mass.get_provider(provider_id)
- cache_key = f"library_radios_{provider_id}"
- prev_db_ids = await self.mass.cache.get(cache_key, default=[])
- cur_db_ids = set()
- for item in await music_provider.get_library_radios():
- db_radio = await self.mass.music.get_radio(
- item.item_id, provider_id, lazy=False
- )
- cur_db_ids.add(db_radio.item_id)
- await self.mass.database.add_to_library(db_radio.item_id, MediaType.RADIO)
- # process deletions
- for db_id in prev_db_ids:
- if db_id not in cur_db_ids:
- await self.mass.database.remove_from_library(
- db_id,
- MediaType.RADIO,
- )
- # store ids in cache for next sync
- await self.mass.cache.set(cache_key, cur_db_ids)
+++ /dev/null
-"""All logic for metadata retrieval."""
-
-import logging
-from typing import Dict, List
-
-from music_assistant.helpers.cache import cached
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import merge_dict
-from music_assistant.models.provider import MetadataProvider, ProviderType
-
-LOGGER = logging.getLogger("metadata")
-
-
-class MetaDataManager:
- """Several helpers to search and store metadata for mediaitems using metadata providers."""
-
- # TODO: create periodic task to search for missing metadata
- def __init__(self, mass: MusicAssistant) -> None:
- """Initialize class."""
- self.mass = mass
- self.cache = mass.cache
-
- @property
- def providers(self) -> List[MetadataProvider]:
- """Return all providers of type MetadataProvider."""
- return self.mass.get_providers(ProviderType.METADATA_PROVIDER)
-
- async def get_artist_metadata(self, mb_artist_id: str, cur_metadata: Dict) -> Dict:
- """Get/update rich metadata for an artist by providing the musicbrainz artist id."""
- metadata = cur_metadata
- for provider in self.providers:
- if "fanart" in metadata:
- # no need to query (other) metadata providers if we already have a result
- break
- LOGGER.info(
- "Fetching metadata for MusicBrainz Artist %s on provider %s",
- mb_artist_id,
- provider.name,
- )
- cache_key = f"{provider.id}.artist_metadata.{mb_artist_id}"
- res = await cached(
- self.cache, cache_key, provider.get_artist_images, mb_artist_id
- )
- if res:
- metadata = merge_dict(metadata, res)
- LOGGER.debug(
- "Found metadata for MusicBrainz Artist %s on provider %s: %s",
- mb_artist_id,
- provider.name,
- ", ".join(res.keys()),
- )
- return metadata
+++ /dev/null
-"""MusicManager: Orchestrates all data from music providers and sync to internal database."""
-
-import asyncio
-import logging
-from typing import List, Set, Tuple
-
-from music_assistant.constants import (
- EVENT_ALBUM_ADDED,
- EVENT_ARTIST_ADDED,
- EVENT_PLAYLIST_ADDED,
- EVENT_RADIO_ADDED,
- EVENT_TRACK_ADDED,
-)
-from music_assistant.helpers.cache import cached
-from music_assistant.helpers.compare import (
- compare_album,
- compare_artists,
- compare_strings,
- compare_track,
-)
-from music_assistant.helpers.musicbrainz import MusicBrainz
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.web import api_route
-from music_assistant.managers.tasks import TaskInfo
-from music_assistant.models.media_types import (
- Album,
- AlbumType,
- Artist,
- FullAlbum,
- ItemMapping,
- MediaItem,
- MediaType,
- Playlist,
- Radio,
- SearchResult,
- Track,
-)
-from music_assistant.models.provider import MusicProvider, ProviderType
-
-LOGGER = logging.getLogger("music_manager")
-
-
-class MusicManager:
- """Several helpers around the musicproviders."""
-
- def __init__(self, mass: MusicAssistant):
- """Initialize class."""
- self.mass = mass
- self.cache = mass.cache
- self.musicbrainz = MusicBrainz(mass)
- self._db_add_progress = set()
-
- async def setup(self):
- """Async initialize of module."""
-
- @property
- def providers(self) -> Tuple[MusicProvider]:
- """Return all providers of type musicprovider."""
- return self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
-
- ################ GET MediaItem(s) by id and provider #################
-
- @api_route("artists/{provider_id}/{item_id}")
- async def get_artist(
- self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
- ) -> Artist:
- """Return artist details for the given provider artist id."""
- if provider_id == "database" and not refresh:
- return await self.mass.database.get_artist(item_id)
- db_item = await self.mass.database.get_artist_by_prov_id(provider_id, item_id)
- if db_item and refresh:
- provider_id, item_id = await self.__get_provider_id(db_item)
- elif db_item:
- return db_item
- artist = await self._get_provider_artist(item_id, provider_id)
- if not lazy:
- return await self.add_artist(artist)
- self.mass.tasks.add(
- f"Add artist {artist.uri} to database", self.add_artist, artist
- )
- return db_item if db_item else artist
-
- async def _get_provider_artist(self, item_id: str, provider_id: str) -> Artist:
- """Return artist details for the given provider artist id."""
- provider = self.mass.get_provider(provider_id)
- if not provider or not provider.available:
- raise Exception("Provider %s is not available!" % provider_id)
- cache_key = f"{provider_id}.get_artist.{item_id}"
- artist = await cached(self.cache, cache_key, provider.get_artist, item_id)
- if not artist:
- raise Exception(
- "Artist %s not found on provider %s" % (item_id, provider_id)
- )
- return artist
-
- @api_route("albums/{provider_id}/{item_id}")
- async def get_album(
- self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
- ) -> Album:
- """Return album details for the given provider album id."""
- if provider_id == "database" and not refresh:
- return await self.mass.database.get_album(item_id)
- db_item = await self.mass.database.get_album_by_prov_id(provider_id, item_id)
- if db_item and refresh:
- provider_id, item_id = await self.__get_provider_id(db_item)
- elif db_item:
- return db_item
- album = await self._get_provider_album(item_id, provider_id)
- if not lazy:
- return await self.add_album(album)
- self.mass.tasks.add(f"Add album {album.uri} to database", self.add_album, album)
- return db_item if db_item else album
-
- async def _get_provider_album(self, item_id: str, provider_id: str) -> Album:
- """Return album details for the given provider album id."""
- provider = self.mass.get_provider(provider_id)
- if not provider or not provider.available:
- raise Exception("Provider %s is not available!" % provider_id)
- cache_key = f"{provider_id}.get_album.{item_id}"
- album = await cached(self.cache, cache_key, provider.get_album, item_id)
- if not album:
- raise Exception(
- "Album %s not found on provider %s" % (item_id, provider_id)
- )
- return album
-
- @api_route("tracks/{provider_id}/{item_id}")
- async def get_track(
- self,
- item_id: str,
- provider_id: str,
- track_details: Track = None,
- album_details: Album = None,
- refresh: bool = False,
- lazy: bool = True,
- ) -> Track:
- """Return track details for the given provider track id."""
- if provider_id == "database" and not refresh:
- return await self.mass.database.get_track(item_id)
- db_item = await self.mass.database.get_track_by_prov_id(provider_id, item_id)
- if db_item and refresh:
- provider_id, item_id = await self.__get_provider_id(db_item)
- elif db_item:
- return db_item
- if not track_details:
- track_details = await self._get_provider_track(item_id, provider_id)
- if album_details:
- track_details.album = album_details
- if not lazy:
- return await self.add_track(track_details)
- self.mass.tasks.add(
- f"Add track {track_details.uri} to database", self.add_track, track_details
- )
- return db_item if db_item else track_details
-
- async def _get_provider_track(self, item_id: str, provider_id: str) -> Track:
- """Return track details for the given provider track id."""
- provider = self.mass.get_provider(provider_id)
- if not provider or not provider.available:
- raise Exception("Provider %s is not available!" % provider_id)
- cache_key = f"{provider_id}.get_track.{item_id}"
- track = await cached(self.cache, cache_key, provider.get_track, item_id)
- if not track:
- raise Exception(
- "Track %s not found on provider %s" % (item_id, provider_id)
- )
- return track
-
- @api_route("playlists/{provider_id}/{item_id}")
- async def get_playlist(
- self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
- ) -> Playlist:
- """Return playlist details for the given provider playlist id."""
- assert item_id and provider_id
- db_item = await self.mass.database.get_playlist_by_prov_id(provider_id, item_id)
- if db_item and refresh:
- provider_id, item_id = await self.__get_provider_id(db_item)
- elif db_item:
- return db_item
- playlist = await self._get_provider_playlist(item_id, provider_id)
- if not lazy:
- return await self.add_playlist(playlist)
- self.mass.tasks.add(
- f"Add playlist {playlist.name} to database", self.add_playlist, playlist
- )
- return db_item if db_item else playlist
-
- async def _get_provider_playlist(self, item_id: str, provider_id: str) -> Playlist:
- """Return playlist details for the given provider playlist id."""
- provider = self.mass.get_provider(provider_id)
- if not provider or not provider.available:
- raise Exception("Provider %s is not available!" % provider_id)
- cache_key = f"{provider_id}.get_playlist.{item_id}"
- playlist = await cached(
- self.cache,
- cache_key,
- provider.get_playlist,
- item_id,
- expires=86400 * 2,
- )
- if not playlist:
- raise Exception(
- "Playlist %s not found on provider %s" % (item_id, provider_id)
- )
- return playlist
-
- @api_route("radios/{provider_id}/{item_id}")
- async def get_radio(
- self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
- ) -> Radio:
- """Return radio details for the given provider radio id."""
- assert item_id and provider_id
- db_item = await self.mass.database.get_radio_by_prov_id(provider_id, item_id)
- if db_item and refresh:
- provider_id, item_id = await self.__get_provider_id(db_item)
- elif db_item:
- return db_item
- radio = await self._get_provider_radio(item_id, provider_id)
- if not lazy:
- return await self.add_radio(radio)
- self.mass.tasks.add(
- f"Add radio station {radio.name} to database", self.add_radio, radio
- )
- return db_item if db_item else radio
-
- async def _get_provider_radio(self, item_id: str, provider_id: str) -> Radio:
- """Return radio details for the given provider playlist id."""
- provider = self.mass.get_provider(provider_id)
- if not provider or not provider.available:
- raise Exception("Provider %s is not available!" % provider_id)
- cache_key = f"{provider_id}.get_radio.{item_id}"
- radio = await cached(self.cache, cache_key, provider.get_radio, item_id)
- if not radio:
- raise Exception(
- "Radio %s not found on provider %s" % (item_id, provider_id)
- )
- return radio
-
- @api_route("albums/{provider_id}/{item_id}/tracks")
- async def get_album_tracks(self, item_id: str, provider_id: str) -> List[Track]:
- """Return album tracks for the given provider album id."""
- assert item_id and provider_id
- album = await self.get_album(item_id, provider_id)
- if album.provider == "database":
- # album tracks are not stored in db, we always fetch them (cached) from the provider.
- prov_id = next(iter(album.provider_ids))
- provider_id = prov_id.provider
- item_id = prov_id.item_id
- provider = self.mass.get_provider(provider_id)
- cache_key = f"{provider_id}.album_tracks.{item_id}"
- all_prov_tracks = await cached(
- self.cache, cache_key, provider.get_album_tracks, item_id
- )
- # retrieve list of db items
- db_tracks = await self.mass.database.get_tracks_from_provider_ids(
- {x.provider for x in album.provider_ids},
- {x.item_id for x in all_prov_tracks},
- )
- # combine provider tracks with db tracks
- return [
- await self.__process_item(
- item,
- db_tracks,
- album=album,
- disc_number=item.disc_number,
- track_number=item.track_number,
- )
- for item in all_prov_tracks
- ]
-
- @api_route("albums/{provider_id}/{item_id}/versions")
- async def get_album_versions(self, item_id: str, provider_id: str) -> Set[Album]:
- """Return all versions of an album we can find on all providers."""
- album = await self.get_album(item_id, provider_id)
- provider_ids = {
- item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
- }
- search_query = f"{album.artist.name} {album.name}"
- return {
- prov_item
- for prov_items in await asyncio.gather(
- *[
- self.search_provider(search_query, prov_id, [MediaType.ALBUM], 25)
- for prov_id in provider_ids
- ]
- )
- for prov_item in prov_items.albums
- if compare_strings(prov_item.artist.name, album.artist.name)
- }
-
- @api_route("tracks/{provider_id}/{item_id}/versions")
- async def get_track_versions(self, item_id: str, provider_id: str) -> Set[Track]:
- """Return all versions of a track we can find on all providers."""
- track = await self.get_track(item_id, provider_id)
- provider_ids = {
- item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
- }
- first_artist = next(iter(track.artists))
- search_query = f"{first_artist.name} {track.name}"
- return {
- prov_item
- for prov_items in await asyncio.gather(
- *[
- self.search_provider(search_query, prov_id, [MediaType.TRACK], 25)
- for prov_id in provider_ids
- ]
- )
- for prov_item in prov_items.tracks
- if compare_artists(prov_item.artists, track.artists)
- }
-
- @api_route("playlists/{provider_id}/{item_id}/tracks")
- async def get_playlist_tracks(self, item_id: str, provider_id: str) -> List[Track]:
- """Return playlist tracks for the given provider playlist id."""
- assert item_id and provider_id
- if provider_id == "database":
- # playlist tracks are not stored in db, we always fetch them (cached) from the provider.
- playlist = await self.mass.database.get_playlist(item_id)
- prov_id = next(iter(playlist.provider_ids))
- provider_id = prov_id.provider
- item_id = prov_id.item_id
- provider = self.mass.get_provider(provider_id)
- else:
- provider = self.mass.get_provider(provider_id)
- playlist = await provider.get_playlist(item_id)
- cache_checksum = playlist.checksum
- cache_key = f"{provider_id}.playlist_tracks.{item_id}"
- return await cached(
- self.cache,
- cache_key,
- provider.get_playlist_tracks,
- item_id,
- checksum=cache_checksum,
- )
-
- async def __process_item(
- self,
- item,
- db_items,
- index=None,
- album=None,
- disc_number=None,
- track_number=None,
- ):
- """Return combined result of provider item and db result."""
- for db_item in db_items:
- if item.item_id in {x.item_id for x in db_item.provider_ids}:
- item = db_item
- break
- if index is not None and not item.position:
- item.position = index
- if album is not None:
- item.album = album
- if disc_number is not None:
- item.disc_number = disc_number
- if track_number is not None:
- item.track_number = track_number
- return item
-
- @api_route("artists/{provider_id}/{item_id}/tracks")
- async def get_artist_toptracks(self, item_id: str, provider_id: str) -> Set[Track]:
- """Return top tracks for an artist."""
- if provider_id != "database":
- return await self._get_provider_artist_toptracks(item_id, provider_id)
-
- # db artist: get results from all providers
- artist = await self.get_artist(item_id, provider_id)
- all_prov_tracks = {
- track
- for prov_tracks in await asyncio.gather(
- *[
- self._get_provider_artist_toptracks(item.item_id, item.provider)
- for item in artist.provider_ids
- ]
- )
- for track in prov_tracks
- }
- # retrieve list of db items
- db_tracks = await self.mass.database.get_tracks_from_provider_ids(
- {x.provider for x in artist.provider_ids},
- {x.item_id for x in all_prov_tracks},
- )
- # combine provider tracks with db tracks and filter duplicate itemid's
- return {await self.__process_item(item, db_tracks) for item in all_prov_tracks}
-
- async def _get_provider_artist_toptracks(
- self, item_id: str, provider_id: str
- ) -> List[Track]:
- """Return top tracks for an artist on given provider."""
- provider = self.mass.get_provider(provider_id)
- if not provider or not provider.available:
- LOGGER.error("Provider %s is not available", provider_id)
- return []
- cache_key = f"{provider_id}.artist_toptracks.{item_id}"
- return await cached(
- self.cache,
- cache_key,
- provider.get_artist_toptracks,
- item_id,
- )
-
- @api_route("artists/{provider_id}/{item_id}/albums")
- async def get_artist_albums(self, item_id: str, provider_id: str) -> Set[Album]:
- """Return (all) albums for an artist."""
- if provider_id != "database":
- return await self._get_provider_artist_albums(item_id, provider_id)
- # db artist: get results from all providers
- artist = await self.get_artist(item_id, provider_id)
- all_prov_albums = {
- album
- for prov_albums in await asyncio.gather(
- *[
- self._get_provider_artist_albums(item.item_id, item.provider)
- for item in artist.provider_ids
- ]
- )
- for album in prov_albums
- }
- # retrieve list of db items
- db_tracks = await self.mass.database.get_albums_from_provider_ids(
- [x.provider for x in artist.provider_ids],
- [x.item_id for x in all_prov_albums],
- )
- # combine provider tracks with db tracks and filter duplicate itemid's
- return {await self.__process_item(item, db_tracks) for item in all_prov_albums}
-
- async def _get_provider_artist_albums(
- self, item_id: str, provider_id: str
- ) -> List[Album]:
- """Return albums for an artist on given provider."""
- provider = self.mass.get_provider(provider_id)
- if not provider or not provider.available:
- LOGGER.error("Provider %s is not available", provider_id)
- return []
- cache_key = f"{provider_id}.artistalbums.{item_id}"
- return await cached(
- self.cache,
- cache_key,
- provider.get_artist_albums,
- item_id,
- )
-
- @api_route("search/{provider_id}")
- async def search_provider(
- self,
- search_query: str,
- provider_id: str,
- media_types: List[MediaType],
- limit: int = 10,
- ) -> SearchResult:
- """
- Perform search on given provider.
-
- :param search_query: Search query
- :param provider_id: provider_id of the provider to perform the search on.
- :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).
- """
- if provider_id == "database":
- # get results from database
- return await self.mass.database.search(search_query, media_types)
- provider = self.mass.get_provider(provider_id)
- cache_key = f"{provider_id}.search.{search_query}.{media_types}.{limit}"
- return await cached(
- self.cache,
- cache_key,
- provider.search,
- search_query,
- media_types,
- limit,
- )
-
- @api_route("search")
- async def global_search(
- self, search_query, media_types: List[MediaType], limit: int = 10
- ) -> SearchResult:
- """
- Perform global search for media items on all providers.
-
- :param search_query: Search query.
- :param media_types: A list of media_types to include.
- :param limit: number of items to return in the search (per type).
- """
- result = SearchResult([], [], [], [], [])
- # include results from all music providers
- provider_ids = ["database"] + [
- item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
- ]
- for provider_id in provider_ids:
- provider_result = await self.search_provider(
- search_query, provider_id, media_types, limit
- )
- result.artists += provider_result.artists
- result.albums += provider_result.albums
- result.tracks += provider_result.tracks
- result.playlists += provider_result.playlists
- result.radios += provider_result.radios
- # TODO: sort by name and filter out duplicates ?
- return result
-
- @api_route("items/by_uri")
- async def get_item_by_uri(self, uri: str) -> MediaItem:
- """Fetch MediaItem by uri."""
- if "://" in uri:
- provider = uri.split("://")[0]
- item_id = uri.split("/")[-1]
- media_type = MediaType(uri.split("/")[-2])
- else:
- # spotify new-style uri
- provider, media_type, item_id = uri.split(":")
- media_type = MediaType(media_type)
- return await self.get_item(item_id, provider, media_type)
-
- @api_route("items/{media_type}/{provider_id}/{item_id}")
- async def get_item(
- self,
- item_id: str,
- provider_id: str,
- media_type: MediaType,
- refresh: bool = False,
- lazy: bool = True,
- ) -> MediaItem:
- """Get single music item by id and media type."""
- if media_type == MediaType.ARTIST:
- return await self.get_artist(
- item_id, provider_id, refresh=refresh, lazy=lazy
- )
- if media_type == MediaType.ALBUM:
- return await self.get_album(
- item_id, provider_id, refresh=refresh, lazy=lazy
- )
- if media_type == MediaType.TRACK:
- return await self.get_track(
- item_id, provider_id, refresh=refresh, lazy=lazy
- )
- if media_type == MediaType.PLAYLIST:
- return await self.get_playlist(
- item_id, provider_id, refresh=refresh, lazy=lazy
- )
- if media_type == MediaType.RADIO:
- return await self.get_radio(
- item_id, provider_id, refresh=refresh, lazy=lazy
- )
- return None
-
- @api_route("items/refresh", method="PUT")
- async def refresh_items(self, items: List[MediaItem]) -> List[TaskInfo]:
- """
- Refresh MediaItems to force retrieval of full info and matches.
-
- Creates background tasks to process the action.
- """
- result = []
- for media_item in items:
- job_desc = f"Refresh metadata of {media_item.uri}"
- result.append(self.mass.tasks.add(job_desc, self.refresh_item, media_item))
- return result
-
- async def refresh_item(
- self,
- media_item: MediaItem,
- ):
- """Try to refresh a mediaitem by requesting it's full object or search for substitutes."""
- try:
- return await self.get_item(
- media_item.item_id,
- media_item.provider,
- media_item.media_type,
- refresh=True,
- lazy=False,
- )
- except Exception: # pylint:disable=broad-except
- pass
- searchresult: SearchResult = await self.global_search(
- media_item.name, [media_item.media_type], 20
- )
- for items in [
- searchresult.artists,
- searchresult.albums,
- searchresult.tracks,
- searchresult.playlists,
- searchresult.radios,
- ]:
- for item in items:
- if item.available:
- await self.get_item(
- item.item_id, item.provider, item.media_type, lazy=False
- )
-
- ################ ADD MediaItem(s) to database helpers ################
-
- async def add_artist(self, artist: Artist) -> Artist:
- """Add artist to local db and return the database item."""
- if not artist.musicbrainz_id:
- artist.musicbrainz_id = await self._get_artist_musicbrainz_id(artist)
- # grab additional metadata
- artist.metadata = await self.mass.metadata.get_artist_metadata(
- artist.musicbrainz_id, artist.metadata
- )
- db_item = await self.mass.database.add_artist(artist)
- # also fetch same artist on all providers
- await self.match_artist(db_item)
- db_item = await self.mass.database.get_artist(db_item.item_id)
- self.mass.eventbus.signal(EVENT_ARTIST_ADDED, db_item)
- return db_item
-
- async def add_album(self, album: Album) -> Album:
- """Add album to local db and return the database item."""
- # make sure we have an artist
- assert album.artist
- db_item = await self.mass.database.add_album(album)
- # also fetch same album on all providers
- await self.match_album(db_item)
- db_item = await self.mass.database.get_album(db_item.item_id)
- self.mass.eventbus.signal(EVENT_ALBUM_ADDED, db_item)
- return db_item
-
- async def add_track(self, track: Track) -> Track:
- """Add track to local db and return the new database item."""
- # make sure we have artists
- assert track.artists
- # make sure we have an album
- assert track.album or track.albums
- db_item = await self.mass.database.add_track(track)
- # also fetch same track on all providers (will also get other quality versions)
- await self.match_track(db_item)
- db_item = await self.mass.database.get_track(db_item.item_id)
- self.mass.eventbus.signal(EVENT_TRACK_ADDED, db_item)
- return db_item
-
- async def add_playlist(self, playlist: Playlist) -> Playlist:
- """Add playlist to local db and return the new database item."""
- db_item = await self.mass.database.add_playlist(playlist)
- self.mass.eventbus.signal(EVENT_PLAYLIST_ADDED, db_item)
- return db_item
-
- async def add_radio(self, radio: Radio) -> Radio:
- """Add radio to local db and return the new database item."""
- db_item = await self.mass.database.add_radio(radio)
- self.mass.eventbus.signal(EVENT_RADIO_ADDED, db_item)
- return db_item
-
- async def _get_artist_musicbrainz_id(self, artist: Artist):
- """Fetch musicbrainz id by performing search using the artist name, albums and tracks."""
- # try with album first
- for lookup_album in await self._get_provider_artist_albums(
- artist.item_id, artist.provider
- ):
- if not lookup_album:
- continue
- if artist.name != lookup_album.artist.name:
- continue
- musicbrainz_id = await self.musicbrainz.get_mb_artist_id(
- artist.name,
- albumname=lookup_album.name,
- album_upc=lookup_album.upc,
- )
- if musicbrainz_id:
- return musicbrainz_id
- # fallback to track
- for lookup_track in await self._get_provider_artist_toptracks(
- artist.item_id, artist.provider
- ):
- if not lookup_track:
- continue
- musicbrainz_id = await self.musicbrainz.get_mb_artist_id(
- artist.name,
- trackname=lookup_track.name,
- track_isrc=lookup_track.isrc,
- )
- if musicbrainz_id:
- return musicbrainz_id
- # lookup failed, use the shitty workaround to use the name as id.
- LOGGER.warning("Unable to get musicbrainz ID for artist %s !", artist.name)
- return artist.name
-
- async def match_artist(self, db_artist: Artist):
- """
- Try to find matching artists on all providers for the provided (database) item_id.
-
- This is used to link objects of different providers together.
- """
- assert (
- db_artist.provider == "database"
- ), "Matching only supported for database items!"
- cur_providers = [item.provider for item in db_artist.provider_ids]
- for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
- if provider.id in cur_providers:
- continue
- if MediaType.ARTIST not in provider.supported_mediatypes:
- continue
- if not await self._match_prov_artist(db_artist, provider):
- LOGGER.debug(
- "Could not find match for Artist %s on provider %s",
- db_artist.name,
- provider.name,
- )
-
- async def _match_prov_artist(self, db_artist: Artist, provider: MusicProvider):
- """Try to find matching artists on given provider for the provided (database) artist."""
- LOGGER.debug(
- "Trying to match artist %s on provider %s", db_artist.name, provider.name
- )
- # try to get a match with some reference tracks of this artist
- for ref_track in await self.get_artist_toptracks(
- db_artist.item_id, db_artist.provider
- ):
- # make sure we have a full track
- if isinstance(ref_track.album, ItemMapping):
- ref_track = await self.get_track(ref_track.item_id, ref_track.provider)
- searchstr = "%s %s" % (db_artist.name, ref_track.name)
- search_results = await self.search_provider(
- searchstr, provider.id, [MediaType.TRACK], limit=25
- )
- for search_result_item in search_results.tracks:
- if compare_track(search_result_item, ref_track):
- # get matching artist from track
- for search_item_artist in search_result_item.artists:
- if compare_strings(db_artist.name, search_item_artist.name):
- # 100% album match
- # get full artist details so we have all metadata
- prov_artist = await self._get_provider_artist(
- search_item_artist.item_id, search_item_artist.provider
- )
- await self.mass.database.update_artist(
- db_artist.item_id, prov_artist
- )
- return True
- # try to get a match with some reference albums of this artist
- artist_albums = await self.get_artist_albums(
- db_artist.item_id, db_artist.provider
- )
- for ref_album in artist_albums:
- if ref_album.album_type == AlbumType.COMPILATION:
- continue
- searchstr = "%s %s" % (db_artist.name, ref_album.name)
- search_result = await self.search_provider(
- searchstr, provider.id, [MediaType.ALBUM], limit=25
- )
- for search_result_item in search_result.albums:
- # artist must match 100%
- if not compare_strings(db_artist.name, search_result_item.artist.name):
- continue
- if compare_album(search_result_item, ref_album):
- # 100% album match
- # get full artist details so we have all metadata
- prov_artist = await self._get_provider_artist(
- search_result_item.artist.item_id,
- search_result_item.artist.provider,
- )
- await self.mass.database.update_artist(
- db_artist.item_id, prov_artist
- )
- return True
- return False
-
- async def match_album(self, db_album: Album):
- """
- Try to find matching album on all providers for the provided (database) album_id.
-
- This is used to link objects of different providers/qualities together.
- """
- assert (
- db_album.provider == "database"
- ), "Matching only supported for database items!"
- if not isinstance(db_album, FullAlbum):
- # matching only works if we have a full album object
- db_album = await self.mass.database.get_album(db_album.item_id)
-
- async def find_prov_match(provider):
- LOGGER.debug(
- "Trying to match album %s on provider %s", db_album.name, provider.name
- )
- match_found = False
- searchstr = "%s %s" % (db_album.artist.name, db_album.name)
- if db_album.version:
- searchstr += " " + db_album.version
- search_result = await self.search_provider(
- searchstr, provider.id, [MediaType.ALBUM], limit=25
- )
- for search_result_item in search_result.albums:
- if not search_result_item.available:
- continue
- if not compare_album(search_result_item, db_album):
- continue
- # we must fetch the full album version, search results are simplified objects
- prov_album = await self._get_provider_album(
- search_result_item.item_id, search_result_item.provider
- )
- if compare_album(prov_album, db_album):
- # 100% match, we can simply update the db with additional provider ids
- await self.mass.database.update_album(db_album.item_id, prov_album)
- match_found = True
- # while we're here, also match the artist
- if db_album.artist.provider == "database":
- prov_artist = await self._get_provider_artist(
- prov_album.artist.item_id, prov_album.artist.provider
- )
- await self.mass.database.update_artist(
- db_album.artist.item_id, prov_artist
- )
-
- # no match found
- if not match_found:
- LOGGER.debug(
- "Could not find match for Album %s on provider %s",
- db_album.name,
- provider.name,
- )
-
- # try to find match on all providers
- providers = self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
- for provider in providers:
- if MediaType.ALBUM in provider.supported_mediatypes:
- await find_prov_match(provider)
-
- async def match_track(self, db_track: Track):
- """
- Try to find matching track on all providers for the provided (database) track_id.
-
- This is used to link objects of different providers/qualities together.
- """
- assert (
- db_track.provider == "database"
- ), "Matching only supported for database items!"
- if isinstance(db_track.album, ItemMapping):
- # matching only works if we have a full track object
- db_track = await self.mass.database.get_track(db_track.item_id)
- for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
- if MediaType.TRACK not in provider.supported_mediatypes:
- continue
- LOGGER.debug(
- "Trying to match track %s on provider %s", db_track.name, provider.name
- )
- match_found = False
- for db_track_artist in db_track.artists:
- if match_found:
- break
- searchstr = "%s %s" % (db_track_artist.name, db_track.name)
- if db_track.version:
- searchstr += " " + db_track.version
- search_result = await self.search_provider(
- searchstr, provider.id, [MediaType.TRACK], limit=25
- )
- for search_result_item in search_result.tracks:
- if not search_result_item.available:
- continue
- if compare_track(search_result_item, db_track):
- # 100% match, we can simply update the db with additional provider ids
- match_found = True
- await self.mass.database.update_track(
- db_track.item_id, search_result_item
- )
- # while we're here, also match the artist
- if db_track_artist.provider == "database":
- for artist in search_result_item.artists:
- if not compare_strings(
- db_track_artist.name, artist.name
- ):
- continue
- prov_artist = await self._get_provider_artist(
- artist.item_id, artist.provider
- )
- await self.mass.database.update_artist(
- db_track_artist.item_id, prov_artist
- )
-
- if not match_found:
- LOGGER.debug(
- "Could not find match for Track %s on provider %s",
- db_track.name,
- provider.name,
- )
-
- async def __get_provider_id(self, media_item: MediaItem) -> tuple:
- """Return provider and item id."""
- if media_item.provider == "database":
- media_item = await self.mass.database.get_item_by_prov_id(
- "database", media_item.item_id, media_item.media_type
- )
- for prov in media_item.provider_ids:
- if prov.available and self.mass.get_provider(prov.provider):
- provider = self.mass.get_provider(prov.provider)
- if provider and provider.available:
- return (prov.provider, prov.item_id)
- else:
- provider = self.mass.get_provider(media_item.provider)
- if provider and provider.available:
- return (media_item.provider, media_item.item_id)
- return None, None
+++ /dev/null
-"""PlayerManager: Orchestrates all players from player providers."""
-
-import asyncio
-import logging
-import pathlib
-from typing import Dict, List, Optional, Set, Tuple, Union
-
-from music_assistant.constants import (
- CONF_CROSSFADE_DURATION,
- CONF_POWER_CONTROL,
- CONF_VOLUME_CONTROL,
- EVENT_PLAYER_ADDED,
- EVENT_PLAYER_REMOVED,
-)
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import callback, create_task, try_parse_int
-from music_assistant.helpers.web import api_route
-from music_assistant.models.media_types import MediaItem, MediaType
-from music_assistant.models.player import (
- Player,
- PlayerControl,
- PlayerControlType,
- PlayerState,
-)
-from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption
-from music_assistant.models.provider import PlayerProvider, ProviderType
-
-POLL_INTERVAL = 30
-
-LOGGER = logging.getLogger("player_manager")
-RESOURCES_DIR = (
- pathlib.Path(__file__).parent.resolve().parent.resolve().joinpath("resources")
-)
-ALERT_ANNOUNCE_FILE = str(RESOURCES_DIR.joinpath("announce.flac"))
-ALERT_FINISH_FILE = str(RESOURCES_DIR.joinpath("silence.flac"))
-
-
-class PlayerManager:
- """Several helpers to handle playback through player providers."""
-
- def __init__(self, mass: MusicAssistant) -> None:
- """Initialize class."""
- self.mass = mass
- self._players = {}
- self._providers = {}
- self._player_queues = {}
- self._controls = {}
- self._alerts_in_progress = set()
-
- async def setup(self) -> None:
- """Async initialize of module."""
- asyncio.create_task(self.poll_task())
-
- async def close(self) -> None:
- """Handle stop/shutdown."""
- for player_queue in self._player_queues.values():
- await player_queue.close()
- for player in self:
- await player.on_remove()
-
- async def poll_task(self):
- """Check for updates on players that need to be polled."""
- count = 0
- while True:
- for player in self:
- if not player.calculated_state.available:
- continue
- if not player.should_poll:
- continue
- if player.state == PlayerState.PLAYING or count == POLL_INTERVAL:
- await player.on_poll()
- if count == POLL_INTERVAL:
- count = 0
- else:
- count += 1
- await asyncio.sleep(1)
-
- @property
- def players(self) -> Dict[str, Player]:
- """Return dict of all registered players."""
- return self._players
-
- @property
- def player_queues(self) -> Dict[str, PlayerQueue]:
- """Return dict of all player queues."""
- return self._player_queues
-
- @property
- def providers(self) -> Tuple[PlayerProvider]:
- """Return tuple with all loaded player providers."""
- return self.mass.get_providers(ProviderType.PLAYER_PROVIDER)
-
- def __iter__(self):
- """Iterate over players."""
- return iter(self._players.values())
-
- @callback
- @api_route("players")
- def get_players(self) -> Tuple[Player]:
- """Return all players in a tuple."""
- return tuple(self._players.values())
-
- @callback
- @api_route("queues")
- def get_player_queues(self) -> Tuple[PlayerQueue]:
- """Return all player queues in a tuple."""
- return tuple(self._player_queues.values())
-
- @callback
- @api_route("players/{player_id}")
- def get_player(self, player_id: str, raise_not_found: bool = False) -> Player:
- """Return Player by player_id."""
- player = self._players.get(player_id)
- if not player and raise_not_found:
- raise FileNotFoundError("Player not found %s" % player_id)
- return player
-
- @callback
- def get_player_by_name(
- self, name: str, provider_id: Optional[str] = None
- ) -> Optional[Player]:
- """Return Player by name or None if no match is found."""
- for player in self:
- if provider_id is not None and player.provider_id != provider_id:
- continue
- if name in (player.name, player.calculated_state.name):
- return player
- return None
-
- @callback
- def get_player_provider(self, player_id: str) -> PlayerProvider:
- """Return provider by player_id or None if player does not exist."""
- player = self.get_player(player_id)
- return self.mass.get_provider(player.provider_id) if player else None
-
- @callback
- @api_route("queues/{queue_id}")
- def get_player_queue(self, queue_id: str) -> PlayerQueue:
- """Return player Queue by queue id or None if queue does not exist."""
- queue = self._player_queues.get(queue_id)
- if not queue:
- LOGGER.warning("Player(queue) %s is not available!", queue_id)
- return None
- return queue
-
- @callback
- @api_route("players/{player_id}/queue")
- def get_active_player_queue(
- self, player_id: str, raise_not_found: bool = True
- ) -> PlayerQueue:
- """Return the active queue for given player id."""
- player = self.get_player(player_id, raise_not_found)
- if player:
- return self.get_player_queue(player.calculated_state.active_queue)
- return None
-
- @callback
- @api_route("queues/{queue_id}/items")
- def get_player_queue_items(self, queue_id: str) -> Set[QueueItem]:
- """Return player's queueitems by player_id."""
- player_queue = self.get_player_queue(queue_id)
- return player_queue.items if player_queue else {}
-
- @callback
- @api_route("players/controls/{control_id}")
- def get_player_control(self, control_id: str) -> PlayerControl:
- """Return PlayerControl by id."""
- if control_id not in self._controls:
- LOGGER.warning("PlayerControl %s is not available", control_id)
- return None
- return self._controls[control_id]
-
- @callback
- @api_route("players/controls")
- def get_player_controls(
- self, filter_type: Optional[PlayerControlType] = None
- ) -> Set[PlayerControl]:
- """Return all PlayerControls, optionally filtered by type."""
- return {
- item
- for item in self._controls.values()
- if (filter_type is None or item.type == filter_type)
- }
-
- # ADD/REMOVE/UPDATE HELPERS
-
- async def add_player(self, player: Player) -> None:
- """Register a new player or update an existing one."""
- player_id = player.player_id
-
- # guard for invalid data or exit in progress
- if not player or self.mass.exit:
- return
-
- # redirect to update if player is already added
- if player_id in self._players:
- player = self._players[player_id]
- if player.added_to_mass:
- await self.trigger_player_update(player_id)
- return
- else:
- self._players[player.player_id] = player
- # make sure that the mass instance is set on the player
- player.mass = self.mass
-
- # make sure that the player state is created/updated
- player.calculated_state.update(player.create_calculated_state())
-
- # Fully initialize only if player is enabled
- if not player.enabled:
- LOGGER.debug(
- "Ignoring player: %s/%s because it's disabled",
- player.provider_id,
- player.name,
- )
- return
-
- # new player
- player.added_to_mass = True
- await player.on_add()
- # create playerqueue instance
- self._player_queues[player.player_id] = PlayerQueue(self.mass, player.player_id)
- LOGGER.info(
- "Player added: %s/%s",
- player.provider_id,
- player.name,
- )
- self.mass.eventbus.signal(EVENT_PLAYER_ADDED, player.calculated_state)
-
- async def remove_player(self, player_id: str):
- """Remove a player from the registry."""
- self._player_queues.pop(player_id, None)
- player = self._players.pop(player_id, None)
- if player:
- await player.on_remove()
- player_name = player.name if player else player_id
- LOGGER.info("Player removed: %s", player_name)
- self.mass.eventbus.signal(EVENT_PLAYER_REMOVED, {"player_id": player_id})
-
- async def trigger_player_update(self, player_id: str):
- """Trigger update of an existing player.."""
- player = self.get_player(player_id, False)
- if player:
- await player.on_poll()
-
- @api_route("players/controls/{control_id}", method="POST")
- async def register_player_control(self, control_id: str, control: PlayerControl):
- """Register a playercontrol with the player manager."""
- control.mass = self.mass
- self._controls[control_id] = control
- LOGGER.info(
- "New PlayerControl (%s) registered: %s\\%s",
- control.type,
- control.provider,
- control.name,
- )
- # update all players using this playercontrol
- for player in self:
- conf = self.mass.config.player_settings[player.player_id]
- if control_id in [
- conf.get(CONF_POWER_CONTROL),
- conf.get(CONF_VOLUME_CONTROL),
- ]:
- create_task(self.trigger_player_update(player.player_id))
-
- @api_route("players/controls/{control_id}", method="PUT")
- async def update_player_control(self, control_id: str, control: PlayerControl):
- """Update a playercontrol's state on the player manager."""
- if control_id not in self._controls:
- return await self.register_player_control(control_id, control)
- new_state = control.state
- if self._controls[control_id].state == new_state:
- return
- self._controls[control_id].state = new_state
- LOGGER.debug(
- "PlayerControl %s\\%s updated - new state: %s",
- control.provider,
- control.name,
- new_state,
- )
- # update all players using this playercontrol
- for player in self:
- conf = self.mass.config.player_settings[player.player_id]
- if control_id in [
- conf.get(CONF_POWER_CONTROL),
- conf.get(CONF_VOLUME_CONTROL),
- ]:
- create_task(self.trigger_player_update(player.player_id))
-
- # SERVICE CALLS / PLAYER COMMANDS
-
- @api_route("players/{player_id}/play_media", method="PUT")
- async def play_media(
- self,
- player_id: str,
- items: Union[MediaItem, List[MediaItem]],
- queue_opt: QueueOption = QueueOption.PLAY,
- ):
- """
- Play media item(s) on the given player.
-
- :param player_id: player_id of the player to handle the command.
- :param items: media item(s) that should be played (single item or list of items)
- :param queue_opt:
- QueueOption.PLAY -> Insert new items in queue and start playing at inserted position
- QueueOption.REPLACE -> Replace queue contents with these items
- QueueOption.NEXT -> Play item(s) after current playing item
- QueueOption.ADD -> Append new items at end of the queue
- """
- player = self.get_player(player_id, True)
- player_queue = self.get_active_player_queue(player_id, True)
- # power on player if needed
- if not player.calculated_state.powered:
- await self.cmd_power_on(player_id)
- # a single item or list of items may be provided
- if not isinstance(items, list):
- items = [items]
- queue_items = []
- for media_item in items:
- # collect tracks to play
- if media_item.media_type == MediaType.ARTIST:
- tracks = await self.mass.music.get_artist_toptracks(
- media_item.item_id, provider_id=media_item.provider
- )
- elif media_item.media_type == MediaType.ALBUM:
- tracks = await self.mass.music.get_album_tracks(
- media_item.item_id, provider_id=media_item.provider
- )
- elif media_item.media_type == MediaType.PLAYLIST:
- tracks = await self.mass.music.get_playlist_tracks(
- media_item.item_id, provider_id=media_item.provider
- )
- elif media_item.media_type == MediaType.RADIO:
- # single radio
- tracks = [
- await self.mass.music.get_radio(
- media_item.item_id, provider_id=media_item.provider
- )
- ]
- else:
- # single track
- tracks = [
- await self.mass.music.get_track(
- media_item.item_id, provider_id=media_item.provider
- )
- ]
- for track in tracks:
- if not track.available:
- continue
- queue_item = player_queue.create_queue_item(track)
- queue_items.append(queue_item)
-
- # load items into the queue
- if queue_opt == QueueOption.REPLACE:
- return await player_queue.load(queue_items)
- if queue_opt in [QueueOption.PLAY, QueueOption.NEXT] and len(queue_items) > 100:
- return await player_queue.load(queue_items)
- if queue_opt == QueueOption.NEXT:
- return await player_queue.insert(queue_items, 1)
- if queue_opt == QueueOption.PLAY:
- return await player_queue.insert(queue_items, 0)
- if queue_opt == QueueOption.ADD:
- return await player_queue.append(queue_items)
-
- @api_route("players/{player_id}/play_uri", method="PUT")
- async def play_uri(
- self, player_id: str, uri: str, queue_opt: QueueOption = QueueOption.PLAY
- ):
- """
- Play the specified uri/url on the given player.
-
- Will create a fake track on the queue.
-
- :param player_id: player_id of the player to handle the command.
- :param uri: Url/Uri that can be played by a player.
- """
- # try media uri first
- if not uri.startswith("http"):
- item = await self.mass.music.get_item_by_uri(uri)
- if item:
- return await self.play_media(player_id, item, queue_opt)
- raise FileNotFoundError("Invalid uri: %s" % uri)
- player = self.get_player(player_id, True)
- player_queue = self.get_active_player_queue(player_id, True)
- # power on player if needed
- if not player.calculated_state.powered:
- await self.cmd_power_on(player_id)
- # load item into the queue
- queue_item = player_queue.create_queue_item(
- item_id=uri, provider="url", name=uri, uri=uri
- )
- if queue_opt == QueueOption.REPLACE:
- return await player_queue.load([queue_item])
- if queue_opt == QueueOption.NEXT:
- return await player_queue.insert([queue_item], 1)
- if queue_opt == QueueOption.PLAY:
- return await player_queue.insert([queue_item], 0)
- if queue_opt == QueueOption.ADD:
- return await player_queue.append([queue_item])
-
- @api_route("players/{player_id}/play_alert", method="PUT")
- async def play_alert(
- self,
- player_id: str,
- url: str,
- volume: Optional[int] = None,
- force: bool = True,
- announce: bool = False,
- ):
- """
- Play alert (e.g. tts message) on selected player.
-
- Will pause the current playing queue and resume after the alert is played.
-
- :param player_id: player_id of the player to handle the command.
- :param url: Url to the sound effect/tts message that should be played.
- :param volume: Force volume of player to this level during the alert.
- :param force: Play alert even if player is currently powered off.
- :param announce: Announce the alert by prepending an alert sound.
- """
- player = self.get_player(player_id, True)
- player_queue = self.get_active_player_queue(player_id)
- if player_queue.queue_id in self._alerts_in_progress:
- LOGGER.debug(
- "Ignoring Play Alert for queue %s - Another alert is already in progress.",
- player_queue.queue_id,
- )
- return
- self._alerts_in_progress.add(player_queue.queue_id)
- prev_state = player_queue.state
- prev_power = player.calculated_state.powered
- prev_volume = player.calculated_state.volume_level
- prev_repeat = player_queue.repeat_enabled
- if not player.calculated_state.powered:
- if not force:
- LOGGER.debug(
- "Ignore alert playback: Player %s is powered off.",
- player.calculated_state.name,
- )
- return
- # power on player if needed
- if not player.calculated_state.powered:
- await self.cmd_power_on(player_id)
- # snapshot the (active) queue
- prev_queue_items = player_queue.items
- prev_queue_index = player_queue.cur_index
- prev_queue_crossfade = self.mass.config.get_player_config(
- player_queue.queue_id
- )[CONF_CROSSFADE_DURATION]
-
- # pause playback
- if prev_state == PlayerState.PLAYING:
- await self.cmd_pause(player_queue.queue_id)
- # disable crossfade and repeat if needed
- if prev_queue_crossfade:
- self.mass.config.player_settings[player_queue.queue_id][
- CONF_CROSSFADE_DURATION
- ] = 0
- if prev_repeat:
- await player_queue.set_repeat_enabled(False)
- # set alert volume
- if volume:
- await self.cmd_volume_set(player_id, volume)
- # load alert items in player queue
- queue_items = []
- if announce:
- queue_items.append(
- player_queue.create_queue_item(
- item_id="alert_announce",
- provider="url",
- name="alert_announce",
- uri=ALERT_ANNOUNCE_FILE,
- )
- )
- queue_items.append(
- player_queue.create_queue_item(
- item_id="alert", provider="url", name="alert", uri=url
- )
- )
- queue_items.append(
- # add a special (silent) file so we can detect finishing of the alert
- player_queue.create_queue_item(
- item_id="alert_finish",
- provider="url",
- name="alert_finish",
- uri=ALERT_FINISH_FILE,
- )
- )
- # load queue items
- await player_queue.load(queue_items)
-
- # add listener when playback of alert finishes
- async def restore_queue():
- count = 0
- while count < 30:
- if (
- player_queue.cur_item == queue_items[-1]
- and player_queue.cur_item_time > 2
- ):
- break
- count += 1
- await asyncio.sleep(1)
- # restore queue
- if volume:
- await self.cmd_volume_set(player_id, prev_volume)
- if prev_queue_crossfade:
- self.mass.config.player_settings[player_queue.queue_id][
- CONF_CROSSFADE_DURATION
- ] = prev_queue_crossfade
- await player_queue.set_repeat_enabled(prev_repeat)
- # pylint: disable=protected-access
- player_queue._items = prev_queue_items
- player_queue._cur_index = prev_queue_index
- if prev_state == PlayerState.PLAYING:
- await player_queue.resume()
- if not prev_power:
- await self.cmd_power_off(player_id)
- self._alerts_in_progress.remove(player_queue.queue_id)
- player_queue.signal_update()
-
- create_task(restore_queue)
-
- @api_route("players/{player_id}/cmd/stop", method="PUT")
- async def cmd_stop(self, player_id: str) -> None:
- """
- Send STOP command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player_queue = self.get_active_player_queue(player_id)
- await player_queue.stop()
-
- @api_route("players/{player_id}/cmd/play", method="PUT")
- async def cmd_play(self, player_id: str) -> None:
- """
- Send PLAY command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self.get_player(player_id, True)
- player_queue = self.get_active_player_queue(player_id)
- # power on player if needed
- if not player.calculated_state.powered:
- await self.cmd_power_on(player_id)
- # unpause if paused else resume queue
- if player_queue.state == PlayerState.PAUSED:
- await player_queue.play()
- else:
- await player_queue.resume()
-
- @api_route("players/{player_id}/cmd/pause", method="PUT")
- async def cmd_pause(self, player_id: str) -> None:
- """
- Send PAUSE command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player_queue = self.get_active_player_queue(player_id, True)
- await player_queue.pause()
-
- @api_route("players/{player_id}/cmd/play_pause", method="PUT")
- async def cmd_play_pause(self, player_id: str) -> None:
- """
- Toggle play/pause on given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player_queue = self.get_active_player_queue(player_id, True)
- if player_queue.state == PlayerState.PLAYING:
- await self.cmd_pause(player_queue.queue_id)
- else:
- await self.cmd_play(player_queue.queue_id)
-
- @api_route("players/{player_id}/cmd/next", method="PUT")
- async def cmd_next(self, player_id: str) -> None:
- """
- Send NEXT TRACK command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player_queue = self.get_active_player_queue(player_id, True)
- if player_queue.state == PlayerState.PLAYING:
- await player_queue.next()
- else:
- await self.cmd_play(player_queue.queue_id)
-
- @api_route("players/{player_id}/cmd/previous", method="PUT")
- async def cmd_previous(self, player_id: str):
- """
- Send PREVIOUS TRACK command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player_queue = self.get_active_player_queue(player_id, True)
- if player_queue.state == PlayerState.PLAYING:
- await player_queue.previous()
- else:
- await self.cmd_play(player_queue.queue_id)
-
- @api_route("players/{player_id}/cmd/power_on", method="PUT")
- async def cmd_power_on(self, player_id: str) -> None:
- """
- Send POWER ON command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self.get_player(player_id)
- if not player:
- return
- player_config = self.mass.config.player_settings[player.player_id]
- # turn on player
- await player.cmd_power_on()
- # player control support
- if player_config.get(CONF_POWER_CONTROL):
- control = self.get_player_control(player_config[CONF_POWER_CONTROL])
- if control:
- await control.set_state(True)
-
- @api_route("players/{player_id}/cmd/power_off", method="PUT")
- async def cmd_power_off(self, player_id: str) -> None:
- """
- Send POWER OFF command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self.get_player(player_id, True)
- # send stop if player is active queue
- if player.active_queue == player_id and player.state not in [
- PlayerState.OFF,
- PlayerState.IDLE,
- ]:
- await self.cmd_stop(player_id)
- player_config = self.mass.config.player_settings[player.player_id]
- # turn off player
- await player.cmd_power_off()
- # player control support
- if player_config.get(CONF_POWER_CONTROL):
- control = self.get_player_control(player_config[CONF_POWER_CONTROL])
- if control:
- await control.set_state(False)
- # handle group power
- if player.is_group_player:
- # player is group, turn off all childs
- for child_player_id in player.group_childs:
- child_player = self.get_player(child_player_id)
- if child_player and child_player.calculated_state.powered:
- create_task(self.cmd_power_off(child_player_id))
- else:
- # if this was the last powered player in the group, turn off group
- for parent_player_id in player.group_parents:
- parent_player = self.get_player(parent_player_id)
- if not parent_player or not parent_player.calculated_state.powered:
- continue
- has_powered_players = False
- for child_player_id in parent_player.group_childs:
- if child_player_id == player_id:
- continue
- child_player = self.get_player(child_player_id)
- if child_player and child_player.calculated_state.powered:
- has_powered_players = True
- if not has_powered_players:
- create_task(self.cmd_power_off(parent_player_id))
-
- @api_route("players/{player_id}/cmd/power_toggle", method="PUT")
- async def cmd_power_toggle(self, player_id: str):
- """
- Send POWER TOGGLE command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self.get_player(player_id, True)
- if player.calculated_state.powered:
- return await self.cmd_power_off(player_id)
- return await self.cmd_power_on(player_id)
-
- @api_route("players/{player_id}/cmd/volume_set", method="PUT")
- async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
- """
- Send volume level command to given player.
-
- :param player_id: player_id of the player to handle the command.
- :param volume_level: volume level to set (0..100).
- """
- player = self.get_player(player_id, True)
- player_config = self.mass.config.player_settings[player.player_id]
- volume_level = try_parse_int(volume_level)
- if volume_level < 0:
- volume_level = 0
- elif volume_level > 100:
- volume_level = 100
- # player control support
- if player_config.get(CONF_VOLUME_CONTROL):
- control = self.get_player_control(player_config[CONF_VOLUME_CONTROL])
- if control:
- await control.set_state(volume_level)
- # just force full volume on actual player if volume is outsourced to volumecontrol
- await player.cmd_volume_set(100)
- # handle group volume
- elif player.is_group_player:
- cur_volume = player.volume_level
- new_volume = volume_level
- volume_dif = new_volume - cur_volume
- if cur_volume == 0:
- volume_dif_percent = 1 + (new_volume / 100)
- else:
- volume_dif_percent = volume_dif / cur_volume
- for child_player_id in player.group_childs:
- if child_player_id == player_id:
- continue
- child_player = self.get_player(child_player_id)
- if (
- child_player
- and child_player.available
- and child_player.calculated_state.powered
- ):
- cur_child_volume = child_player.volume_level
- new_child_volume = cur_child_volume + (
- cur_child_volume * volume_dif_percent
- )
- await self.cmd_volume_set(child_player_id, new_child_volume)
- # regular volume command
- else:
- await player.cmd_volume_set(volume_level)
-
- @api_route("players/{player_id}/cmd/volume_up", method="PUT")
- async def cmd_volume_up(self, player_id: str):
- """
- Send volume UP command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self.get_player(player_id, True)
- if player.volume_level <= 10 or player.volume_level >= 90:
- step_size = 2
- else:
- step_size = 5
- new_level = player.volume_level + step_size
- if new_level > 100:
- new_level = 100
- return await self.cmd_volume_set(player_id, new_level)
-
- @api_route("players/{player_id}/cmd/volume_down", method="PUT")
- async def cmd_volume_down(self, player_id: str):
- """
- Send volume DOWN command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self.get_player(player_id, True)
- if player.volume_level <= 10 or player.volume_level >= 90:
- step_size = 2
- else:
- step_size = 5
- new_level = player.volume_level - step_size
- if new_level < 0:
- new_level = 0
- return await self.cmd_volume_set(player_id, new_level)
-
- @api_route("players/{player_id}/cmd/volume_mute", method="PUT")
- async def cmd_volume_mute(self, player_id: str, is_muted: bool = False):
- """
- Send MUTE command to given player.
-
- :param player_id: player_id of the player to handle the command.
- :param is_muted: bool with the new mute state.
- """
- player = self.get_player(player_id, True)
- # TODO: handle mute on volumecontrol?
- return await player.cmd_volume_mute(is_muted)
-
- @api_route("queues/{queue_id}", method="PUT")
- async def player_queue_update(
- self,
- queue_id: str,
- enable_shuffle: Optional[bool] = None,
- enable_repeat: Optional[bool] = None,
- ) -> None:
- """Set options to given playerqueue."""
- player_queue = self.get_player_queue(queue_id)
- if not player_queue:
- raise FileNotFoundError("Unknown Queue: %s" % queue_id)
- if enable_shuffle is not None:
- await player_queue.set_shuffle_enabled(enable_shuffle)
- if enable_repeat is not None:
- await player_queue.set_repeat_enabled(enable_repeat)
-
- @api_route("queues/{queue_id}/cmd/next", method="PUT")
- async def player_queue_cmd_next(self, queue_id: str):
- """
- Send next track command to given playerqueue.
-
- :param queue_id: player_id of the playerqueue to handle the command.
- """
- player_queue = self.get_player_queue(queue_id)
- if not player_queue:
- return
- return await player_queue.next()
-
- @api_route("queues/{queue_id}/cmd/previous", method="PUT")
- async def player_queue_cmd_previous(self, queue_id: str):
- """
- Send previous track command to given playerqueue.
-
- :param queue_id: player_id of the playerqueue to handle the command.
- """
- player_queue = self.get_player_queue(queue_id)
- if not player_queue:
- return
- return await player_queue.previous()
-
- @api_route("queues/{queue_id}/cmd/move", method="PUT")
- async def player_queue_cmd_move_item(
- self, queue_id: str, queue_item_id: str, pos_shift: int = 1
- ):
- """
- Move queue item x up/down the queue.
-
- param pos_shift: move item x positions down if positive value
- move item x positions up if negative value
- move item to top of queue as next item if 0
- """
- player_queue = self.get_player_queue(queue_id)
- if not player_queue:
- return
- return await player_queue.move_item(queue_item_id, pos_shift)
-
- @api_route("queues/{queue_id}/cmd/play_index", method="PUT")
- async def play_index(self, queue_id: str, index: Union[int, str]) -> None:
- """Play item at index (or item_id) X in queue."""
- player_queue = self.get_player_queue(queue_id)
- if not player_queue:
- return
- return await player_queue.play_index(index)
-
- @api_route("queues/{queue_id}/items", method="DELETE")
- async def player_queue_cmd_clear(self, queue_id: str):
- """
- Clear all items in player's queue.
-
- :param queue_id: player_id of the playerqueue to handle the command.
- """
- player_queue = self.get_player_queue(queue_id)
- if not player_queue:
- return
- return await player_queue.clear()
+++ /dev/null
-"""Logic to process tasks on the event loop."""
-
-import asyncio
-import logging
-from asyncio.futures import Future
-from enum import IntEnum
-from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
-from uuid import uuid4
-
-from music_assistant.constants import EVENT_TASK_UPDATED
-from music_assistant.helpers.datetime import now
-from music_assistant.helpers.muli_state_queue import MultiStateQueue
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task
-from music_assistant.helpers.web import api_route
-
-LOGGER = logging.getLogger("task_manager")
-
-MAX_SIMULTANEOUS_TASKS = 2
-
-
-class TaskStatus(IntEnum):
- """Enum for Task status."""
-
- PENDING = 0
- PROGRESS = 1
- FINISHED = 2
- ERROR = 3
- CANCELLED = 4
-
-
-class TaskInfo:
- """Model for a background task."""
-
- def __init__(
- self,
- name: str,
- target: Union[Callable, Awaitable],
- args: Any,
- kwargs: Any,
- periodic: Optional[int] = None,
- ) -> None:
- """Initialize instance."""
- self.name = name
- self.target = target
- self.args = args
- self.kwargs = kwargs
- self.periodic = periodic
- self.status = TaskStatus.PENDING
- self.error_details = ""
- self.updated_at = now()
- self.execution_time = 0 # time in seconds it took to process
- self.id = str(uuid4())
-
- def __str__(self):
- """Return string representation, used for logging."""
- return f"{self.name} ({self.id})"
-
- def to_dict(self) -> Dict[str, Any]:
- """Return serializable dict."""
- return {
- "id": self.id,
- "name": self.name,
- "status": self.status,
- "error_details": self.error_details,
- "updated_at": self.updated_at.isoformat(),
- "execution_time": self.execution_time,
- }
-
- @property
- def dupe_hash(self):
- """Return simple hash to identify duplicate tasks."""
- return f"{self.name}.{self.target.__qualname__}.{self.args}"
-
-
-class TaskManager:
- """Task manager that executes tasks from a queue in the background."""
-
- def __init__(self, mass: MusicAssistant):
- """Initialize TaskManager instance."""
- self.mass = mass
- self._queue = None
-
- async def setup(self):
- """Async initialize of module."""
- # queue can only be initialized when the loop is running
- MultiStateQueue.QUEUE_ITEM_TYPE = TaskInfo
- self._queue = MultiStateQueue()
- create_task(self.__process_tasks())
-
- def add(
- self,
- name: str,
- target: Union[Callable, Awaitable],
- *args: Any,
- periodic: Optional[int] = None,
- prevent_duplicate: bool = True,
- **kwargs: Any,
- ) -> TaskInfo:
- """Add a job/task to the task manager.
-
- name: A name to identify this task in the task queue.
- target: target to call (coroutine function or callable).
- periodic: [optional] run this task every X seconds.
- prevent_duplicate: [default true] prevent same task running at same time
- args: [optional] parameters for method to call.
- kwargs: [optional] parameters for method to call.
- """
-
- if self.mass.exit:
- return
- if self._queue is None:
- raise RuntimeError("Not yet initialized")
-
- if periodic and asyncio.iscoroutine(target):
- raise RuntimeError(
- "Provide a coroutine function and not a coroutine itself"
- )
-
- task_info = TaskInfo(
- name, periodic=periodic, target=target, args=args, kwargs=kwargs
- )
- if prevent_duplicate:
- for task in self._queue.progress_items + self._queue.pending_items:
- if task.dupe_hash == task_info.dupe_hash:
- LOGGER.debug(
- "Ignoring task %s as it is already running....", task_info.name
- )
- return task
- self._add_task(task_info)
- return task_info
-
- @api_route("tasks")
- def get_all_tasks(self) -> List[TaskInfo]:
- """Return all tasks in the TaskManager."""
- return self._queue.all_items
-
- def _add_task(self, task_info: TaskInfo) -> None:
- """Handle adding a task to the task queue."""
- LOGGER.debug("Adding task [%s] to Task Queue...", task_info.name)
- self._queue.put_nowait(task_info)
- self.mass.eventbus.signal(EVENT_TASK_UPDATED, task_info)
-
- def __task_done_callback(self, future: Future):
- task_info: TaskInfo = future.task_info
- self._queue.mark_finished(task_info)
- prev_timestamp = task_info.updated_at.timestamp()
- task_info.updated_at = now()
- task_info.execution_time = round(
- task_info.updated_at.timestamp() - prev_timestamp, 2
- )
- if future.cancelled():
- future.task_info.status = TaskStatus.CANCELLED
- elif future.exception():
- exc = future.exception()
- task_info.status = TaskStatus.ERROR
- task_info.error_details = repr(exc)
- LOGGER.debug(
- "Error while running task [%s]",
- task_info.name,
- exc_info=exc,
- )
- else:
- task_info.status = TaskStatus.FINISHED
- LOGGER.debug(
- "Task finished: [%s] in %s seconds",
- task_info.name,
- task_info.execution_time,
- )
- self.mass.eventbus.signal(EVENT_TASK_UPDATED, task_info)
- # reschedule if the task is periodic
- if task_info.periodic:
- self.mass.loop.call_later(task_info.periodic, self._add_task, task_info)
-
- async def __process_tasks(self):
- """Process handling of tasks in the queue."""
- while not self.mass.exit:
- while len(self._queue.progress_items) >= MAX_SIMULTANEOUS_TASKS:
- await asyncio.sleep(1)
- next_task = await self._queue.get()
- next_task.status = TaskStatus.PROGRESS
- next_task.updated_at = now()
- task = create_task(next_task.target, *next_task.args, **next_task.kwargs)
- setattr(task, "task_info", next_task)
- task.add_done_callback(self.__task_done_callback)
- self.mass.eventbus.signal(EVENT_TASK_UPDATED, next_task)
"""Main Music Assistant class."""
+from __future__ import annotations
import asyncio
-import importlib
import logging
-import os
-from typing import Dict, Optional, Tuple
+from time import time
+from typing import Any, Callable, Coroutine, Optional, Tuple
import aiohttp
-import music_assistant.helpers.util as util
-from music_assistant.constants import (
- CONF_ENABLED,
- EVENT_PROVIDER_REGISTERED,
- EVENT_PROVIDER_UNREGISTERED,
- EVENT_SHUTDOWN,
-)
-from music_assistant.helpers.cache import Cache
-from music_assistant.helpers.migration import check_migrations
-from music_assistant.helpers.util import callback, create_task, get_ip_pton
-from music_assistant.managers.config import ConfigManager
-from music_assistant.managers.database import DatabaseManager
-from music_assistant.managers.events import EventBus
-from music_assistant.managers.library import LibraryManager
-from music_assistant.managers.metadata import MetaDataManager
-from music_assistant.managers.music import MusicManager
-from music_assistant.managers.players import PlayerManager
-from music_assistant.managers.tasks import TaskManager
-from music_assistant.models.provider import Provider, ProviderType
-from music_assistant.web import WebServer
-from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf
-
-LOGGER = logging.getLogger("mass")
+from databases import DatabaseURL
+from music_assistant.constants import EventType
+from music_assistant.controllers.metadata import MetaDataController
+from music_assistant.controllers.music import MusicController
+from music_assistant.controllers.players import PlayerController
+from music_assistant.helpers import util
+from music_assistant.helpers.cache import Cache
+from music_assistant.helpers.database import Database
+from music_assistant.helpers.util import create_task
-def global_exception_handler(loop: asyncio.AbstractEventLoop, context: Dict) -> None:
- """Global exception handler."""
- LOGGER.debug("Caught exception: %s", context.get("exception", context["message"]))
- if "Broken pipe" in str(context.get("exception")):
- # fix for the spamming subprocess
- return
- loop.default_exception_handler(context)
+EventDetails = Any | None
+EventCallBackType = Callable[[EventType, EventDetails], None]
+EventSubscriptionType = Tuple[EventCallBackType, Optional[Tuple[EventType]]]
class MusicAssistant:
"""Main MusicAssistant object."""
- def __init__(self, datapath: str, debug: bool = False, port: int = 8095) -> None:
+ def __init__(
+ self,
+ db_url: DatabaseURL,
+ stream_port: int = 8095,
+ session: aiohttp.ClientSession | None = None,
+ ) -> None:
"""
Create an instance of MusicAssistant.
- :param datapath: file location to store the data
+ db_url: Database connection string/url.
+ stream_port: TCP port used for streaming audio.
+ session: Optionally provide an aiohttp clientsession
"""
- self._exit = False
- self._loop = None
- self.debug = debug
- self._http_session = None
+ self.loop: asyncio.AbstractEventLoop = None
+ self.http_session: aiohttp.ClientSession = session
+ self.http_session_provided = session is not None
+ self.logger = logging.getLogger(__name__)
- self._providers = {}
+ self._listeners = []
+ self._jobs = asyncio.Queue()
- # init core managers/controllers
- self._eventbus = EventBus(self)
- self._config = ConfigManager(self, datapath)
- self._tasks = TaskManager(self)
- self._database = DatabaseManager(self)
- self._cache = Cache(self)
- self._metadata = MetaDataManager(self)
- self._web = WebServer(self, port)
- self._music = MusicManager(self)
- self._library = LibraryManager(self)
- self._players = PlayerManager(self)
- # shared zeroconf instance
- self.zeroconf = Zeroconf(interfaces=InterfaceChoice.All)
+ # init core controllers
+ self.database = Database(self, db_url)
+ self.cache = Cache(self)
+ self.metadata = MetaDataController(self)
+ self.music = MusicController(self)
+ self.players = PlayerController(self, stream_port)
+ self._jobs_task: asyncio.Task = None
- async def start(self) -> None:
- """Start running the music assistant server."""
+ async def setup(self) -> None:
+ """Async setup of music assistant."""
# initialize loop
- self._loop = asyncio.get_event_loop()
- util.DEFAULT_LOOP = self._loop
- self._loop.set_exception_handler(global_exception_handler)
- self._loop.set_debug(self.debug)
+ self.loop = asyncio.get_event_loop()
+ util.DEFAULT_LOOP = self.loop
# create shared aiohttp ClientSession
- self._http_session = aiohttp.ClientSession(
- loop=self.loop,
- connector=aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=False),
- )
- # run migrations if needed
- await check_migrations(self)
- await self._tasks.setup()
- await self._config.setup()
- await self._cache.setup()
- await self._music.setup()
- await self._players.setup()
- await self._preload_providers()
- await self.setup_discovery()
- await self._web.setup()
- await self._library.setup()
- self.tasks.add("Save config", self.config.save)
+ if not self.http_session:
+ self.http_session = aiohttp.ClientSession(
+ loop=self.loop,
+ connector=aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=False),
+ )
+ # setup core controllers
+ await self.database.setup()
+ await self.cache.setup()
+ await self.music.setup()
+ await self.metadata.setup()
+ await self.players.setup()
+ self._jobs_task = create_task(self.__process_jobs())
async def stop(self) -> None:
"""Stop running the music assistant server."""
- self._exit = True
- LOGGER.info("Application shutdown")
- self._eventbus.signal(EVENT_SHUTDOWN)
- await self.config.close()
- await self._web.stop()
- for prov in self._providers.values():
- await prov.on_stop()
- await self._players.close()
- await self._http_session.connector.close()
- self._http_session.detach()
-
- @property
- def loop(self) -> asyncio.AbstractEventLoop:
- """Return the running event loop."""
- return self._loop
-
- @property
- def exit(self) -> bool:
- """Return bool if the main process is exiting."""
- return self._exit
-
- @property
- def players(self) -> PlayerManager:
- """Return the Players controller/manager."""
- return self._players
-
- @property
- def music(self) -> MusicManager:
- """Return the Music controller/manager."""
- return self._music
-
- @property
- def library(self) -> LibraryManager:
- """Return the Library controller/manager."""
- return self._library
-
- @property
- def config(self) -> ConfigManager:
- """Return the Configuration controller/manager."""
- return self._config
-
- @property
- def cache(self) -> Cache:
- """Return the Cache instance."""
- return self._cache
-
- @property
- def database(self) -> DatabaseManager:
- """Return the Database controller/manager."""
- return self._database
-
- @property
- def metadata(self) -> MetaDataManager:
- """Return the Metadata controller/manager."""
- return self._metadata
-
- @property
- def tasks(self) -> TaskManager:
- """Return the Tasks controller/manager."""
- return self._tasks
-
- @property
- def eventbus(self) -> EventBus:
- """Return the EventBus."""
- return self._eventbus
-
- @property
- def web(self) -> WebServer:
- """Return the webserver instance."""
- return self._web
-
- @property
- def http_session(self) -> aiohttp.ClientSession:
- """Return the default http session."""
- return self._http_session
-
- async def register_provider(self, provider: Provider) -> None:
- """Register a new Provider/Plugin."""
- assert provider.id and provider.name
- if provider.id in self._providers:
- LOGGER.debug("Provider %s is already registered.", provider.id)
- return
- provider.mass = self # make sure we have the mass object
- provider.available = False
- self._providers[provider.id] = provider
- if self.config.get_provider_config(provider.id, provider.type)[CONF_ENABLED]:
- if await provider.on_start() is not False:
- provider.available = True
- LOGGER.debug("Provider registered: %s", provider.name)
- self.eventbus.signal(EVENT_PROVIDER_REGISTERED, provider.id)
- else:
- LOGGER.debug(
- "Provider registered but loading failed: %s", provider.name
- )
- else:
- LOGGER.debug("Not loading provider %s as it is disabled", provider.name)
-
- async def unregister_provider(self, provider_id: str) -> None:
- """Unregister an existing Provider/Plugin."""
- if provider_id in self._providers:
- # unload it if it's loaded
- await self._providers[provider_id].on_stop()
- LOGGER.debug("Provider unregistered: %s", provider_id)
- self.eventbus.signal(EVENT_PROVIDER_UNREGISTERED, provider_id)
- return self._providers.pop(provider_id, None)
-
- async def reload_provider(self, provider_id: str) -> None:
- """Reload an existing Provider/Plugin."""
- provider = await self.unregister_provider(provider_id)
- if provider is not None:
- # simply re-register the same provider again
- await self.register_provider(provider)
- else:
- # try preloading all providers
- self.tasks.add("Reload providers", self._preload_providers)
+ self.logger.info("Application shutdown")
+ self.signal_event(EventType.SHUTDOWN)
+ if self._jobs_task is not None:
+ self._jobs_task.cancel()
+ if self.http_session and not self.http_session_provided:
+ await self.http_session.connector.close()
+ self.http_session.detach()
+
+ def signal_event(
+ self, event_type: EventType, event_details: EventDetails = None
+ ) -> None:
+ """
+ Signal (systemwide) event.
- @callback
- def get_provider(self, provider_id: str) -> Provider:
- """Return provider/plugin by id."""
- if provider_id not in self._providers:
- LOGGER.warning("Provider %s is not available", provider_id)
- return None
- return self._providers[provider_id]
+ :param event_msg: the eventmessage to signal
+ :param event_details: optional details to send with the event.
+ """
+ for cb_func, event_filter in self._listeners:
+ if not event_filter or event_type in event_filter:
+ create_task(cb_func, event_type, event_details)
- @callback
- def get_providers(
+ def subscribe(
self,
- filter_type: Optional[ProviderType] = None,
- include_unavailable: bool = False,
- ) -> Tuple[Provider]:
- """Return all providers, optionally filtered by type."""
- return tuple(
- item
- for item in self._providers.values()
- if (filter_type is None or item.type == filter_type)
- and (include_unavailable or item.available)
- )
-
- async def setup_discovery(self) -> None:
- """Make this Music Assistant instance discoverable on the network."""
-
- def _setup_discovery():
- zeroconf_type = "_music-assistant._tcp.local."
+ cb_func: EventCallBackType,
+ event_filter: EventType | Tuple[EventType] | None = None,
+ ) -> Callable:
+ """
+ Add callback to event listeners.
- info = ServiceInfo(
- zeroconf_type,
- name=f"{self.web.server_id}.{zeroconf_type}",
- addresses=[get_ip_pton()],
- port=self.web.port,
- properties=self.web.discovery_info,
- server=f"mass_{self.web.server_id}.local.",
- )
- LOGGER.debug("Starting Zeroconf broadcast...")
+ Returns function to remove the listener.
+ :param cb_func: callback function or coroutine
+ :param event_filter: Optionally only listen for these events
+ """
+ if isinstance(event_filter, EventType):
+ event_filter = (event_filter,)
+ elif event_filter is None:
+ event_filter = tuple()
+ listener = (cb_func, event_filter)
+ self._listeners.append(listener)
+
+ def remove_listener():
+ self._listeners.remove(listener)
+
+ return remove_listener
+
+ def add_job(self, job: Coroutine, name: str | None = None) -> None:
+ """Add job to be (slowly) processed in the background (one by one)."""
+ if not name:
+ name = job.__qualname__ or job.__name__
+ self._jobs.put_nowait((name, job))
+
+ async def __process_jobs(self):
+ """Process jobs in the background."""
+ while True:
+ name, job = await self._jobs.get()
+ time_start = time()
+ self.logger.debug("Start processing job [%s].", name)
try:
- existing = getattr(self, "mass_zc_service_set", None)
- if existing:
- self.zeroconf.update_service(info)
- else:
- self.zeroconf.register_service(info)
- setattr(self, "mass_zc_service_set", True)
- except NonUniqueNameException:
- LOGGER.error(
- "Music Assistant instance with identical name present in the local network!"
+ # await job
+ task = asyncio.create_task(job, name=name)
+ await task
+ except Exception as err: # pylint: disable=broad-except
+ self.logger.error(
+ "Job [%s] failed with error %s.", name, str(err), exc_info=err
)
-
- create_task(_setup_discovery)
-
- async def _preload_providers(self) -> None:
- """Dynamically load all providermodules."""
- base_dir = os.path.dirname(os.path.abspath(__file__))
- modules_path = os.path.join(base_dir, "providers")
- # load modules
- for dir_str in os.listdir(modules_path):
- dir_path = os.path.join(modules_path, dir_str)
- if not os.path.isdir(dir_path):
- continue
- # get files in directory
- for file_str in os.listdir(dir_path):
- file_path = os.path.join(dir_path, file_str)
- if not os.path.isfile(file_path):
- continue
- if not file_str == "__init__.py":
- continue
- module_name = dir_str
- if module_name in [i.id for i in self._providers.values()]:
- continue
- # try to load the module
- try:
- prov_mod = importlib.import_module(
- f".{module_name}", "music_assistant.providers"
- )
- await prov_mod.setup(self)
- # pylint: disable=broad-except
- except Exception as exc:
- LOGGER.exception("Error preloading module %s: %s", module_name, exc)
- else:
- LOGGER.debug("Successfully preloaded module %s", module_name)
+ else:
+ duration = round(time() - time_start, 2)
+ self.logger.info("Finished job [%s] in %s seconds.", name, duration)
-"""Models."""
+"""Models package."""
+++ /dev/null
-"""Model and helpers for Config entries."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from enum import Enum
-from typing import List, Optional, Tuple, Union
-
-from mashumaro import DataClassDictMixin
-
-
-class ConfigEntryType(Enum):
- """Enum for the type of a config entry."""
-
- BOOL = "boolean"
- STRING = "string"
- PASSWORD = "password"
- INT = "integer"
- FLOAT = "float"
- LABEL = "label"
- DICT = "dict"
-
-
-ValueTypes = Union[str, int, float, bool, dict, None]
-
-
-@dataclass
-class ConfigValueOption(DataClassDictMixin):
- """Model for a value with seperated name/value."""
-
- text: str
- value: ValueTypes
-
-
-@dataclass
-class ConfigEntry(DataClassDictMixin):
- """Model for a Config Entry."""
-
- entry_key: str
- entry_type: ConfigEntryType
- default_value: ValueTypes = None
- # options: select from list of possible values/options
- options: Optional[List[ConfigValueOption]] = None
- range: Optional[Tuple[int, int]] = None # select values within range
- label: Optional[str] = None # a friendly name for the setting
- description: Optional[str] = None # extended description of the setting.
- help_key: Optional[str] = None # key in the translations file
- multi_value: bool = False # allow multiple values from the list
- depends_on: Optional[
- str
- ] = None # needs to be set before this setting shows up in frontend
- hidden: bool = False # hide from UI
- value: ValueTypes = None # set by the configuration manager
- store_hashed: bool = False # value will be hashed, non reversible
--- /dev/null
+"""Custom errors and exceptions."""
+
+
+class MusicAssistantError(Exception):
+ """Custom Exception for all errors."""
+
+
+class ProviderUnavailableError(MusicAssistantError):
+ """Error raised when trying to access mediaitem of unavailable provider."""
+
+
+class MediaNotFoundError(MusicAssistantError):
+ """Error raised when trying to access non existing media item."""
+
+
+class InvalidDataError(MusicAssistantError):
+ """Error raised when an object has invalid data."""
+
+
+class AlreadyRegisteredError(MusicAssistantError):
+ """Error raised when a duplicate music provider or player is registered."""
+
+
+class SetupFailedError(MusicAssistantError):
+ """Error raised when setup of a provider or player failed."""
+
+
+class LoginFailed(MusicAssistantError):
+ """Error raised when a login failed."""
+
+
+class AudioError(MusicAssistantError):
+ """Error raised when an issue arrised when processing audio."""
--- /dev/null
+"""Model for a base media_controller."""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import Generic, List, Tuple, TypeVar
+
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.models.errors import MediaNotFoundError, ProviderUnavailableError
+
+from .media_items import MediaItemType, MediaType
+
+ItemCls = TypeVar("ItemCls", bound="MediaControllerBase")
+
+
+class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
+ """Base model for controller managing a MediaType."""
+
+ media_type: MediaType
+ item_cls: MediaItemType
+ db_table: str
+
+ def __init__(self, mass: MusicAssistant):
+ """Initialize class."""
+ self.mass = mass
+ self.logger = mass.logger.getChild(f"music.{self.media_type.value}")
+
+ @abstractmethod
+ async def setup(self):
+ """Async initialize of module."""
+
+ @abstractmethod
+ async def add(self, item: ItemCls) -> ItemCls:
+ """Add item to local db and return the database item."""
+ raise NotImplementedError
+
+ async def library(self) -> List[ItemCls]:
+ """Get all in-library items."""
+ match = {"in_library": True}
+ return [
+ self.item_cls.from_db_row(db_row)
+ for db_row in await self.mass.database.get_rows(self.db_table, match)
+ ]
+
+ async def get(
+ self,
+ provider_item_id: str,
+ provider_id: str,
+ force_refresh: bool = False,
+ lazy: bool = True,
+ details: ItemCls = None,
+ ) -> ItemCls:
+ """Return (full) details for a single media item."""
+ db_item = await self.get_db_item_by_prov_id(provider_id, provider_item_id)
+ if db_item and force_refresh:
+ provider_id, provider_item_id = await self.get_provider_id(db_item)
+ elif db_item:
+ return db_item
+ if not details:
+ details = await self.get_provider_item(provider_item_id, provider_id)
+ if not lazy:
+ return await self.add(details)
+ self.mass.add_job(self.add(details), f"Add {details.uri} to database")
+ return db_item if db_item else details
+
+ async def search(
+ self, search_query: str, provider_id: str, limit: int = 25
+ ) -> List[ItemCls]:
+ """Search database or provider with given query."""
+ if provider_id == "database":
+ return [
+ self.item_cls.from_db_row(db_row)
+ for db_row in await self.mass.database.search(
+ self.db_table, search_query
+ )
+ ]
+
+ provider = self.mass.music.get_provider(provider_id)
+ if not provider:
+ return {}
+ cache_key = (
+ f"{provider_id}.search.{self.media_type.value}.{search_query}.{limit}"
+ )
+ return await cached(
+ self.mass.cache,
+ cache_key,
+ provider.search,
+ search_query,
+ [self.media_type],
+ limit,
+ )
+
+ async def add_to_library(self, provider_item_id: str, provider_id: str) -> None:
+ """Add an item to the library."""
+ # make sure we have a valid full item
+ db_item = await self.get(provider_item_id, provider_id, lazy=False)
+ # add to provider libraries
+ for prov_id in db_item.provider_ids:
+ if prov := self.mass.music.get_provider(prov_id.provider):
+ await prov.library_add(prov_id.item_id, self.media_type)
+ # mark as library item in internal db
+ if not db_item.in_library:
+ await self.set_db_library(db_item.item_id, True)
+
+ async def remove_from_library(
+ self, provider_item_id: str, provider_id: str
+ ) -> None:
+ """Remove item from the library."""
+ # make sure we have a valid full item
+ db_item = await self.get(provider_item_id, provider_id, lazy=False)
+ # add to provider's libraries
+ for prov_id in db_item.provider_ids:
+ if prov := self.mass.music.get_provider(prov_id.provider):
+ await prov.library_remove(prov_id.item_id, self.media_type)
+ # unmark as library item in internal db
+ if db_item.in_library:
+ await self.set_db_library(db_item.item_id, False)
+
+ async def get_provider_id(self, item: ItemCls) -> Tuple[str, str]:
+ """Return provider and item id."""
+ if item.provider == "database":
+ # make sure we have a full object
+ item = await self.get_db_item(item.item_id)
+ for prov in item.provider_ids:
+ # returns the first provider that is available
+ if not prov.available:
+ continue
+ if self.mass.music.get_provider(prov.provider):
+ return (prov.provider, prov.item_id)
+ return None, None
+
+ async def get_db_items(self, custom_query: str | None = None) -> List[ItemCls]:
+ """Fetch all records from database."""
+ if custom_query is not None:
+ func = self.mass.database.get_rows_from_query(custom_query)
+ else:
+ func = self.mass.database.get_rows(self.db_table)
+ return [self.item_cls.from_db_row(db_row) for db_row in await func]
+
+ async def get_db_item(self, item_id: int) -> ItemCls:
+ """Get record by id."""
+ match = {"item_id": int(item_id)}
+ if db_row := await self.mass.database.get_row(self.db_table, match):
+ return self.item_cls.from_db_row(db_row)
+ return None
+
+ async def get_db_item_by_prov_id(
+ self,
+ provider_id: str,
+ provider_item_id: str,
+ ) -> ItemCls | None:
+ """Get the database album for the given prov_id."""
+ if provider_id == "database":
+ return await self.get_db_item(provider_item_id)
+ if item_id := await self.mass.music.get_provider_mapping(
+ self.media_type, provider_id, provider_item_id
+ ):
+ return await self.get_db_item(item_id)
+ return None
+
+ async def set_db_library(self, item_id: int, in_library: bool) -> None:
+ """Set the in-library bool on a database item."""
+ match = {"item_id": item_id}
+ await self.mass.database.update(
+ self.db_table,
+ match,
+ {"in_library": in_library},
+ )
+
+ async def get_provider_item(self, item_id: str, provider_id: str) -> ItemCls:
+ """Return item details for the given provider item id."""
+ if provider_id == "database":
+ return await self.get_db_item(item_id)
+ provider = self.mass.music.get_provider(provider_id)
+ if not provider:
+ raise ProviderUnavailableError(f"Provider {provider_id} is not available!")
+ cache_key = f"{provider_id}.get_{self.media_type.value}.{item_id}"
+ item = await cached(
+ self.mass.cache, cache_key, provider.get_item, self.media_type, item_id
+ )
+ if not item:
+ raise MediaNotFoundError(
+ f"{self.media_type.value} {item_id} not found on provider {provider_id}"
+ )
+ return item
--- /dev/null
+"""Models and helpers for media items."""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum, IntEnum
+from typing import Any, Dict, List, Mapping
+
+from mashumaro import DataClassDictMixin
+
+
+from music_assistant.helpers.json import json
+
+from music_assistant.helpers.util import create_sort_name
+
+
+class MediaType(Enum):
+ """Enum for MediaType."""
+
+ ARTIST = "artist"
+ ALBUM = "album"
+ TRACK = "track"
+ PLAYLIST = "playlist"
+ RADIO = "radio"
+ UNKNOWN = "unknown"
+
+
+class MediaQuality(IntEnum):
+ """Enum for Media Quality."""
+
+ LOSSY_MP3 = 0
+ LOSSY_OGG = 1
+ LOSSY_AAC = 2
+ FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits
+ FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES
+ FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES
+ FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES
+ FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES
+ UNKNOWN = 99
+
+
+@dataclass
+class MediaItemProviderId(DataClassDictMixin):
+ """Model for a MediaItem's provider id."""
+
+ provider: str
+ item_id: str
+ quality: MediaQuality = MediaQuality.UNKNOWN
+ details: str = None
+ available: bool = True
+
+ def __hash__(self):
+ """Return custom hash."""
+ return hash((self.provider, self.item_id, self.quality))
+
+
+@dataclass
+class MediaItem(DataClassDictMixin):
+ """Base representation of a media item."""
+
+ item_id: str
+ provider: str
+ name: str
+ sort_name: str | None = None
+ metadata: Dict[str, Any] = field(default_factory=dict)
+ provider_ids: List[MediaItemProviderId] = field(default_factory=list)
+ in_library: bool = False
+ media_type: MediaType = MediaType.UNKNOWN
+ uri: str = ""
+
+ def __post_init__(self):
+ """Call after init."""
+ if not self.uri:
+ self.uri = create_uri(self.media_type, self.provider, self.item_id)
+ if not self.sort_name:
+ self.sort_name = create_sort_name(self.name)
+
+ @classmethod
+ def from_db_row(cls, db_row: Mapping):
+ """Create MediaItem object from database row."""
+ db_row = dict(db_row)
+ for key in ["artists", "artist", "metadata", "provider_ids"]:
+ if key in db_row:
+ db_row[key] = json.loads(db_row[key])
+ db_row["provider"] = "database"
+ if "in_library" in db_row:
+ db_row["in_library"] = bool(db_row["in_library"])
+ if db_row.get("albums"):
+ db_row["album"] = db_row["albums"][0]
+ db_row["item_id"] = str(db_row["item_id"])
+ return cls.from_dict(db_row)
+
+ def to_db_row(self) -> dict:
+ """Create dict from item suitable for db."""
+ return {
+ key: json.dumps(val) if isinstance(val, (list, dict)) else val
+ for key, val in self.to_dict().items()
+ if key
+ not in [
+ "item_id",
+ "provider",
+ "media_type",
+ "uri",
+ "album",
+ "disc_number",
+ "track_number",
+ "position",
+ ]
+ }
+
+ @property
+ def available(self):
+ """Return (calculated) availability."""
+ return any(x.available for x in self.provider_ids)
+
+
+@dataclass
+class ItemMapping(DataClassDictMixin):
+ """Representation of a minimized item object."""
+
+ item_id: str
+ provider: str
+ name: str = ""
+ media_type: MediaType = MediaType.ARTIST
+ uri: str = ""
+
+ def __post_init__(self):
+ """Call after init."""
+ if not self.uri:
+ self.uri = create_uri(self.media_type, self.provider, self.item_id)
+
+ @classmethod
+ def from_item(cls, item: "MediaItem"):
+ """Create ItemMapping object from regular item."""
+ return cls.from_dict(item.to_dict())
+
+ def __hash__(self):
+ """Return custom hash."""
+ return hash((self.media_type, self.provider, self.item_id))
+
+
+@dataclass
+class Artist(MediaItem):
+ """Model for an artist."""
+
+ media_type: MediaType = MediaType.ARTIST
+ musicbrainz_id: str = ""
+
+
+class AlbumType(Enum):
+ """Enum for Album type."""
+
+ ALBUM = "album"
+ SINGLE = "single"
+ COMPILATION = "compilation"
+ UNKNOWN = "unknown"
+
+
+@dataclass
+class Album(MediaItem):
+ """Model for an album."""
+
+ media_type: MediaType = MediaType.ALBUM
+ version: str = ""
+ year: int | None = None
+ artist: ItemMapping | Artist | None = None
+ album_type: AlbumType = AlbumType.UNKNOWN
+ upc: str | None = None
+
+ def __hash__(self):
+ """Return custom hash."""
+ return hash((self.provider, self.item_id))
+
+
+@dataclass
+class Track(MediaItem):
+ """Model for a track."""
+
+ media_type: MediaType = MediaType.TRACK
+ duration: int = 0
+ version: str = ""
+ isrc: str = ""
+ artists: List[ItemMapping | Artist] = field(default_factory=list)
+ # album track only
+ album: ItemMapping | Album | None = None
+ disc_number: int | None = None
+ track_number: int | None = None
+ # playlist track only
+ position: int | None = None
+
+ def __hash__(self):
+ """Return custom hash."""
+ return hash((self.provider, self.item_id))
+
+
+@dataclass
+class Playlist(MediaItem):
+ """Model for a playlist."""
+
+ media_type: MediaType = MediaType.PLAYLIST
+ owner: str = ""
+ checksum: str = "" # some value to detect playlist track changes
+ is_editable: bool = False
+
+
+@dataclass
+class Radio(MediaItem):
+ """Model for a radio station."""
+
+ media_type: MediaType = MediaType.RADIO
+ duration: int = 86400
+
+ def to_db_row(self) -> dict:
+ """Create dict from item suitable for db."""
+ val = super().to_db_row()
+ val.pop("duration", None)
+ return val
+
+
+def create_uri(media_type: MediaType, provider_id: str, item_id: str):
+ """Create uri for mediaitem."""
+ return f"{provider_id}://{media_type.value}/{item_id}"
+
+
+MediaItemType = Artist | Album | Track | Radio | Playlist
+
+
+class StreamType(Enum):
+ """Enum with stream types."""
+
+ EXECUTABLE = "executable"
+ URL = "url"
+ FILE = "file"
+ CACHE = "cache"
+
+
+class ContentType(Enum):
+ """Enum with audio content types supported by ffmpeg."""
+
+ OGG = "ogg"
+ FLAC = "flac"
+ MP3 = "mp3"
+ AAC = "aac"
+ MPEG = "mpeg"
+ PCM_S16LE = "s16le" # PCM signed 16-bit little-endian
+ PCM_S24LE = "s24le" # PCM signed 24-bit little-endian
+ PCM_S32LE = "s32le" # PCM signed 32-bit little-endian
+ PCM_F32LE = "f32le" # PCM 32-bit floating-point little-endian
+ PCM_F64LE = "f64le" # PCM 64-bit floating-point little-endian
+
+ def is_pcm(self):
+ """Return if contentype is PCM."""
+ return self.name.startswith("PCM")
+
+ def sox_supported(self):
+ """Return if ContentType is supported by SoX."""
+ return self not in [ContentType.AAC, ContentType.MPEG]
+
+ def sox_format(self):
+ """Convert the ContentType to SoX compatible format."""
+ if not self.sox_supported():
+ raise NotImplementedError
+ return self.value.replace("le", "")
+
+ @classmethod
+ def from_bit_depth(
+ cls, bit_depth: int, floating_point: bool = False
+ ) -> "ContentType":
+ """Return (PCM) Contenttype from PCM bit depth."""
+ if floating_point and bit_depth > 32:
+ return cls.PCM_F64LE
+ if floating_point:
+ return cls.PCM_F32LE
+ if bit_depth == 16:
+ return cls.PCM_S16LE
+ if bit_depth == 24:
+ return cls.PCM_S24LE
+ return cls.PCM_S32LE
+
+
+@dataclass
+class StreamDetails(DataClassDictMixin):
+ """Model for streamdetails."""
+
+ type: StreamType
+ provider: str
+ item_id: str
+ path: str
+ content_type: ContentType
+ player_id: str = ""
+ details: Dict[str, Any] = field(default_factory=dict)
+ seconds_played: int = 0
+ gain_correct: float = 0
+ loudness: float | None = None
+ sample_rate: int | None = None
+ bit_depth: int | None = None
+ channels: int = 2
+ media_type: MediaType = MediaType.TRACK
+ queue_id: str = None
+
+ def __post_serialize__(self, d: Dict[Any, Any]) -> Dict[Any, Any]:
+ """Exclude internal fields from dict."""
+ d.pop("path")
+ d.pop("details")
+ return d
+
+ def __str__(self):
+ """Return pretty printable string of object."""
+ return f"{self.type.value}/{self.content_type.value} - {self.provider}/{self.item_id}"
+++ /dev/null
-"""Models and helpers for media items."""
-
-from dataclasses import dataclass, field
-from enum import Enum, IntEnum
-from typing import Any, Dict, List, Mapping, Optional, Set
-
-import ujson
-from mashumaro import DataClassDictMixin
-from music_assistant.helpers.util import create_uri
-
-
-class MediaType(Enum):
- """Enum for MediaType."""
-
- ARTIST = "artist"
- ALBUM = "album"
- TRACK = "track"
- PLAYLIST = "playlist"
- RADIO = "radio"
- UNKNOWN = "unknown"
-
-
-class ContributorRole(Enum):
- """Enum for Contributor Role."""
-
- ARTIST = "artist"
- WRITER = "writer"
- PRODUCER = "producer"
-
-
-class AlbumType(Enum):
- """Enum for Album type."""
-
- ALBUM = "album"
- SINGLE = "single"
- COMPILATION = "compilation"
- UNKNOWN = "unknown"
-
-
-class TrackQuality(IntEnum):
- """Enum for Track Quality."""
-
- LOSSY_MP3 = 0
- LOSSY_OGG = 1
- LOSSY_AAC = 2
- FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits
- FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES
- FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES
- FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES
- FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES
- UNKNOWN = 99
-
-
-@dataclass
-class MediaItemProviderId(DataClassDictMixin):
- """Model for a MediaItem's provider id."""
-
- provider: str
- item_id: str
- quality: TrackQuality = TrackQuality.UNKNOWN
- details: str = None
- available: bool = True
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.provider, self.item_id, self.quality))
-
-
-@dataclass
-class MediaItem(DataClassDictMixin):
- """Base representation of a media item."""
-
- item_id: str
- provider: str
- name: str = ""
- metadata: Dict[str, Any] = field(default_factory=dict)
- provider_ids: Set[MediaItemProviderId] = field(default_factory=set)
- in_library: bool = False
- media_type: MediaType = MediaType.UNKNOWN
- uri: str = ""
-
- def __post_init__(self):
- """Call after init."""
- if not self.uri:
- self.uri = create_uri(self.media_type, self.provider, self.item_id)
-
- @classmethod
- def from_dict(cls, dict_obj: dict):
- # pylint: disable=arguments-differ
- """Parse MediaItem from dict."""
- if dict_obj["media_type"] == "artist":
- return Artist.from_dict(dict_obj)
- if dict_obj["media_type"] == "album":
- return Album.from_dict(dict_obj)
- if dict_obj["media_type"] == "track":
- return Track.from_dict(dict_obj)
- if dict_obj["media_type"] == "playlist":
- return Playlist.from_dict(dict_obj)
- if dict_obj["media_type"] == "radio":
- return Radio.from_dict(dict_obj)
- return super().from_dict(dict_obj)
-
- @classmethod
- def from_db_row(cls, db_row: Mapping):
- """Create MediaItem object from database row."""
- db_row = dict(db_row)
- for key in ["artists", "artist", "album", "metadata", "provider_ids", "albums"]:
- if key in db_row:
- db_row[key] = ujson.loads(db_row[key])
- db_row["provider"] = "database"
- if "in_library" in db_row:
- db_row["in_library"] = bool(db_row["in_library"])
- if db_row.get("albums"):
- db_row["album"] = db_row["albums"][0]
- db_row["item_id"] = str(db_row["item_id"])
- return cls.from_dict(db_row)
-
- @property
- def sort_name(self):
- """Return sort name."""
- sort_name = self.name
- for item in ["The ", "De ", "de ", "Les "]:
- if self.name.startswith(item):
- sort_name = "".join(self.name.split(item)[1:])
- return sort_name.lower()
-
- @property
- def available(self):
- """Return (calculated) availability."""
- return any(x.available for x in self.provider_ids)
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
- def __str__(self):
- """Return string representation, used for logging."""
- return f"{self.name} ({self.uri})"
-
-
-@dataclass
-class Artist(MediaItem):
- """Model for an artist."""
-
- media_type: MediaType = MediaType.ARTIST
- musicbrainz_id: str = ""
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class ItemMapping(DataClassDictMixin):
- """Representation of a minimized item object."""
-
- item_id: str
- provider: str
- name: str = ""
- media_type: MediaType = MediaType.ARTIST
- uri: str = ""
-
- def __post_init__(self):
- """Call after init."""
- if not self.uri:
- self.uri = create_uri(self.media_type, self.provider, self.item_id)
-
- @classmethod
- def from_item(cls, item: Mapping):
- """Create ItemMapping object from regular item."""
- return cls.from_dict(item.to_dict())
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class Album(MediaItem):
- """Model for an album."""
-
- media_type: MediaType = MediaType.ALBUM
- version: str = ""
- year: int = 0
- artist: Optional[ItemMapping] = None
- album_type: AlbumType = AlbumType.UNKNOWN
- upc: str = ""
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class FullAlbum(Album):
- """Model for an album with full details."""
-
- artist: Optional[Artist] = None
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class Track(MediaItem):
- """Model for a track."""
-
- media_type: MediaType = MediaType.TRACK
- duration: int = 0
- version: str = ""
- isrc: str = ""
- artists: Set[ItemMapping] = field(default_factory=set)
- albums: Set[ItemMapping] = field(default_factory=set)
- # album track only
- album: Optional[ItemMapping] = None
- disc_number: int = 0
- track_number: int = 0
- # playlist track only
- position: int = 0
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class FullTrack(Track):
- """Model for an album with full details."""
-
- artists: Set[Artist] = field(default_factory=set)
- albums: Set[Album] = field(default_factory=set)
- album: Optional[Album] = None
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class Playlist(MediaItem):
- """Model for a playlist."""
-
- media_type: MediaType = MediaType.PLAYLIST
- owner: str = ""
- checksum: str = "" # some value to detect playlist track changes
- is_editable: bool = False
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class Radio(MediaItem):
- """Model for a radio station."""
-
- media_type: MediaType = MediaType.RADIO
- duration: int = 86400
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
-
-@dataclass
-class SearchResult(DataClassDictMixin):
- """Model for Media Item Search result."""
-
- artists: List[Artist] = field(default_factory=list)
- albums: List[Album] = field(default_factory=list)
- tracks: List[Track] = field(default_factory=list)
- playlists: List[Playlist] = field(default_factory=list)
- radios: List[Radio] = field(default_factory=list)
"""Models and helpers for a player."""
+from __future__ import annotations
-from abc import abstractmethod
-from dataclasses import dataclass, field
+from abc import ABC
+from dataclasses import dataclass
from enum import Enum, IntEnum
-from typing import Any, Optional, Set
+from typing import TYPE_CHECKING, Any, Dict, List
from mashumaro import DataClassDictMixin
-from music_assistant.constants import (
- CONF_ENABLED,
- CONF_NAME,
- CONF_POWER_CONTROL,
- CONF_VOLUME_CONTROL,
- EVENT_PLAYER_CHANGED,
-)
-from music_assistant.helpers.typing import ConfigSubItem, MusicAssistant, QueueItems
-from music_assistant.helpers.util import callback, create_task
-from music_assistant.models.config_entry import ConfigEntry
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import create_task
+
+if TYPE_CHECKING:
+ from .player_queue import PlayerQueue
class PlayerState(Enum):
class DeviceInfo(DataClassDictMixin):
"""Model for a player's deviceinfo."""
- model: str = ""
- address: str = ""
- manufacturer: str = ""
+ model: str = "unknown"
+ address: str = "unknown"
+ manufacturer: str = "unknown"
class PlayerFeature(IntEnum):
CROSSFADE = 2
-class PlayerControlType(Enum):
- """Enum with different player control types."""
-
- POWER = 0
- VOLUME = 1
- UNKNOWN = 99
-
-
-@dataclass
-class PlayerControl(DataClassDictMixin):
- """
- Model for a player control.
-
- Allows for a plugin-like
- structure to override common player commands.
- """
-
- type: PlayerControlType
- control_id: str
- provider: str
- name: str
- state: Any = None
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.type, self.provider, self.control_id))
-
- async def set_state(self, new_state: Any) -> None:
- """Handle command to set the state for a player control."""
- # by default we just signal an event on the eventbus
- # pickup this event (e.g. from the websocket api)
- # or override this method with your own implementation.
- # pylint: disable=no-member
- self.mass.eventbus.signal(
- f"players/controls/{self.control_id}/state", new_state
- )
-
-
-@dataclass
-class CalculatedPlayerState(DataClassDictMixin):
- """Model for a (calculated) player state."""
-
- player_id: str = None
- provider_id: str = None
- name: str = None
- powered: bool = False
- state: PlayerState = PlayerState.IDLE
- available: bool = False
- volume_level: int = 0
- muted: bool = False
- is_group_player: bool = False
- group_childs: Set[str] = field(default_factory=set)
- device_info: DeviceInfo = field(default_factory=DeviceInfo)
- group_parents: Set[str] = field(default_factory=set)
- features: Set[PlayerFeature] = field(default_factory=set)
- active_queue: str = None
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.provider_id, self.player_id))
-
- def __str__(self):
- """Return string representation, used for logging."""
- return f"{self.name} ({self.provider_id}/{self.player_id})"
-
- def update(self, new_obj: "PlayerState") -> Set[str]:
- """Update state from other PlayerState instance and return changed keys."""
- changed_keys = set()
- # pylint: disable=no-member
- for key in self.__dataclass_fields__.keys():
- new_val = getattr(new_obj, key)
- if getattr(self, key) != new_val:
- setattr(self, key, new_val)
- changed_keys.add(key)
- return changed_keys
-
-
-class Player:
+class Player(ABC):
"""Model for a music player."""
- # Public properties: should be overriden with provider specific implementation
-
- @property
- @abstractmethod
- def player_id(self) -> str:
- """Return player id of this player."""
- return None
+ player_id: str
+ is_group: bool = False
+ _attr_name: str = None
+ _attr_powered: bool = False
+ _attr_elapsed_time: int = 0
+ _attr_current_url: str = None
+ _attr_state: PlayerState = PlayerState.IDLE
+ _attr_available: bool = True
+ _attr_volume_level: int = 100
+ _attr_device_info: DeviceInfo = DeviceInfo()
+ _attr_max_sample_rate: int = 96000
+ # mass object will be set by playermanager at register
+ mass: MusicAssistant = None # type: ignore[assignment]
@property
- @abstractmethod
- def provider_id(self) -> str:
- """Return provider id of this player."""
- return None
+ def name(self) -> bool:
+ """Return player name."""
+ return self._attr_name or self.player_id
@property
- def name(self) -> str:
- """Return name of the player."""
- return None
-
- @property
- @abstractmethod
def powered(self) -> bool:
"""Return current power state of player."""
- return False
+ return self._attr_powered
@property
- @abstractmethod
def elapsed_time(self) -> int:
"""Return elapsed time of current playing media in seconds."""
- return 0
+ return self._attr_elapsed_time
@property
- def elapsed_milliseconds(self) -> Optional[int]:
- """
- Return elapsed time of current playing media in milliseconds.
-
- This is an optional property.
- If provided, the property must return the REALTIME value while playing.
- Used for synced playback in player groups.
- """
- return None
+ def current_url(self) -> str:
+ """Return URL that is currently loaded in the player."""
@property
- @abstractmethod
def state(self) -> PlayerState:
"""Return current PlayerState of player."""
- return PlayerState.IDLE
+ return self._attr_state
@property
def available(self) -> bool:
"""Return current availablity of player."""
- return True
+ return self._attr_available
@property
- @abstractmethod
- def current_uri(self) -> Optional[str]:
- """Return currently loaded uri of player (if any)."""
- return None
-
- @property
- @abstractmethod
def volume_level(self) -> int:
"""Return current volume level of player (scale 0..100)."""
- return 0
-
- @property
- @abstractmethod
- def muted(self) -> bool:
- """Return current mute state of player."""
- return False
-
- @property
- @abstractmethod
- def is_group_player(self) -> bool:
- """Return True if this player is a group player."""
- return False
-
- @property
- def group_childs(self) -> Set[str]:
- """Return list of child player id's if player is a group player."""
- return {}
+ return self._attr_volume_level
@property
def device_info(self) -> DeviceInfo:
- """Return the device info for this player."""
- return DeviceInfo()
-
- @property
- def should_poll(self) -> bool:
- """Return True if this player should be polled for state updates."""
- return False
+ """Return basic device/provider info for this player."""
+ return self._attr_device_info
@property
- def features(self) -> Set[PlayerFeature]:
- """Return list of features this player supports."""
- return {}
-
- @property
- def config_entries(self) -> Set[ConfigEntry]:
- """Return player specific config entries (if any)."""
- return {}
-
- # Public methods / player commands: should be overriden with provider specific implementation
-
- async def on_poll(self) -> None:
- """Call when player is periodically polled by the player manager (should_poll=True)."""
- self.update_state()
+ def max_sample_rate(self) -> int:
+ """Return the maximum supported sample rate this player supports."""
+ return self._attr_max_sample_rate
- async def on_add(self) -> None:
- """Call when player is added to the player manager."""
-
- async def on_remove(self) -> None:
- """Call when player is removed from the player manager."""
-
- async def cmd_play_uri(self, uri: str) -> None:
- """
- Play the specified uri/url on the player.
-
- :param uri: uri/url to send to the player.
- """
+ async def play_url(self, url: str) -> None:
+ """Play the specified url on the player."""
raise NotImplementedError
- async def cmd_stop(self) -> None:
+ async def stop(self) -> None:
"""Send STOP command to player."""
raise NotImplementedError
- async def cmd_play(self) -> None:
- """Send PLAY command to player."""
+ async def play(self) -> None:
+ """Send PLAY/UNPAUSE command to player."""
raise NotImplementedError
- async def cmd_pause(self) -> None:
+ async def pause(self) -> None:
"""Send PAUSE command to player."""
raise NotImplementedError
- async def cmd_next(self) -> None:
- """Send NEXT TRACK command to player."""
+ async def power(self, powered: bool) -> None:
+ """Send POWER command to player."""
raise NotImplementedError
- async def cmd_previous(self) -> None:
- """Send PREVIOUS TRACK command to player."""
+ async def volume_set(self, volume_level: int) -> None:
+ """Send volume level (0..100) command to player."""
raise NotImplementedError
- async def cmd_power_on(self) -> None:
- """Send POWER ON command to player."""
- raise NotImplementedError
+ # SOME CONVENIENCE METHODS
- async def cmd_power_off(self) -> None:
- """Send POWER OFF command to player."""
- raise NotImplementedError
+ async def volume_up(self, step_size: int = 5):
+ """Send volume UP command to player."""
+ new_level = min(self.volume_level + step_size, 100)
+ return await self.volume_set(new_level)
- async def cmd_volume_set(self, volume_level: int) -> None:
- """
- Send volume level command to player.
+ async def volume_down(self, step_size: int = 5):
+ """Send volume DOWN command to player."""
+ new_level = max(self.volume_level - step_size, 0)
+ return await self.volume_set(new_level)
- :param volume_level: volume level to set (0..100).
- """
- raise NotImplementedError
+ async def play_pause(self) -> None:
+ """Toggle play/pause on player."""
+ if self.state == PlayerState.PAUSED:
+ await self.play()
+ else:
+ await self.pause()
- async def cmd_volume_mute(self, is_muted: bool = False) -> None:
- """
- Send volume MUTE command to given player.
+ async def power_toggle(self) -> None:
+ """Toggle power on player."""
+ await self.power(not self.powered)
- :param is_muted: bool with new mute state.
- """
- raise NotImplementedError
-
- # OPTIONAL: QUEUE SERVICE CALLS/COMMANDS - OVERRIDE ONLY IF SUPPORTED BY PROVIDER
-
- async def cmd_queue_play_index(self, index: int) -> None:
- """
- Play item at index X on player's queue.
-
- :param index: (int) index of the queue item that should start playing
- """
- if PlayerFeature.QUEUE in self.features:
- raise NotImplementedError
-
- async def cmd_queue_load(
- self, queue_items: QueueItems, repeat: bool = False
- ) -> None:
- """
- Load/overwrite given items in the player's queue implementation.
-
- :param queue_items: a list of QueueItems
- """
- if PlayerFeature.QUEUE in self.features:
- raise NotImplementedError
-
- async def cmd_queue_insert(
- self, queue_items: QueueItems, insert_at_index: int
- ) -> None:
- """
- Insert new items at position X into existing queue.
-
- If insert_at_index 0 or None, will start playing newly added item(s)
- :param queue_items: a list of QueueItems
- :param insert_at_index: queue position to insert new items
- """
- if PlayerFeature.QUEUE in self.features:
- raise NotImplementedError
-
- async def cmd_queue_append(self, queue_items: QueueItems) -> None:
- """
- Append new items at the end of the queue.
-
- :param queue_items: a list of QueueItems
- """
- if PlayerFeature.QUEUE in self.features:
- raise NotImplementedError
-
- async def cmd_queue_update(self, queue_items: QueueItems) -> None:
- """
- Overwrite the existing items in the queue, used for reordering.
-
- :param queue_items: a list of QueueItems
- """
- if PlayerFeature.QUEUE in self.features:
- raise NotImplementedError
-
- async def cmd_queue_clear(self) -> None:
- """Clear the player's queue."""
- if PlayerFeature.QUEUE in self.features:
- raise NotImplementedError
-
- # Private properties and methods
- # Do not override below this point!
+ # DO NOT OVERRIDE BELOW
@property
- def active_queue(self) -> str:
- """Return the active parent player/queue for a player."""
- return self._calculated_state.active_queue or self.player_id
+ def queue(self) -> "PlayerQueue":
+ """Return PlayerQueue for this player."""
+ return self.mass.players.get_player_queue(self.player_id, True)
- @property
- def group_parents(self) -> Set[str]:
- """Return all groups this player belongs to."""
- return self._calculated_state.group_parents
+ def update_state(self) -> None:
+ """Update current player state in the player manager."""
+ # basic throttle: do not send state changed events if player did not change
+ prev_state = getattr(self, "_prev_state", None)
+ cur_state = self.to_dict()
+ if prev_state == cur_state:
+ return
+ setattr(self, "_prev_state", cur_state)
+ self.mass.signal_event(EventType.PLAYER_CHANGED, self)
+ self.queue.on_player_update()
+ if self.is_group:
+ # update group player childs when parent updates
+ for child_player_id in self.group_childs:
+ if player := self.mass.players.get_player(child_player_id):
+ create_task(player.update_state)
+ else:
+ # update group player when child updates
+ for group_player_id in self.get_group_parents():
+ if player := self.mass.players.get_player(group_player_id):
+ create_task(player.update_state)
+
+ def get_group_parents(self) -> List[str]:
+ """Get any/all group player id's this player belongs to."""
+ return [
+ x.player_id
+ for x in self.mass.players
+ if x.is_group and self.player_id in x.group_childs
+ ]
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Export object to dict."""
+ return {
+ "player_id": self.player_id,
+ "name": self.name,
+ "powered": self.powered,
+ "elapsed_time": self.elapsed_time,
+ "state": self.state.value,
+ "available": self.available,
+ "volume_level": int(self.volume_level),
+ "device_info": self.device_info.to_dict(),
+ }
- @property
- def config(self) -> ConfigSubItem:
- """Return this player's configuration."""
- return self.mass.config.get_player_config(self.player_id)
- @property
- def enabled(self):
- """Return True if this player is enabled."""
- return self.config[CONF_ENABLED]
+class PlayerGroup(Player):
+ """Model for a player group."""
- @property
- def power_control(self) -> Optional[PlayerControl]:
- """Return this player's Power Control."""
- player_control_conf = self.config.get(CONF_POWER_CONTROL)
- if player_control_conf:
- return self.mass.players.get_player_control(player_control_conf)
- return None
+ is_group: bool = True
+ _attr_group_childs: List[str] = []
+ _attr_support_join_control: bool = True
@property
- def volume_control(self) -> Optional[PlayerControl]:
- """Return this player's Volume Control."""
- player_control_conf = self.config.get(CONF_VOLUME_CONTROL)
- if player_control_conf:
- return self.mass.players.get_player_control(player_control_conf)
- return None
+ def support_join_control(self) -> bool:
+ """Return bool if joining/unjoining of players to this group is supported."""
+ return self._attr_support_join_control
@property
- def calculated_state(self) -> CalculatedPlayerState:
- """Return calculated/final state for this player."""
- return self._calculated_state
+ def group_childs(self) -> List[str]:
+ """Return list of child player id's of this PlayerGroup."""
+ return self._attr_group_childs
- @callback
- def update_state(self) -> None:
- """Call to update current player state in the player manager."""
- if self.mass.exit:
- return
- if not self.added_to_mass:
- if self.enabled:
- # player is now enabled and can be added
- create_task(self.mass.players.add_player(self))
- return
- new_state = self.create_calculated_state()
- changed_keys = self._calculated_state.update(new_state)
- # always update the player queue
- player_queue = self.mass.players.get_player_queue(self.active_queue)
- if player_queue:
- create_task(player_queue.update_state)
- # basic throttle: do not send state changed events if player did not change
- if not changed_keys:
- return
- self._calculated_state = new_state
- self.mass.eventbus.signal(EVENT_PLAYER_CHANGED, new_state)
- # update group player childs when parent updates
- for child_player_id in self.group_childs:
- create_task(self.mass.players.trigger_player_update(child_player_id))
- # update group player when child updates
- for group_player_id in self._calculated_state.group_parents:
- create_task(self.mass.players.trigger_player_update(group_player_id))
-
- @callback
- def _get_powered(self) -> bool:
- """Return final/calculated player's power state."""
- if not self.available or not self.enabled:
- return False
- power_control = self.power_control
- if power_control:
- return power_control.state
- return self.powered
-
- @callback
- def _get_state(self, powered: bool, active_queue: str) -> PlayerState:
- """Return final/calculated player's PlayerState."""
- if powered and active_queue != self.player_id:
- # use group state
- return self.mass.players.get_player(active_queue).state
- return PlayerState.OFF if not powered else self.state
-
- @callback
- def _get_available(self) -> bool:
- """Return current availablity of player."""
- return False if not self.enabled else self.available
-
- @callback
- def _get_volume_level(self) -> int:
- """Return final/calculated player's volume_level."""
- if not self.available or not self.enabled:
+ @property
+ def volume_level(self) -> int:
+ """Return current volume level of player (scale 0..100)."""
+ if not self.available:
return 0
- # handle volume control
- volume_control = self.volume_control
- if volume_control:
- return volume_control.state
+ # calculate group volume from powered players for convenience
+ group_volume = 0
+ active_players = 0
+ for child_player in self._get_players(True):
+ group_volume += child_player.volume_level
+ active_players += 1
+ if active_players:
+ group_volume = group_volume / active_players
+ return int(group_volume)
+
+ async def power(self, powered: bool) -> None:
+ """Send POWER command to player."""
+ try:
+ super().power(powered)
+ except NotImplementedError:
+ self._attr_powered = powered
+ if not powered:
+ # turn off all childs
+ for child_player in self._get_players(True):
+ await child_player.power(False)
+
+ async def volume_set(self, volume_level: int) -> None:
+ """Send volume level (0..100) command to player."""
# handle group volume
- if self.is_group_player:
- group_volume = 0
- active_players = 0
- for child_player_id in self.group_childs:
- child_player = self.mass.players.get_player(child_player_id)
- if child_player:
- group_volume += child_player.calculated_state.volume_level
- active_players += 1
- if active_players:
- group_volume = group_volume / active_players
- return int(group_volume)
- return int(self.volume_level)
-
- @callback
- def _get_group_parents(self) -> Set[str]:
- """Return all group players this player belongs to."""
- if self.is_group_player:
- return {}
- return {
- player.player_id
- for player in self.mass.players
- if player.is_group_player and self.player_id in player.group_childs
- }
+ cur_volume = self.volume_level
+ new_volume = volume_level
+ volume_dif = new_volume - cur_volume
+ if cur_volume == 0:
+ volume_dif_percent = 1 + (new_volume / 100)
+ else:
+ volume_dif_percent = volume_dif / cur_volume
+ for child_player in self._get_players(True):
+ cur_child_volume = child_player.volume_level
+ new_child_volume = cur_child_volume + (
+ cur_child_volume * volume_dif_percent
+ )
+ await child_player.volume_set(new_child_volume)
+
+ async def join(self, player_id: str) -> None:
+ """Command to add/join a player to this group."""
+ raise NotImplementedError
+
+ async def unjoin(self, player_id: str) -> None:
+ """Command to remove/unjoin a player to this group."""
+ raise NotImplementedError
- @callback
- def _get_active_queue(self) -> str:
- """Return the active parent player/queue for a player."""
- # if a group is playing, all of it's childs will have/use
- # the parent's player's queue.
- for group_player_id in self.group_parents:
- group_player = self.mass.players.get_player(group_player_id)
- if group_player and group_player.state in [
- PlayerState.PLAYING,
- PlayerState.PAUSED,
- ]:
- return group_player_id
- return self.player_id
-
- @callback
- def create_calculated_state(self) -> CalculatedPlayerState:
- """Create CalculatedPlayerState."""
- conf_name = self.config.get(CONF_NAME)
- active_queue = self._get_active_queue()
- powered = self._get_powered()
- return CalculatedPlayerState(
- player_id=self.player_id,
- provider_id=self.provider_id,
- name=conf_name if conf_name else self.name,
- powered=powered,
- state=self._get_state(powered, active_queue),
- available=self._get_available(),
- volume_level=self._get_volume_level(),
- muted=self.muted,
- is_group_player=self.is_group_player,
- group_childs=self.group_childs,
- device_info=self.device_info,
- group_parents=self._get_group_parents(),
- features=self.features,
- active_queue=active_queue,
- )
-
- def __init__(self, *args, **kwargs) -> None:
- """Initialize a Player instance."""
- self.mass: Optional[MusicAssistant] = None
- self.added_to_mass = False
- self._calculated_state = CalculatedPlayerState()
-
- def to_dict(self):
- """Return playerstate for compatability with json serializer."""
- return self._calculated_state.to_dict()
+ def _get_players(self, only_powered: bool = False) -> List[Player]:
+ """Get players attached to this group."""
+ return [
+ x
+ for x in self.mass.players
+ if x.player_id in self.group_childs and x.powered or not only_powered
+ ]
-"""Models and helpers for a player queue."""
+"""Model and helpders for a PlayerQueue."""
+from __future__ import annotations
-import logging
+import asyncio
import random
import time
-import uuid
-from dataclasses import dataclass, field
+from asyncio import Task, TimerHandle
+from dataclasses import dataclass
from enum import Enum
-from typing import Any, Dict, List, Optional, Set, Tuple, Union
-
-from music_assistant.constants import (
- CONF_CROSSFADE_DURATION,
- EVENT_QUEUE_ITEMS_UPDATED,
- EVENT_QUEUE_UPDATED,
-)
-from music_assistant.helpers.datetime import now
-from music_assistant.helpers.typing import (
- MusicAssistant,
- OptionalInt,
- OptionalStr,
- Player,
-)
-from music_assistant.helpers.util import callback, create_task
-from music_assistant.models.media_types import ItemMapping, Radio, Track
-from music_assistant.models.player import PlayerFeature, PlayerState
-from music_assistant.models.streamdetails import StreamDetails
-
-# pylint: disable=too-many-instance-attributes
-# pylint: disable=too-many-public-methods
-# pylint: disable=too-few-public-methods
-
-LOGGER = logging.getLogger("player_queue")
+from typing import TYPE_CHECKING, List, Tuple
+from uuid import uuid4
+
+from mashumaro import DataClassDictMixin
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.audio import get_stream_details
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import create_task
+from music_assistant.models.media_items import MediaType, StreamDetails
+
+from .player import Player, PlayerState
+
+if TYPE_CHECKING:
+ from music_assistant.models.media_items import Radio, Track
class QueueOption(Enum):
@dataclass
-class QueueItem(ItemMapping):
- """Representation of a queue item, simplified version of track."""
+class QueueItem(DataClassDictMixin):
+ """Representation of a queue item."""
- queue_item_id: str = ""
- streamdetails: StreamDetails = None
- stream_url: str = ""
- duration: int = 0
- artists: Set[ItemMapping] = field(default_factory=set)
+ uri: str
+ name: str = ""
+ duration: int | None = None
+ item_id: str = ""
+ sort_index: int = 0
+ streamdetails: StreamDetails | None = None
+ is_media_item: bool = False
def __post_init__(self):
- """Generate unique id for the QueueItem."""
- super().__post_init__()
- self.queue_item_id = str(uuid.uuid4())
+ """Set default values."""
+ if not self.item_id:
+ self.item_id = str(uuid4())
+ if not self.name:
+ self.name = self.uri
@classmethod
- def from_track(cls, base_item: Union[Track, Radio]):
+ def from_media_item(cls, media_item: "Track" | "Radio"):
"""Construct QueueItem from track/radio item."""
- return cls.from_dict(base_item.to_dict())
+ return cls(
+ uri=media_item.uri,
+ name=media_item.name,
+ duration=media_item.duration,
+ is_media_item=True,
+ )
class PlayerQueue:
- """Class that holds the queue items for a player."""
+ """Represents a PlayerQueue object."""
- def __init__(self, mass: MusicAssistant, player_id: str) -> None:
- """Initialize class."""
+ def __init__(self, mass: MusicAssistant, player_id: str):
+ """Instantiate a PlayerQueue instance."""
self.mass = mass
- self._queue_id = player_id
- self._items = []
- self._shuffle_enabled = False
- self._repeat_enabled = False
- self._cur_index = 0
- self._cur_item_time = 0
- self._last_item = None
- self._queue_stream_start_index = 0
- self._queue_stream_next_index = 0
- self._queue_stream_active = False
- self._last_playback_state = PlayerState.IDLE
- # load previous queue settings from disk
- create_task(self._restore_saved_state())
-
- def __str__(self):
- """Return string representation, used for logging."""
- return f"{self.player.name} ({self._queue_id})"
-
- async def close(self) -> None:
- """Handle shutdown/close."""
- # pylint: disable=unused-argument
- await self._save_state()
+ self.logger = mass.players.logger
+ self.queue_id = player_id
+ self.player_id = player_id
+
+ self._shuffle_enabled: bool = False
+ self._repeat_enabled: bool = False
+ self._crossfade_duration: int = 0
+ self._volume_normalization_enabled: bool = True
+ self._volume_normalization_target: int = -23
+
+ self._current_index: int | None = None
+ self._current_item_time: int = 0
+ self._last_item: QueueItem | None = None
+ self._start_index: int = 0
+ self._next_index: int = 0
+ self._last_state = PlayerState.IDLE
+ self._items: List[QueueItem] = []
+ self._save_task: TimerHandle = None
+ self._update_task: Task = None
+ self._signal_next: bool = False
+ self._last_player_update: int = 0
+ self._stream_url: str | None = None
+
+ async def setup(self) -> None:
+ """Handle async setup of instance."""
+ await self._restore_saved_state()
+ self.mass.signal_event(EventType.QUEUE_ADDED, self)
@property
def player(self) -> Player:
- """Return handle to (master) player of this queue."""
- return self.mass.players.get_player(self._queue_id)
+ """Return the player attached to this queue."""
+ return self.mass.players.get_player(self.player_id, include_unavailable=True)
@property
- def state(self) -> PlayerState:
- """Return playbackstate of this (player) Queue."""
- return self.player.state
+ def available(self) -> bool:
+ """Return bool if this queue is available."""
+ return self.player.available
@property
- def queue_id(self) -> str:
- """Return the Queue's id."""
- return self._queue_id
-
- def get_stream_url(self) -> str:
- """Return the full stream url for the player's Queue Stream."""
- url = f"{self.mass.web.stream_url}/queue/{self.queue_id}"
- # we set the checksum just to invalidate cache stuf
- url += f"?checksum={time.time()}"
- return url
+ def active(self) -> bool:
+ """Return bool if the queue is currenty active on the player."""
+ # TODO: figure out a way to handle group childs playing the parent queue
+ if self.player.current_url is None:
+ return False
+ return self._stream_url in self.player.current_url
@property
- def shuffle_enabled(self) -> bool:
- """Return shuffle enabled property."""
- return self._shuffle_enabled
-
- async def set_shuffle_enabled(self, enable_shuffle: bool) -> None:
- """Set shuffle."""
- if not self._shuffle_enabled and enable_shuffle:
- # shuffle requested
- self._shuffle_enabled = True
- if self.cur_index is not None:
- played_items = self.items[: self.cur_index]
- next_items = self.__shuffle_items(self.items[self.cur_index + 1 :])
- items = played_items + [self.cur_item] + next_items
- await self.update(items)
- elif self._shuffle_enabled and not enable_shuffle:
- # unshuffle
- self._shuffle_enabled = False
- if self.cur_index is not None:
- played_items = self.items[: self.cur_index]
- next_items = self.items[self.cur_index + 1 :]
- next_items.sort(key=lambda x: x.sort_index, reverse=False)
- items = played_items + [self.cur_item] + next_items
- await self.update(items)
- self.update_state()
- self.signal_update()
+ def elapsed_time(self) -> int:
+ """Return elapsed time of current playing media in seconds."""
+ if not self.active:
+ return self.player.elapsed_time
+ return self._current_item_time
@property
def repeat_enabled(self) -> bool:
- """Return if crossfade is enabled for this player."""
+ """Return if repeat is enabled."""
return self._repeat_enabled
- async def set_repeat_enabled(self, enable_repeat: bool) -> None:
- """Set the repeat mode for this queue."""
- if self._repeat_enabled != enable_repeat:
- self._repeat_enabled = enable_repeat
- self.update_state()
- create_task(self._save_state())
- self.signal_update()
+ @property
+ def shuffle_enabled(self) -> bool:
+ """Return if shuffle is enabled."""
+ return self._shuffle_enabled
@property
- def cur_index(self) -> OptionalInt:
- """
- Return the current index of the queue.
+ def crossfade_duration(self) -> int:
+ """Return crossfade duration (0 if disabled)."""
+ return self._crossfade_duration
- Returns None if queue is empty.
- """
- if not self._items:
- return None
- return self._cur_index
+ @property
+ def max_sample_rate(self) -> int:
+ """Return the maximum supported sample rate this playerqueue supports."""
+ if self.player.max_sample_rate is None:
+ return 96000
+ return self.player.max_sample_rate
@property
- def cur_item_id(self) -> OptionalStr:
+ def items(self) -> List[QueueItem]:
+ """Return all items in this queue."""
+ return self._items
+
+ @property
+ def current_index(self) -> int | None:
"""
- Return the queue item id of the current item in the queue.
+ Return the current index of the queue.
Returns None if queue is empty.
"""
- cur_item = self.cur_item
- if not cur_item:
+ if self._current_index >= len(self._items):
return None
- return cur_item.queue_item_id
+ return self._current_index
@property
- def cur_item(self) -> Optional[QueueItem]:
+ def current_item(self) -> QueueItem | None:
"""
Return the current item in the queue.
Returns None if queue is empty.
"""
- if (
- self.cur_index is None
- or not self.items
- or not len(self.items) > self.cur_index
- ):
+ if self._current_index is None:
return None
- return self.items[self.cur_index]
-
- @property
- def cur_item_time(self) -> int:
- """Return the time (progress) for current (playing) item."""
- return self._cur_item_time
+ if self._current_index >= len(self._items):
+ return None
+ return self._items[self._current_index]
@property
- def next_index(self) -> OptionalInt:
- """Return the next index for this player's queue.
+ def next_index(self) -> int | None:
+ """
+ Return the next index for this PlayerQueue.
Return None if queue is empty or no more items.
"""
- if not self.items:
+ if not self._items:
# queue is empty
return None
- if self.cur_index is None:
- # playback started
+ if self._current_index is None:
+ # playback just started
return 0
# player already playing (or paused) so return the next item
- if len(self.items) > (self.cur_index + 1):
- return self.cur_index + 1
- if self._repeat_enabled:
+ if len(self._items) > (self._current_index + 1):
+ return self._current_index + 1
+ if self.repeat_enabled:
# repeat enabled, start queue at beginning
return 0
return None
@property
- def next_item(self) -> Optional[QueueItem]:
- """Return the next item in the queue.
+ def next_item(self) -> QueueItem | None:
+ """
+ Return the next item in the queue.
Returns None if queue is empty or no more items.
"""
if self.next_index is not None:
- return self.items[self.next_index]
+ return self._items[self.next_index]
return None
@property
- def items(self) -> List[QueueItem]:
- """Return all queue items for this player's queue."""
- return self._items
+ def volume_normalization_enabled(self) -> bool:
+ """Return bool if volume normalization is enabled for this queue."""
+ return self._volume_normalization_enabled
@property
- def use_queue_stream(self) -> bool:
- """
- Indicate that we need to use the queue stream.
-
- For example if crossfading is requested but a player doesn't natively support it
- we will send a constant stream of audio to the player with all tracks.
- """
- return (
- not self.supports_crossfade
- if self.crossfade_enabled
- else not self.supports_queue
- )
-
- @property
- def crossfade_duration(self) -> int:
- """Return crossfade duration (if enabled)."""
- player_settings = self.mass.config.get_player_config(self.queue_id)
- if player_settings:
- return player_settings.get(CONF_CROSSFADE_DURATION, 0)
- return 0
-
- @property
- def crossfade_enabled(self) -> bool:
- """Return bool if crossfade is enabled."""
- return self.crossfade_duration > 0
-
- @property
- def supports_queue(self) -> bool:
- """Return if this player supports native queue."""
- return PlayerFeature.QUEUE in self.player.features
-
- @property
- def supports_crossfade(self) -> bool:
- """Return if this player supports native crossfade."""
- return PlayerFeature.CROSSFADE in self.player.features
-
- @callback
- def get_item(self, index: int) -> Optional[QueueItem]:
- """Get item by index from queue."""
- if index is not None and len(self.items) > index:
- return self.items[index]
+ def volume_normalization_target(self) -> int:
+ """Return volume target (in LUFS) for volume normalization for this queue."""
+ return self._volume_normalization_target
+
+ def get_item(self, index: int) -> QueueItem | None:
+ """Get queue item by index."""
+ if index is not None and len(self._items) > index:
+ return self._items[index]
return None
- @callback
- def by_item_id(self, queue_item_id: str) -> Optional[QueueItem]:
+ def item_by_id(self, queue_item_id: str) -> QueueItem | None:
"""Get item by queue_item_id from queue."""
if not queue_item_id:
return None
- for item in self.items:
- if item.queue_item_id == queue_item_id:
- return item
+ return next((x for x in self.items if x.item_id == queue_item_id), None)
+
+ def index_by_id(self, queue_item_id: str) -> int | None:
+ """Get index by queue_item_id."""
+ for index, item in enumerate(self.items):
+ if item.item_id == queue_item_id:
+ return index
return None
+ async def play_media(
+ self,
+ uris: str | List[str],
+ queue_opt: QueueOption = QueueOption.PLAY,
+ ):
+ """
+ Play media item(s) on the given queue.
+
+ :param queue_id: queue id of the PlayerQueue to handle the command.
+ :param uri: uri(s) that should be played (single item or list of uri's).
+ :param queue_opt:
+ QueueOption.PLAY -> Insert new items in queue and start playing at inserted position
+ QueueOption.REPLACE -> Replace queue contents with these items
+ QueueOption.NEXT -> Play item(s) after current playing item
+ QueueOption.ADD -> Append new items at end of the queue
+ """
+ # a single item or list of items may be provided
+ if not isinstance(uris, list):
+ uris = [uris]
+ queue_items = []
+ for uri in uris:
+ if uri.startswith("http"):
+ # a plain url was provided
+ queue_items.append(QueueItem(uri))
+ continue
+ media_item = await self.mass.music.get_item_by_uri(uri)
+ if not media_item:
+ raise FileNotFoundError(f"Invalid uri: {uri}")
+ # collect tracks to play
+ if media_item.media_type == MediaType.ARTIST:
+ tracks = await self.mass.music.artists.toptracks(
+ media_item.item_id, provider_id=media_item.provider
+ )
+ elif media_item.media_type == MediaType.ALBUM:
+ tracks = await self.mass.music.albums.tracks(
+ media_item.item_id, provider_id=media_item.provider
+ )
+ elif media_item.media_type == MediaType.PLAYLIST:
+ tracks = await self.mass.music.playlists.tracks(
+ media_item.item_id, provider_id=media_item.provider
+ )
+ elif media_item.media_type == MediaType.RADIO:
+ # single radio
+ tracks = [
+ await self.mass.music.radio.get(
+ media_item.item_id, provider_id=media_item.provider
+ )
+ ]
+ else:
+ # single track
+ tracks = [
+ await self.mass.music.tracks.get(
+ media_item.item_id, provider_id=media_item.provider
+ )
+ ]
+ for track in tracks:
+ if not track.available:
+ continue
+ queue_items.append(QueueItem.from_media_item(track))
+
+ # load items into the queue
+ if queue_opt == QueueOption.REPLACE:
+ return await self.load(queue_items)
+ if queue_opt in [QueueOption.PLAY, QueueOption.NEXT] and len(queue_items) > 100:
+ return await self.load(queue_items)
+ if queue_opt == QueueOption.NEXT:
+ return await self.insert(queue_items, 1)
+ if queue_opt == QueueOption.PLAY:
+ return await self.insert(queue_items, 0)
+ if queue_opt == QueueOption.ADD:
+ return await self.append(queue_items)
+
+ async def set_shuffle_enabled(self, enable_shuffle: bool) -> None:
+ """Set shuffle."""
+ if not self._shuffle_enabled and enable_shuffle:
+ # shuffle requested
+ self._shuffle_enabled = True
+ if self._current_index is not None:
+ played_items = self.items[: self._current_index]
+ next_items = self.__shuffle_items(self.items[self._current_index + 1 :])
+ items = played_items + [self.current_item] + next_items
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+ await self.update(items)
+ elif self._shuffle_enabled and not enable_shuffle:
+ # unshuffle
+ self._shuffle_enabled = False
+ if self._current_index is not None:
+ played_items = self.items[: self._current_index]
+ next_items = self.items[self._current_index + 1 :]
+ next_items.sort(key=lambda x: x.sort_index, reverse=False)
+ items = played_items + [self.current_item] + next_items
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+ await self.update(items)
+
+ async def set_repeat_enabled(self, enable_repeat: bool) -> None:
+ """Set the repeat mode for this queue."""
+ if self._repeat_enabled != enable_repeat:
+ self._repeat_enabled = enable_repeat
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+ await self._save_state()
+
+ async def set_crossfade_duration(self, duration: int) -> None:
+ """Set the crossfade duration for this queue, 0 to disable."""
+ duration = max(duration, 10)
+ if self._crossfade_duration != duration:
+ self._crossfade_duration = duration
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+ await self._save_state()
+
+ async def set_volume_normalization_enabled(self, enable: bool) -> None:
+ """Set volume normalization."""
+ if self._repeat_enabled != enable:
+ self._repeat_enabled = enable
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+ await self._save_state()
+
+ async def set_volume_normalization_target(self, target: int) -> None:
+ """Set the target for the volume normalization in LUFS (default is -23)."""
+ target = min(target, 0)
+ target = max(target, -40)
+ if self._volume_normalization_target != target:
+ self._volume_normalization_target = target
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+ await self._save_state()
+
async def stop(self) -> None:
"""Stop command on queue player."""
- return await self.player.cmd_stop()
+ # redirect to underlying player
+ await self.player.stop()
async def play(self) -> None:
"""Play (unpause) command on queue player."""
- return await self.player.cmd_play()
+ if self.player.state == PlayerState.PAUSED:
+ await self.player.play()
+ else:
+ await self.resume()
async def pause(self) -> None:
"""Pause command on queue player."""
- return await self.player.cmd_pause()
+ # redirect to underlying player
+ await self.player.pause()
async def next(self) -> None:
"""Play the next track in the queue."""
- if self.cur_index is None:
+ if self._current_index is None:
+ return
+ if self.next_index is None:
return
- if self.use_queue_stream:
- return await self.play_index(self.cur_index + 1)
- return await self.player.cmd_next()
+ await self.play_index(self.next_index)
async def previous(self) -> None:
"""Play the previous track in the queue."""
- if self.cur_index is None:
+ if self._current_index is None:
return
- if self.use_queue_stream:
- return await self.play_index(self.cur_index - 1)
- return await self.player.cmd_previous()
+ await self.play_index(self._current_index - 1)
async def resume(self) -> None:
"""Resume previous queue."""
# TODO: Support skipping to last known position
- if self.items:
- prev_index = self.cur_index
- if self.use_queue_stream:
- await self.play_index(prev_index)
- else:
- # at this point we don't know if the queue is synced with the player
- # so just to be safe we send the queue_items to the player
- self._items = self._items[prev_index:]
- return await self.player.cmd_queue_load(
- self._items, self.repeat_enabled
- )
+ if self._items:
+ prev_index = self._current_index
+ await self.play_index(prev_index)
else:
- LOGGER.warning(
+ self.logger.warning(
"resume queue requested for %s but queue is empty", self.queue_id
)
- async def play_index(self, index: Union[int, str]) -> None:
+ async def play_index(self, index: int | str) -> None:
"""Play item at index (or item_id) X in queue."""
if not isinstance(index, int):
- index = self.__index_by_id(index)
+ index = self.index_by_id(index)
if index is None:
- raise FileNotFoundError("Unknown index/id: %s" % index)
+ raise FileNotFoundError(f"Unknown index/id: {index}")
if not len(self.items) > index:
return
- self._cur_index = index
- self._queue_stream_next_index = index
- if self.use_queue_stream:
- queue_stream_url = self.get_stream_url()
- return await self.player.cmd_play_uri(queue_stream_url)
- if self.supports_queue:
- try:
- return await self.player.cmd_queue_play_index(index)
- except NotImplementedError:
- # not supported by player, use load queue instead
- LOGGER.debug(
- "cmd_queue_insert not supported by player, fallback to cmd_queue_load "
- )
- self._items = self._items[index:]
- return await self.player.cmd_queue_load(self._items)
- else:
- return await self.player.cmd_play_uri(self._items[index].stream_url)
+ self._current_index = index
+
+ # send stream url to player connected to this queue
+ self._stream_url = self.mass.players.streams.get_stream_url(self.queue_id)
+ await self.player.play_url(self._stream_url)
async def move_item(self, queue_item_id: str, pos_shift: int = 1) -> None:
"""
move item x positions up if negative value
move item to top of queue as next item if 0
"""
- items = self.items.copy()
- item_index = self.__index_by_id(queue_item_id)
+ items = self._items.copy()
+ item_index = self.index_by_id(queue_item_id)
if pos_shift == 0 and self.player.state == PlayerState.PLAYING:
- new_index = self.cur_index + 1
+ new_index = self._current_index + 1
elif pos_shift == 0:
- new_index = self.cur_index
+ new_index = self._current_index
else:
new_index = item_index + pos_shift
- if (new_index < self.cur_index) or (new_index > len(self.items)):
+ if (new_index < self._current_index) or (new_index > len(self.items)):
return
# move the item in the list
items.insert(new_index, items.pop(item_index))
if self._shuffle_enabled and len(queue_items) > 5:
queue_items = self.__shuffle_items(queue_items)
self._items = queue_items
- if self.use_queue_stream:
- await self.play_index(0)
- else:
- await self.player.cmd_queue_load(queue_items, self.repeat_enabled)
- self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
- create_task(self._save_state())
+ self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self)
+ await self.play_index(0)
+ await self._save_state()
async def insert(self, queue_items: List[QueueItem], offset: int = 0) -> None:
"""
:param queue_items: a list of QueueItem
:param offset: offset from current queue position
"""
-
- if not self.items or self.cur_index is None:
+ if not self.items or self._current_index is None:
return await self.load(queue_items)
- insert_at_index = self.cur_index + offset
+ insert_at_index = self._current_index + offset
for index, item in enumerate(queue_items):
item.sort_index = insert_at_index + index
if self.shuffle_enabled and len(queue_items) > 5:
+ queue_items
+ self._items[insert_at_index:]
)
- if self.use_queue_stream:
- if offset == 0:
- await self.play_index(insert_at_index)
- else:
- # send queue to player's own implementation
- try:
- await self.player.cmd_queue_insert(queue_items, insert_at_index)
- except NotImplementedError:
- # not supported by player, use load queue instead
- LOGGER.debug(
- "cmd_queue_insert not supported by player, fallback to cmd_queue_load "
- )
- self._items = self._items[self.cur_index + offset :]
- return await self.player.cmd_queue_load(
- self._items, self.repeat_enabled
- )
- self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
- create_task(self._save_state())
+
+ if offset == 0:
+ await self.play_index(insert_at_index)
+
+ self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self)
+ await self._save_state()
async def append(self, queue_items: List[QueueItem]) -> None:
"""Append new items at the end of the queue."""
for index, item in enumerate(queue_items):
item.sort_index = len(self.items) + index
if self.shuffle_enabled:
- played_items = self.items[: self.cur_index]
- next_items = self.items[self.cur_index + 1 :] + queue_items
+ played_items = self.items[: self._current_index]
+ next_items = self.items[self._current_index + 1 :] + queue_items
next_items = self.__shuffle_items(next_items)
- items = played_items + [self.cur_item] + next_items
- return await self.update(items)
+ items = played_items + [self.current_item] + next_items
+ await self.update(items)
+ return
self._items = self._items + queue_items
- if not self.use_queue_stream:
- # send queue to player's own implementation
- try:
- await self.player.cmd_queue_append(queue_items)
- except NotImplementedError:
- # not supported by player, use load queue instead
- LOGGER.debug(
- "cmd_queue_append not supported by player, fallback to cmd_queue_load "
- )
- self._items = self._items[self.cur_index :]
- return await self.player.cmd_queue_load(
- self._items, self.repeat_enabled
- )
- self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
- create_task(self._save_state())
+ self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self)
+ await self._save_state()
async def update(self, queue_items: List[QueueItem]) -> None:
"""Update the existing queue items, mostly caused by reordering."""
self._items = queue_items
- if not self.use_queue_stream:
- # send queue to player's own implementation
- try:
- await self.player.cmd_queue_update(queue_items)
- except NotImplementedError:
- # not supported by player, use load queue instead
- LOGGER.debug(
- "cmd_queue_update not supported by player, fallback to cmd_queue_load "
- )
- self._items = self._items[self.cur_index :]
- await self.player.cmd_queue_load(self._items, self.repeat_enabled)
- self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
- create_task(self._save_state())
+ self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self)
+ await self._save_state()
async def clear(self) -> None:
"""Clear all items in the queue."""
- await self.mass.players.cmd_stop(self.queue_id)
- self._items = []
- if self.supports_queue:
- # send queue cmd to player's own implementation
- try:
- await self.player.cmd_queue_clear()
- except NotImplementedError:
- # not supported by player, try update instead
- try:
- await self.player.cmd_queue_update([])
- except NotImplementedError:
- # not supported by player, ignore
- pass
- self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
-
- @callback
- def update_state(self) -> None:
+ await self.stop()
+ await self.update([])
+
+ def on_player_update(self) -> None:
+ """Call when player updates."""
+ self._last_player_update = time.time()
+ if self._last_state != self.player.state:
+ self._last_state = self.player.state
+ # handle case where stream stopped on purpose and we need to restart it
+ if self.player.state != PlayerState.PLAYING and self._signal_next:
+ self._signal_next = False
+ create_task(self.play())
+ # start updater task if needed
+ if self.player.state == PlayerState.PLAYING:
+ if not self._update_task:
+ self._update_task = create_task(self.__update_task())
+ else:
+ if self._update_task:
+ self._update_task.cancel()
+ self._update_task = None
+
+ if not self.update_state():
+ # fire event anyway when player updated.
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+
+ def update_state(self) -> bool:
"""Update queue details, called when player updates."""
- new_index = self._cur_index
- track_time = self._cur_item_time
+ new_index = self._current_index
+ track_time = self._current_item_time
new_item_loaded = False
- # handle queue stream
- if (
- self.use_queue_stream
- and self.player.state == PlayerState.PLAYING
- and self.player.elapsed_time > 1
- ):
+ # if self.player.state == PlayerState.PLAYING and self.elapsed_time > 1:
+ if self.player.state == PlayerState.PLAYING:
new_index, track_time = self.__get_queue_stream_index()
- # normal queue based approach
- elif not self.use_queue_stream:
- track_time = self.player.elapsed_time
- for index, queue_item in enumerate(self.items):
- if queue_item.stream_url == self.player.current_uri:
- new_index = index
- break
# process new index
- if self._cur_index != new_index:
+ if self._current_index != new_index:
# queue track updated
- self._cur_index = new_index
+ self._current_index = new_index
# check if a new track is loaded, wait for the streamdetails
if (
- self.cur_item
- and self._last_item != self.cur_item
- and self.cur_item.streamdetails
+ self.current_item
+ and self._last_item != self.current_item
+ and self.current_item.streamdetails
):
# new active item in queue
new_item_loaded = True
# invalidate previous streamdetails
if self._last_item:
self._last_item.streamdetails = None
- self._last_item = self.cur_item
+ self._last_item = self.current_item
# update vars and signal update on eventbus if needed
- prev_item_time = int(self._cur_item_time)
- self._cur_item_time = int(track_time)
- if self._last_playback_state != self.state:
- # fire event with updated state
- self.signal_update()
- self._last_playback_state = self.state
- elif abs(prev_item_time - self._cur_item_time) > 3:
- # only send media_position if it changed more then 3 seconds (e.g. skipping)
- self.signal_update()
- elif new_item_loaded:
- self.signal_update()
+ prev_item_time = int(self._current_item_time)
+ self._current_item_time = int(track_time)
+ if new_item_loaded or abs(prev_item_time - self._current_item_time) >= 1:
+ self.mass.signal_event(EventType.QUEUE_UPDATED, self)
+ return True
+ return False
+
+ async def queue_stream_prepare(self) -> StreamDetails | None:
+ """Call when queue_streamer is about to start playing."""
+ if next_item := self.next_item:
+ return await get_stream_details(self.mass, next_item, self.queue_id)
+ return None
async def queue_stream_start(self) -> None:
"""Call when queue_streamer starts playing the queue stream."""
- self._cur_item_time = 0
- self._cur_index = self._queue_stream_next_index
- self._queue_stream_next_index += 1
- self._queue_stream_start_index = self._cur_index
- return self._cur_index
+ self._current_item_time = 0
+ self._current_index = self.next_index
+ self._start_index = self._current_index
+ return self._current_index
async def queue_stream_next(self, cur_index: int) -> None:
"""Call when queue_streamer loads next track in buffer."""
elif self._repeat_enabled:
# repeat enabled, start queue at beginning
next_index = 0
- self._queue_stream_next_index = next_index + 1
+ self._next_index = next_index + 1
return next_index
- def to_dict(self) -> Dict[str, Any]:
- """Instance attributes as dict so it can be serialized to json."""
- return {
- "queue_id": self.player.player_id,
- "queue_name": self.player.calculated_state.name,
- "shuffle_enabled": self.shuffle_enabled,
- "repeat_enabled": self.repeat_enabled,
- "crossfade_enabled": self.crossfade_enabled,
- "items": len(self._items),
- "cur_item_id": self.cur_item_id,
- "cur_index": self.cur_index,
- "next_index": self.next_index,
- "cur_item": self.cur_item.to_dict() if self.cur_item else None,
- "cur_item_time": int(self.cur_item_time),
- "next_item": self.next_item.to_dict() if self.next_item else None,
- "state": self.state.value,
- "updated_at": now().isoformat(),
- }
-
- def signal_update(self):
- """Signal update of this Queue to eventbus."""
- self.mass.eventbus.signal(
- EVENT_QUEUE_UPDATED,
- self,
- )
+ async def queue_stream_signal_next(self):
+ """Indicate that queue stream needs to start nex index once playback finished."""
+ self._signal_next = True
+
+ async def __update_task(self) -> None:
+ """Update player queue every interval."""
+ while True:
+ self.update_state()
+ await asyncio.sleep(1)
+
+ def __get_total_elapsed_time(self) -> int:
+ """Calculate the total elapsed time of the queue(player)."""
+ if self.player.state == PlayerState.PLAYING:
+ time_diff = time.time() - self._last_player_update
+ return int(self.player.elapsed_time + time_diff)
+ if self.player.state == PlayerState.PAUSED:
+ return self.player.elapsed_time
+ return 0
- @callback
def __get_queue_stream_index(self) -> Tuple[int, int]:
- """Get index of queue stream."""
- # player is playing a constant stream of the queue so we need to do this the hard way
+ """Calculate current queue index and current track elapsed time."""
+ # player is playing a constant stream so we need to do this the hard way
queue_index = 0
- elapsed_time_queue = self.player.elapsed_time
+ elapsed_time_queue = self.__get_total_elapsed_time()
total_time = 0
track_time = 0
- if self.items and len(self.items) > self._queue_stream_start_index:
- queue_index = (
- self._queue_stream_start_index
- ) # holds the last starting position
+ if self._items and len(self._items) > self._start_index:
+ # start_index: holds the last starting position
+ queue_index = self._start_index
queue_track = None
- while len(self.items) > queue_index:
- queue_track = self.items[queue_index]
+ while len(self._items) > queue_index:
+ queue_track = self._items[queue_index]
if elapsed_time_queue > (queue_track.duration + total_time):
total_time += queue_track.duration
queue_index += 1
return queue_index, track_time
@staticmethod
- def __shuffle_items(queue_items) -> List[QueueItem]:
+ def __shuffle_items(queue_items: List[QueueItem]) -> List[QueueItem]:
"""Shuffle a list of tracks."""
# for now we use default python random function
- # can be extended with some more magic last_played and stuff
+ # can be extended with some more magic based on last_played and stuff
return random.sample(queue_items, len(queue_items))
- def __index_by_id(self, queue_item_id) -> OptionalInt:
- """Get index by queue_item_id."""
- item_index = None
- for index, item in enumerate(self.items):
- if item.queue_item_id == queue_item_id:
- item_index = index
- return item_index
-
async def _restore_saved_state(self) -> None:
- """Try to load the saved queue for this player from cache file."""
- cache_str = "queue_state_%s" % self.queue_id
- cache_data = await self.mass.cache.get(cache_str)
- if cache_data:
- self._shuffle_enabled = cache_data["shuffle_enabled"]
- self._repeat_enabled = cache_data["repeat_enabled"]
- self._items = cache_data["items"]
- self._cur_index = cache_data.get("cur_index", 0)
- self._queue_stream_next_index = self._cur_index
-
- def create_queue_item(self, *args, **kwargs):
- """Create QueueItem including correct stream URL."""
- if args and isinstance(args[0], (Track, Radio)):
- new_item = QueueItem.from_track(args[0])
- else:
- new_item = QueueItem(*args, **kwargs)
- new_item.stream_url = "%s/queue/%s/%s" % (
- self.mass.web.stream_url,
- self.queue_id,
- new_item.queue_item_id,
+ """Try to load the saved state from database."""
+ if db_row := await self.mass.database.get_row(
+ "queue_settings", {"queue_id": self.queue_id}
+ ):
+ self._shuffle_enabled = db_row["shuffle_enabled"]
+ self._repeat_enabled = db_row["repeat_enabled"]
+ self._crossfade_duration = db_row["crossfade_duration"]
+ if queue_cache := await self.mass.cache.get(f"queue_items.{self.queue_id}"):
+ self._items = queue_cache["items"]
+ self._current_index = queue_cache["current_index"]
+
+ async def _save_state(self) -> None:
+ """Save state in database."""
+ # save queue settings in db
+ await self.mass.database.insert_or_replace(
+ "queue_settings",
+ {
+ "queue_id": self.queue_id,
+ "shuffle_enabled": self._shuffle_enabled,
+ "repeat_enabled": self.repeat_enabled,
+ "crossfade_duration": self._crossfade_duration,
+ "volume_normalization_enabled": self._volume_normalization_enabled,
+ "volume_normalization_target": self._volume_normalization_target,
+ },
)
- return new_item
- # pylint: enable=unused-argument
+ # store current items in cache
+ async def cache_items():
+ await self.mass.cache.set(
+ f"queue_items.{self.queue_id}",
+ {"items": self._items, "current_index": self._current_index},
+ )
- async def _save_state(self) -> None:
- """Save current queue settings to file."""
- cache_str = "queue_state_%s" % self.queue_id
- cache_data = {
- "shuffle_enabled": self._shuffle_enabled,
- "repeat_enabled": self._repeat_enabled,
- "items": self._items,
- "cur_index": self._cur_index,
- }
- await self.mass.cache.set(cache_str, cache_data)
- LOGGER.debug("queue state saved to file for player %s", self.queue_id)
+ if self._save_task and not self._save_task.cancelled():
+ return
+ self._save_task = self.mass.loop.call_later(60, create_task, cache_items)
-"""Models for providers/plugins."""
+"""Model for a Music Providers."""
+
+from __future__ import annotations
from abc import abstractmethod
-from enum import Enum
-from typing import Dict, List, Optional
+from logging import Logger
+from typing import List
-from music_assistant.helpers.typing import MusicAssistant, Players
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.media_types import (
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.models.media_items import (
Album,
Artist,
+ MediaItemType,
MediaType,
Playlist,
Radio,
- SearchResult,
Track,
)
-from music_assistant.models.streamdetails import StreamDetails
-
-
-class ProviderType(Enum):
- """Enum with plugin types."""
+from music_assistant.models.player_queue import StreamDetails
- MUSIC_PROVIDER = "music_provider"
- PLAYER_PROVIDER = "player_provider"
- METADATA_PROVIDER = "metadata_provider"
- PLUGIN = "plugin"
+class MusicProvider:
+ """Model for a Music Provider."""
-class Provider:
- """Base model for a provider/plugin."""
+ _attr_id: str = None
+ _attr_name: str = None
+ _attr_available: bool = True
+ _attr_supported_mediatypes: List[MediaType] = []
+ mass: MusicAssistant = None # set by setup
+ logger: Logger = None # set by setup
- mass: MusicAssistant = None # will be set automagically while loading the provider
- available: bool = False # will be set automagically while loading the provider
-
- @property
@abstractmethod
- def type(self) -> ProviderType:
- """Return ProviderType."""
+ async def setup(self) -> None:
+ """
+ Handle async initialization of the provider.
+
+ Called when provider is registered.
+ """
@property
- @abstractmethod
def id(self) -> str:
"""Return provider ID for this provider."""
+ return self._attr_id
@property
- @abstractmethod
def name(self) -> str:
"""Return provider Name for this provider."""
+ return self._attr_name
@property
- @abstractmethod
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
-
- @abstractmethod
- async def on_start(self) -> bool:
- """
- Handle initialization of the provider based on config.
-
- Return bool if start was succesfull. Called on startup.
- """
- raise NotImplementedError
-
- @abstractmethod
- async def on_stop(self) -> None:
- """Handle correct close/cleanup of the provider on exit. Called on shutdown/reload."""
-
-
-class Plugin(Provider):
- """
- Base class for a Plugin.
-
- Should be overridden/subclassed by provider specific implementation.
- """
-
- @property
- def type(self) -> ProviderType:
- """Return ProviderType."""
- return ProviderType.PLUGIN
-
-
-class PlayerProvider(Provider):
- """
- Base class for a Playerprovider.
-
- Should be overridden/subclassed by provider specific implementation.
- """
-
- @property
- def type(self) -> ProviderType:
- """Return ProviderType."""
- return ProviderType.PLAYER_PROVIDER
-
- @property
- def players(self) -> Players:
- """Return all players belonging to this provider."""
- # pylint: disable=no-member
- return [player for player in self.mass.players if player.provider_id == self.id]
-
-
-class MetadataProvider(Provider):
- """
- Base class for a MetadataProvider.
-
- Should be overridden/subclassed by provider specific implementation.
- """
-
- @property
- def type(self) -> ProviderType:
- """Return ProviderType."""
- return ProviderType.METADATA_PROVIDER
-
- async def get_artist_images(self, mb_artist_id: str) -> Dict:
- """Retrieve artist metadata as dict by musicbrainz artist id."""
- raise NotImplementedError
-
- async def get_album_images(self, mb_album_id: str) -> Dict:
- """Retrieve album metadata as dict by musicbrainz album id."""
- raise NotImplementedError
-
-
-class MusicProvider(Provider):
- """
- Base class for a Musicprovider.
-
- Should be overriden in the provider specific implementation.
- """
-
- @property
- def type(self) -> ProviderType:
- """Return ProviderType."""
- return ProviderType.MUSIC_PROVIDER
+ 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 [
- MediaType.ALBUM,
- MediaType.ARTIST,
- MediaType.PLAYLIST,
- MediaType.RADIO,
- MediaType.TRACK,
- ]
+ return self._attr_supported_mediatypes
async def search(
- self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
- ) -> SearchResult:
+ self, search_query: str, media_types=List[MediaType] | None, limit: int = 5
+ ) -> List[MediaItemType]:
"""
Perform search on musicprovider.
if MediaType.PLAYLIST in self.supported_mediatypes:
raise NotImplementedError
- async def get_radios(self) -> List[Radio]:
+ async def get_library_radios(self) -> List[Radio]:
"""Retrieve library/subscribed radio stations from the provider."""
if MediaType.RADIO in self.supported_mediatypes:
raise NotImplementedError
async def get_stream_details(self, item_id: str) -> StreamDetails:
"""Get streamdetails for a track/radio."""
raise NotImplementedError
+
+ # some helper methods below
+ async def get_library_items(self, media_type: MediaType) -> List[MediaItemType]:
+ """Return library items for given media_type."""
+ if media_type == MediaType.ARTIST:
+ return await self.get_library_artists()
+ if media_type == MediaType.ALBUM:
+ return await self.get_library_albums()
+ if media_type == MediaType.TRACK:
+ return await self.get_library_tracks()
+ if media_type == MediaType.PLAYLIST:
+ return await self.get_library_playlists()
+ if media_type == MediaType.RADIO:
+ return await self.get_library_radios()
+
+ 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)
+++ /dev/null
-"""Models and helpers for the streamdetails of a MediaItem."""
-
-from dataclasses import dataclass, field
-from enum import Enum
-from typing import Any, Dict, Optional
-
-from mashumaro.serializer.base.dict import DataClassDictMixin
-from music_assistant.models.media_types import MediaType
-
-
-class StreamType(Enum):
- """Enum with stream types."""
-
- EXECUTABLE = "executable"
- URL = "url"
- FILE = "file"
- CACHE = "cache"
-
-
-class ContentType(Enum):
- """Enum with audio content types supported by ffmpeg."""
-
- OGG = "ogg"
- FLAC = "flac"
- MP3 = "mp3"
- AAC = "aac"
- MPEG = "mpeg"
- PCM_S16LE = "s16le" # PCM signed 16-bit little-endian
- PCM_S24LE = "s24le" # PCM signed 24-bit little-endian
- PCM_S32LE = "s32le" # PCM signed 32-bit little-endian
- PCM_F32LE = "f32le" # PCM 32-bit floating-point little-endian
- PCM_F64LE = "f64le" # PCM 64-bit floating-point little-endian
-
- def is_pcm(self):
- """Return if contentype is PCM."""
- return self.name.startswith("PCM")
-
- def sox_supported(self):
- """Return if ContentType is supported by SoX."""
- return self not in [ContentType.AAC, ContentType.MPEG]
-
- def sox_format(self):
- """Convert the ContentType to SoX compatible format."""
- if not self.sox_supported():
- raise NotImplementedError
- return self.value.replace("le", "")
-
-
-@dataclass
-class StreamDetails(DataClassDictMixin):
- """Model for streamdetails."""
-
- type: StreamType
- provider: str
- item_id: str
- path: str
- content_type: ContentType
- player_id: str = ""
- details: Dict[str, Any] = field(default_factory=dict)
- seconds_played: int = 0
- gain_correct: float = 0
- loudness: Optional[float] = None
- sample_rate: Optional[int] = None
- bit_depth: Optional[int] = None
- media_type: MediaType = MediaType.TRACK
-
- def __post_serialize__(self, d: Dict[Any, Any]) -> Dict[Any, Any]:
- """Exclude internal fields from dict."""
- d.pop("path")
- d.pop("details")
- return d
-
- def __str__(self):
- """Return pretty printable string of object."""
- return f"{self.type.value}/{self.content_type.value} - {self.provider}/{self.item_id}"
-"""Providers package."""
+"""Package with Music Providers."""
+++ /dev/null
-"""Builtin player provider."""
-import logging
-import time
-from typing import List
-
-from music_assistant.helpers.util import create_task, run_periodic
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
-from music_assistant.models.provider import PlayerProvider
-
-PROV_ID = "builtin_player"
-PROV_NAME = "Music Assistant"
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = []
-PLAYER_CONFIG_ENTRIES = []
-PLAYER_FEATURES = []
-
-WS_COMMAND_WSPLAYER_CMD = "wsplayer command"
-WS_COMMAND_WSPLAYER_STATE = "wsplayer state"
-WS_COMMAND_WSPLAYER_REGISTER = "wsplayer register"
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = MassPlayerProvider()
- await mass.register_provider(prov)
-
-
-class MassPlayerProvider(PlayerProvider):
- """
- Built-in PlayerProvider.
-
- Provides virtual players in the frontend using websockets.
- """
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return []
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider based on config."""
- # listen for websockets commands to dynamically create players
- create_task(self.check_players())
- # self.mass.web.register_api_route(
- # WS_COMMAND_WSPLAYER_REGISTER, self.handle_ws_player
- # )
- # self.mass.web.register_api_route(WS_COMMAND_WSPLAYER_STATE, self.handle_ws_player)
- return True
-
- async def on_stop(self):
- """Handle correct close/cleanup of the provider on exit."""
- for player in self.players:
- await player.cmd_stop()
-
- async def handle_ws_player(self, player_id: str, details: dict):
- """Handle state message from ws player."""
- player = self.mass.players.get_player(player_id)
- if not player:
- # register new player
- player = WebsocketsPlayer(player_id, details["name"])
- await self.mass.players.add_player(player)
- await player.handle_player(details)
-
- @run_periodic(30)
- async def check_players(self) -> None:
- """Invalidate players that did not send a heartbeat message in a while."""
- cur_time = time.time()
- offline_players = set()
- for player in self.players:
- if not isinstance(player, WebsocketsPlayer):
- continue
- if cur_time - player.last_message > 30:
- offline_players.add(player.player_id)
- for player_id in offline_players:
- await self.mass.players.remove_player(player_id)
-
-
-class WebsocketsPlayer(Player):
- """
- Implementation of a player using pure HTML/javascript.
-
- Used in the front-end.
- Communication is handled through the websocket connection
- and our internal event bus.
- """
-
- def __init__(self, player_id: str, player_name: str):
- """Initialize the wsplayer."""
- self._player_id = player_id
- self._player_name = player_name
- self._powered = True
- self._elapsed_time = 0
- self._state = PlayerState.IDLE
- self._current_uri = ""
- self._volume_level = 100
- self._muted = False
- self._device_info = DeviceInfo()
- self.last_message = time.time()
- super().__init__()
-
- async def handle_player(self, data: dict):
- """Handle state event from player."""
- if "volume_level" in data:
- self._volume_level = data["volume_level"]
- if "muted" in data:
- self._muted = data["muted"]
- if "state" in data:
- self._state = PlayerState(data["state"])
- if "elapsed_time" in data:
- self._elapsed_time = data["elapsed_time"]
- if "current_uri" in data:
- self._current_uri = data["current_uri"]
- if "name" in data:
- self._player_name = data["name"]
- if "device_info" in data:
- for key, value in data["device_info"].items():
- setattr(self._device_info, key, value)
- self.last_message = time.time()
- self.update_state()
-
- @property
- def player_id(self) -> str:
- """Return player id of this player."""
- return self._player_id
-
- @property
- def provider_id(self) -> str:
- """Return provider id of this player."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return name of the player."""
- return self._player_name
-
- @property
- def powered(self) -> bool:
- """Return current power state of player."""
- return self._powered
-
- @property
- def elapsed_time(self) -> int:
- """Return elapsed time of current playing media in seconds."""
- return self._elapsed_time
-
- @property
- def state(self) -> PlayerState:
- """Return current PlayerState of player."""
- return self._state
-
- @property
- def current_uri(self) -> str:
- """Return currently loaded uri of player (if any)."""
- return self._current_uri
-
- @property
- def volume_level(self) -> int:
- """Return current volume level of player (scale 0..100)."""
- return self._volume_level
-
- @property
- def muted(self) -> bool:
- """Return current mute state of player."""
- return self._muted
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return the device info for this player."""
- return self._device_info
-
- @property
- def should_poll(self) -> bool:
- """Return True if this player should be polled for state updates."""
- return False
-
- @property
- def features(self) -> List[PlayerFeature]:
- """Return list of features this player supports."""
- return PLAYER_FEATURES
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return player specific config entries (if any)."""
- return PLAYER_CONFIG_ENTRIES
-
- async def cmd_play_uri(self, uri: str) -> None:
- """
- Play the specified uri/url on the player.
-
- :param uri: uri/url to send to the player.
- """
- data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri}
- self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
-
- async def cmd_stop(self) -> None:
- """Send STOP command to player."""
- data = {"player_id": self.player_id, "cmd": "stop"}
- self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
-
- async def cmd_play(self) -> None:
- """Send PLAY command to player."""
- data = {"player_id": self.player_id, "cmd": "play"}
- self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
-
- async def cmd_pause(self) -> None:
- """Send PAUSE command to player."""
- data = {"player_id": self.player_id, "cmd": "pause"}
- self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
-
- async def cmd_power_on(self) -> None:
- """Send POWER ON command to player."""
- self._powered = True
- self.update_state()
-
- async def cmd_power_off(self) -> None:
- """Send POWER OFF command to player."""
- self._powered = False
- self.update_state()
-
- async def cmd_volume_set(self, volume_level: int) -> None:
- """
- Send volume level command to player.
-
- :param volume_level: volume level to set (0..100).
- """
- data = {
- "player_id": self.player_id,
- "cmd": "volume_set",
- "volume_level": volume_level,
- }
- self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
-
- async def cmd_volume_mute(self, is_muted: bool = False) -> None:
- """
- Send volume MUTE command to given player.
-
- :param is_muted: bool with new mute state.
- """
- data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted}
- self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
+++ /dev/null
-"""ChromeCast playerprovider."""
-
-import logging
-from typing import List, Optional
-
-import pychromecast
-from music_assistant.helpers.util import create_task
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.provider import PlayerProvider
-from pychromecast.controllers.multizone import MultizoneManager
-
-from .const import PROV_ID, PROV_NAME, PROVIDER_CONFIG_ENTRIES
-from .helpers import ChromecastInfo
-from .player import ChromecastPlayer
-
-LOGGER = logging.getLogger(PROV_ID)
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- logging.getLogger("pychromecast").setLevel(logging.WARNING)
- prov = ChromecastProvider()
- await mass.register_provider(prov)
-
-
-class ChromecastProvider(PlayerProvider):
- """Support for ChromeCast Audio PlayerProvider."""
-
- def __init__(self, *args, **kwargs):
- """Initialize."""
- self.mz_mgr = MultizoneManager()
- self._browser: Optional[pychromecast.discovery.CastBrowser] = None
- super().__init__(*args, **kwargs)
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return PROVIDER_CONFIG_ENTRIES
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider based on config."""
- self._browser = pychromecast.discovery.CastBrowser(
- pychromecast.discovery.SimpleCastListener(
- add_callback=self._discover_chromecast,
- remove_callback=self._remove_chromecast,
- update_callback=self._discover_chromecast,
- ),
- self.mass.zeroconf,
- )
- # start discovery in executor
- create_task(self._browser.start_discovery)
- return True
-
- async def on_stop(self):
- """Handle correct close/cleanup of the provider on exit."""
- if not self._browser:
- return
- # stop discovery
- create_task(self._browser.stop_discovery)
-
- def _discover_chromecast(self, uuid, _):
- """Discover a Chromecast."""
- cast_info: pychromecast.models.CastInfo = self._browser.devices[uuid]
-
- info = ChromecastInfo(
- services=cast_info.services,
- uuid=cast_info.uuid,
- model_name=cast_info.model_name,
- friendly_name=cast_info.friendly_name,
- cast_type=cast_info.cast_type,
- manufacturer=cast_info.manufacturer,
- )
-
- if info.uuid is None:
- LOGGER.error("Discovered chromecast without uuid %s", info)
- return
-
- info = info.fill_out_missing_chromecast_info(self.mass.zeroconf)
- if info.is_dynamic_group:
- LOGGER.warning("Discovered dynamic cast group which will be ignored.")
- return
-
- LOGGER.debug("Discovered new or updated chromecast %s", info)
- player_id = str(info.uuid)
- player = self.mass.players.get_player(player_id)
- if not player:
- player = ChromecastPlayer(self.mass, info)
-
- # if player was already added, the player will take care of reconnects itself.
- player.set_cast_info(info)
- create_task(self.mass.players.add_player(player))
-
- @staticmethod
- def _remove_chromecast(uuid, service, cast_info):
- """Handle zeroconf discovery of a removed chromecast."""
- # pylint: disable=unused-argument
- player_id = str(service[1])
- friendly_name = service[3]
- LOGGER.debug("Chromecast removed: %s - %s", friendly_name, player_id)
- # we ignore this event completely as the Chromecast socket client handles this itself
+++ /dev/null
-"""Constants for the implementation."""
-
-PROV_ID = "chromecast"
-PROV_NAME = "Chromecast"
-
-
-PROVIDER_CONFIG_ENTRIES = []
-
-PLAYER_CONFIG_ENTRIES = []
+++ /dev/null
-"""Helpers to deal with Cast devices."""
-from __future__ import annotations
-
-from typing import Optional
-
-import attr
-from pychromecast import dial
-from pychromecast.const import CAST_TYPE_GROUP
-
-DEFAULT_PORT = 8009
-
-
-@attr.s(slots=True, frozen=True)
-class ChromecastInfo:
- """
- Class to hold all data about a chromecast for creating connections.
-
- This also has the same attributes as the mDNS fields by zeroconf.
- """
-
- services: set | None = attr.ib()
- uuid: str = attr.ib(converter=attr.converters.optional(str))
- model_name: str = attr.ib()
- friendly_name: str = attr.ib()
- cast_type: str = attr.ib()
- manufacturer: str = attr.ib()
- is_dynamic_group = attr.ib(type=Optional[bool], default=None)
-
- @property
- def is_audio_group(self) -> bool:
- """Return if the cast is an audio group."""
- return self.cast_type == CAST_TYPE_GROUP
-
- def fill_out_missing_chromecast_info(self, zconf) -> ChromecastInfo:
- """
- Return a new ChromecastInfo object with missing attributes filled in.
-
- Uses blocking HTTP / HTTPS.
- """
- if not self.is_audio_group or self.is_dynamic_group is not None:
- # We have all information, no need to check HTTP API.
- return self
-
- # Fill out missing group information via HTTP API.
- is_dynamic_group = False
- http_group_status = None
- http_group_status = dial.get_multizone_status(
- None,
- services=self.services,
- zconf=zconf,
- )
- if http_group_status is not None:
- is_dynamic_group = any(
- str(g.uuid) == self.uuid for g in http_group_status.dynamic_groups
- )
-
- return ChromecastInfo(
- services=self.services,
- uuid=self.uuid,
- friendly_name=self.friendly_name,
- model_name=self.model_name,
- cast_type=self.cast_type,
- manufacturer=self.manufacturer,
- is_dynamic_group=is_dynamic_group,
- )
-
- def __str__(self):
- """Return pretty printable string for logging."""
- return f"{self.friendly_name} ({self.uuid})"
-
-
-class CastStatusListener:
- """
- Helper class to handle pychromecast status callbacks.
-
- Necessary because a CastDevice entity can create a new socket client
- and therefore callbacks from multiple chromecast connections can
- potentially arrive. This class allows invalidating past chromecast objects.
- """
-
- def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False):
- """Initialize the status listener."""
- self._cast_device = cast_device
- self._uuid = chromecast.uuid
- self._valid = True
- self._mz_mgr = mz_mgr
-
- if cast_device._cast_info.is_audio_group:
- self._mz_mgr.add_multizone(chromecast)
- if mz_only:
- return
-
- chromecast.register_status_listener(self)
- chromecast.socket_client.media_controller.register_status_listener(self)
- chromecast.register_connection_listener(self)
- if not cast_device._cast_info.is_audio_group:
- self._mz_mgr.register_listener(chromecast.uuid, self)
-
- def new_cast_status(self, cast_status):
- """Handle reception of a new CastStatus."""
- if self._valid:
- self._cast_device.new_cast_status(cast_status)
-
- def new_media_status(self, media_status):
- """Handle reception of a new MediaStatus."""
- if self._valid:
- self._cast_device.new_media_status(media_status)
-
- def new_connection_status(self, connection_status):
- """Handle reception of a new ConnectionStatus."""
- if self._valid:
- self._cast_device.new_connection_status(connection_status)
-
- @staticmethod
- def added_to_multizone(group_uuid):
- """Handle the cast added to a group."""
-
- def removed_from_multizone(self, group_uuid):
- """Handle the cast removed from a group."""
- if self._valid:
- self._cast_device.multizone_new_media_status(group_uuid, None)
-
- def multizone_new_cast_status(self, group_uuid, cast_status):
- """Handle reception of a new CastStatus for a group."""
-
- def multizone_new_media_status(self, group_uuid, media_status):
- """Handle reception of a new MediaStatus for a group."""
- if self._valid:
- self._cast_device.multizone_new_media_status(group_uuid, media_status)
-
- def invalidate(self):
- """
- Invalidate this status listener.
-
- All following callbacks won't be forwarded.
- """
- # pylint: disable=protected-access
- if self._cast_device._cast_info.is_audio_group:
- self._mz_mgr.remove_multizone(self._uuid)
- else:
- self._mz_mgr.deregister_listener(self._uuid, self)
- self._valid = False
+++ /dev/null
-"""Representation of a Cast device on the network."""
-import asyncio
-import logging
-from typing import List, Optional
-
-import pychromecast
-from music_assistant.helpers.compare import compare_strings
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task, yield_chunks
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
-from music_assistant.models.player_queue import QueueItem
-from pychromecast.controllers.multizone import MultizoneController, MultizoneManager
-from pychromecast.socket_client import (
- CONNECTION_STATUS_CONNECTED,
- CONNECTION_STATUS_DISCONNECTED,
-)
-
-from .const import PLAYER_CONFIG_ENTRIES, PROV_ID
-from .helpers import CastStatusListener, ChromecastInfo
-
-LOGGER = logging.getLogger(PROV_ID)
-PLAYER_FEATURES = [PlayerFeature.QUEUE]
-
-
-class ChromecastPlayer(Player):
- """Representation of a Cast device on the network.
-
- This class is the holder of the pychromecast.Chromecast object and
- handles all reconnects and audio group changing
- "elected leader" itself.
- """
-
- def __init__(self, mass: MusicAssistant, cast_info: ChromecastInfo) -> None:
- """Initialize the cast device."""
- super().__init__()
- self.mass = mass
- self._cast_info = cast_info
- self._player_id = cast_info.uuid
-
- self._chromecast: Optional[pychromecast.Chromecast] = None
- self.cast_status = None
- self.media_status = None
- self.media_status_received = None
- self.mz_mgr: Optional[MultizoneManager] = None
- self._available = False
- self._status_listener: Optional[CastStatusListener] = None
- self._is_speaker_group = False
- self._command_busy = False
-
- @property
- def player_id(self) -> str:
- """Return player id of this player."""
- return self._player_id
-
- @property
- def provider_id(self) -> str:
- """Return provider id of this player."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return name of this player."""
- return self._cast_info.friendly_name
-
- @property
- def powered(self) -> bool:
- """Return power state of this player."""
- if not self._chromecast or not self.cast_status:
- return False
- if self.is_group_player:
- return (
- self._chromecast.media_controller.is_active
- and self.cast_status.app_id == pychromecast.APP_MEDIA_RECEIVER
- )
-
- # Chromecast does not support power so we (ab)use mute instead
- if self.cast_status.app_id is None:
- return not self.cast_status.volume_muted
- return (
- self.cast_status.app_id in ["705D30C6", pychromecast.APP_MEDIA_RECEIVER]
- and not self.cast_status.volume_muted
- )
-
- @property
- def should_poll(self) -> bool:
- """Return bool if this player needs to be polled for state changes."""
- return self.media_status and self.media_status.player_is_playing
-
- @property
- def state(self) -> PlayerState:
- """Return the state of the player."""
- if self.media_status is None:
- return PlayerState.IDLE
- if self.media_status.player_is_playing:
- return PlayerState.PLAYING
- if self.media_status.player_is_paused:
- return PlayerState.PAUSED
- if self.media_status.player_is_idle:
- return PlayerState.IDLE
- return PlayerState.IDLE
-
- @property
- def elapsed_time(self) -> int:
- """Return position of current playing media in seconds."""
- if self.media_status is None or not (
- self.media_status.player_is_playing
- or self.media_status.player_is_paused
- or self.media_status.player_is_idle
- ):
- return 0
- if self.media_status.player_is_playing:
- # Add time since last update
- return self.media_status.adjusted_current_time
- # Not playing, return last reported seek time
- return self.media_status.current_time
-
- @property
- def available(self) -> bool:
- """Return availablity state of this player."""
- return self._available
-
- @property
- def current_uri(self) -> str:
- """Return current_uri of this player."""
- return self.media_status.content_id if self.media_status else None
-
- @property
- def volume_level(self) -> int:
- """Return volume_level of this player."""
- return self.cast_status.volume_level * 100 if self.cast_status else 0
-
- @property
- def muted(self) -> bool:
- """Return mute state of this player."""
- return self.cast_status.volume_muted if self.cast_status else False
-
- @property
- def is_group_player(self) -> bool:
- """Return if this player is a group player."""
- return self._cast_info.is_audio_group and not self._is_speaker_group
-
- @property
- def group_childs(self) -> List[str]:
- """Return group_childs."""
- if (
- self._cast_info.is_audio_group
- and self._chromecast
- and not self._is_speaker_group
- ):
- return self._chromecast.mz_controller.members
- return []
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return deviceinfo."""
- return DeviceInfo(
- model=self._cast_info.model_name,
- address=f"{self._chromecast.uri}" if self._chromecast else "",
- manufacturer=self._cast_info.manufacturer,
- )
-
- @property
- def features(self) -> List[PlayerFeature]:
- """Return list of features this player supports."""
- return PLAYER_FEATURES
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return player specific config entries (if any)."""
- return PLAYER_CONFIG_ENTRIES
-
- async def on_add(self) -> None:
- """Call when player is added to the player manager."""
- chromecast = await self.mass.loop.run_in_executor(
- None,
- pychromecast.get_chromecast_from_cast_info,
- pychromecast.discovery.CastInfo(
- self._cast_info.services,
- self._cast_info.uuid,
- self._cast_info.model_name,
- self._cast_info.friendly_name,
- None,
- None,
- self._cast_info.cast_type,
- self._cast_info.manufacturer,
- ),
- self.mass.zeroconf,
- )
- self._chromecast = chromecast
- self.mz_mgr: MultizoneManager = self.mass.get_provider(PROV_ID).mz_mgr
- self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr)
- self._available = False
- self.cast_status = chromecast.status
- self.media_status = chromecast.media_controller.status
- if self._cast_info.is_audio_group:
- mz_controller = MultizoneController(chromecast.uuid)
- chromecast.register_handler(mz_controller)
- chromecast.mz_controller = mz_controller
- self._chromecast.start()
-
- def set_cast_info(self, cast_info: ChromecastInfo) -> None:
- """Set (or update) the cast discovery info."""
- self._cast_info = cast_info
-
- async def disconnect(self):
- """Disconnect Chromecast object if it is set."""
- if self._chromecast is None:
- # Can't disconnect if not connected.
- return
- LOGGER.debug(
- "[%s %s] Disconnecting from chromecast socket",
- self.player_id,
- self._cast_info.friendly_name,
- )
- self._available = False
- self.update_state()
-
- await self.mass.loop.run_in_executor(None, self._chromecast.disconnect)
-
- self._invalidate()
- self.update_state()
-
- def _invalidate(self) -> None:
- """Invalidate some attributes."""
- self._chromecast = None
- self.cast_status = None
- self.media_status = None
- self.media_status_received = None
- self.mz_mgr = None
- if self._status_listener is not None:
- self._status_listener.invalidate()
- self._status_listener = None
-
- async def on_remove(self) -> None:
- """Call when player is removed from the player manager."""
- await self.disconnect()
-
- # ========== Callbacks ==========
-
- def new_cast_status(self, cast_status) -> None:
- """Handle updates of the cast status."""
- self.cast_status = cast_status
- self._is_speaker_group = (
- self._cast_info.is_audio_group
- and self._chromecast.mz_controller
- and self._chromecast.mz_controller.members
- and compare_strings(
- self._chromecast.mz_controller.members[0], self.player_id
- )
- )
- self.update_state()
-
- def new_media_status(self, media_status) -> None:
- """Handle updates of the media status."""
- self.media_status = media_status
- self.update_state()
-
- def new_connection_status(self, connection_status) -> None:
- """Handle updates of connection status."""
- if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
- self._available = False
- self._invalidate()
- self.update_state()
- return
-
- new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
- if new_available != self._available:
- # Connection status callbacks happen often when disconnected.
- # Only update state when availability changed to put less pressure
- # on state machine.
- LOGGER.debug(
- "[%s] Cast device availability changed: %s",
- self._cast_info.friendly_name,
- connection_status.status,
- )
- self._available = new_available
- self.update_state()
- if self._cast_info.is_audio_group and new_available:
- create_task(self._chromecast.mz_controller.update_members)
-
- # ========== Service Calls ==========
-
- async def cmd_stop(self) -> None:
- """Send stop command to player."""
- if self._chromecast.media_controller:
- await self.chromecast_command(self._chromecast.media_controller.stop)
-
- async def cmd_play(self) -> None:
- """Send play command to player."""
- if self._chromecast.media_controller:
- await self.chromecast_command(self._chromecast.media_controller.play)
-
- async def cmd_pause(self) -> None:
- """Send pause command to player."""
- if self._chromecast.media_controller:
- await self.chromecast_command(self._chromecast.media_controller.pause)
-
- async def cmd_next(self) -> None:
- """Send next track command to player."""
- if self._chromecast.media_controller:
- await self.chromecast_command(self._chromecast.media_controller.queue_next)
-
- async def cmd_previous(self) -> None:
- """Send previous track command to player."""
- if self._chromecast.media_controller:
- await self.chromecast_command(self._chromecast.media_controller.queue_prev)
-
- async def cmd_power_on(self) -> None:
- """Send power ON command to player."""
- if self.is_group_player:
- await self.launch_app()
- else:
- # chromecast has no real poweroff so we (ab)use mute instead
- await self.chromecast_command(self._chromecast.set_volume_muted, False)
-
- async def cmd_power_off(self) -> None:
- """Send power OFF command to player."""
- if self.is_group_player or (
- self._chromecast.media_controller.is_active
- and self.cast_status.app_id == self._chromecast.media_controller.app_id
- ):
- await self.chromecast_command(self._chromecast.quit_app)
- if not self.is_group_player:
- # chromecast has no real poweroff so we (ab)use mute instead
- await self.chromecast_command(self._chromecast.set_volume_muted, True)
-
- async def cmd_volume_set(self, volume_level: int) -> None:
- """Send new volume level command to player."""
- await self.chromecast_command(self._chromecast.set_volume, volume_level / 100)
-
- async def cmd_volume_mute(self, is_muted: bool = False) -> None:
- """Send mute command to player."""
- await self.chromecast_command(self._chromecast.set_volume_muted, is_muted)
-
- async def cmd_play_uri(self, uri: str) -> None:
- """Play single uri on player."""
- # create (fake) CC queue so that skip and previous will work
- queue_item = QueueItem(
- item_id=uri, provider="mass", name="Music Assistant", stream_url=uri
- )
- await self.cmd_queue_load([queue_item, queue_item], True)
-
- async def cmd_queue_load(
- self, queue_items: List[QueueItem], repeat: bool = False
- ) -> None:
- """Load (overwrite) queue with new items."""
- cc_queue_items = self.__create_queue_items(queue_items[:25])
- queuedata = {
- "type": "QUEUE_LOAD",
- "repeatMode": "REPEAT_ALL" if repeat else "REPEAT_OFF",
- "shuffle": False, # handled by our queue controller
- "queueType": "PLAYLIST",
- "startIndex": 0, # Item index to play after this request or keep same item if undefined
- "items": cc_queue_items, # only load 25 tracks at once or the socket will crash
- }
- await self.launch_app()
- await self.chromecast_command(self.__send_player_queue, queuedata)
- if len(queue_items) > 25:
- await asyncio.sleep(5)
- await self.cmd_queue_append(queue_items[26:])
-
- async def cmd_queue_append(self, queue_items: List[QueueItem]) -> None:
- """Append new items at the end of the queue."""
- cc_queue_items = self.__create_queue_items(queue_items)
- async for chunk in yield_chunks(cc_queue_items, 25):
- queuedata = {
- "type": "QUEUE_INSERT",
- "insertBefore": None,
- "items": chunk,
- }
- await self.chromecast_command(self.__send_player_queue, queuedata)
- await asyncio.sleep(2)
-
- def __create_queue_items(self, tracks) -> None:
- """Create list of CC queue items from tracks."""
- return [self.__create_queue_item(track) for track in tracks]
-
- @staticmethod
- def __create_queue_item(queue_item: QueueItem):
- """Create CC queue item from track info."""
- return {
- "opt_itemId": queue_item.queue_item_id,
- "autoplay": True,
- "preloadTime": 0,
- "playbackDuration": int(queue_item.duration),
- "startTime": 0,
- "activeTrackIds": [],
- "media": {
- "contentId": queue_item.stream_url,
- "customData": {
- "provider": queue_item.provider,
- "uri": queue_item.stream_url,
- "item_id": queue_item.queue_item_id,
- },
- "contentType": "audio/flac",
- "streamType": pychromecast.STREAM_TYPE_BUFFERED,
- "metadata": {
- "title": queue_item.name,
- "artist": "/".join(x.name for x in queue_item.artists),
- },
- "duration": int(queue_item.duration),
- },
- }
-
- def __send_player_queue(self, queuedata: dict) -> None:
- """Send new data to the CC queue."""
- media_controller = self._chromecast.media_controller
- queuedata["mediaSessionId"] = media_controller.status.media_session_id
- media_controller.send_message(queuedata, False)
-
- async def launch_app(self):
- """Launch the default media receiver app and wait until its launched."""
- media_controller = self._chromecast.media_controller
- event = asyncio.Event()
-
- def launched_callback():
- self.mass.loop.call_soon_threadsafe(event.set)
-
- # pylint: disable=protected-access
- receiver_ctrl = media_controller._socket_client.receiver_controller
- await self.mass.loop.run_in_executor(
- None,
- receiver_ctrl.launch_app,
- media_controller.app_id,
- False,
- launched_callback,
- )
- await event.wait()
-
- async def chromecast_command(self, func, *args):
- """Execute command on Chromecast."""
- if not self.available:
- LOGGER.warning(
- "Player %s is not available, command can't be executed", self.name
- )
- return
- while self._command_busy:
- await asyncio.sleep(0.05)
- try:
- # Sending multiple commands at the same time to the cast socket
- # will make things unstable, make sure to throttle it.
- self._command_busy = True
- await self.mass.loop.run_in_executor(None, func, *args)
- finally:
- self._command_busy = False
+++ /dev/null
-"""FanartTv Metadata provider."""
-
-import logging
-from json.decoder import JSONDecodeError
-from typing import Dict, List
-
-import aiohttp
-from asyncio_throttle import Throttler
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.provider import MetadataProvider
-
-# TODO: add support for personal api keys ?
-# TODO: Add support for album artwork ?
-
-PROV_ID = "fanarttv"
-PROV_NAME = "Fanart.tv"
-
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = []
-
-
-async def setup(mass) -> None:
- """Perform async setup of this Plugin/Provider."""
- prov = FanartTvProvider(mass)
- await mass.register_provider(prov)
-
-
-class FanartTvProvider(MetadataProvider):
- """Fanart.tv metadata provider."""
-
- def __init__(self, mass):
- """Initialize class."""
- self.mass = mass
- self.throttler = Throttler(rate_limit=1, period=2)
-
- async def on_start(self) -> bool:
- """
- Handle initialization of the provider based on config.
-
- Return bool if start was succesfull. Called on startup.
- """
- return True # we have nothing to initialize
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- async def get_artist_images(self, mb_artist_id: str) -> Dict:
- """Retrieve images by musicbrainz artist id."""
- metadata = {}
- data = await self._get_data("music/%s" % mb_artist_id)
- if data:
- if data.get("hdmusiclogo"):
- metadata["logo"] = data["hdmusiclogo"][0]["url"]
- elif data.get("musiclogo"):
- metadata["logo"] = data["musiclogo"][0]["url"]
- if data.get("artistbackground"):
- count = 0
- for item in data["artistbackground"]:
- key = "fanart" if count == 0 else "fanart.%s" % count
- metadata[key] = item["url"]
- if data.get("artistthumb"):
- url = data["artistthumb"][0]["url"]
- if "2a96cbd8b46e442fc41c2b86b821562f" not in url:
- metadata["image"] = url
- if data.get("musicbanner"):
- metadata["banner"] = data["musicbanner"][0]["url"]
- return metadata
-
- async def _get_data(self, endpoint, params=None):
- """Get data from api."""
- if params is None:
- params = {}
- url = "http://webservice.fanart.tv/v3/%s" % endpoint
- params["api_key"] = "639191cb0774661597f28a47e7e2bad5"
- async with self.throttler:
- async with self.mass.http_session.get(
- url, params=params, verify_ssl=False
- ) as response:
- try:
- result = await response.json()
- except (
- aiohttp.client_exceptions.ContentTypeError,
- JSONDecodeError,
- ):
- LOGGER.error("Failed to retrieve %s", endpoint)
- text_result = await response.text()
- LOGGER.debug(text_result)
- return None
- except aiohttp.client_exceptions.ClientConnectorError:
- LOGGER.error("Failed to retrieve %s", endpoint)
- return None
- if "error" in result and "limit" in result["error"]:
- LOGGER.error(result["error"])
- return None
- return result
+++ /dev/null
-"""Filesystem musicprovider support for MusicAssistant."""
-import base64
-import logging
-import os
-import re
-from typing import List, Optional
-
-import taglib
-from music_assistant.helpers.util import parse_title_and_version
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import (
- Album,
- Artist,
- MediaItemProviderId,
- MediaType,
- Playlist,
- SearchResult,
- Track,
- TrackQuality,
-)
-from music_assistant.models.provider import MusicProvider
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-PROV_ID = "file"
-PROV_NAME = "Local files and playlists"
-
-LOGGER = logging.getLogger(PROV_ID)
-
-CONF_MUSIC_DIR = "music_dir"
-CONF_PLAYLISTS_DIR = "playlists_dir"
-
-CONFIG_ENTRIES = [
- ConfigEntry(
- entry_key=CONF_MUSIC_DIR,
- entry_type=ConfigEntryType.STRING,
- label="file_prov_music_path",
- description="file_prov_music_path_desc",
- ),
- ConfigEntry(
- entry_key=CONF_PLAYLISTS_DIR,
- entry_type=ConfigEntryType.STRING,
- label="file_prov_playlists_path",
- description="file_prov_playlists_path_desc",
- ),
-]
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = FileProvider()
- await mass.register_provider(prov)
-
-
-class FileProvider(MusicProvider):
- """
- Very basic implementation of a musicprovider for local files.
-
- Assumes files are stored on disk in format <artist>/<album>/<track.ext>
- Reads ID3 tags from file and falls back to parsing filename
- Supports m3u files only for playlists
- Supports having URI's from streaming providers within m3u playlist
- Should be compatible with LMS
- """
-
- # pylint chokes on taglib so ignore these
- # pylint: disable=unsubscriptable-object,unsupported-membership-test
-
- _music_dir = None
- _playlists_dir = None
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- @property
- def supported_mediatypes(self) -> List[MediaType]:
- """Return MediaTypes the provider supports."""
- return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK]
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider based on config."""
- conf = self.mass.config.get_provider_config(self.id)
- if not conf[CONF_MUSIC_DIR]:
- return False
- if not os.path.isdir(conf[CONF_MUSIC_DIR]):
- raise FileNotFoundError(f"Directory {conf[CONF_MUSIC_DIR]} does not exist")
- self._music_dir = conf["music_dir"]
- if os.path.isdir(conf[CONF_PLAYLISTS_DIR]):
- self._playlists_dir = conf[CONF_PLAYLISTS_DIR]
- else:
- self._playlists_dir = None
-
- async def on_stop(self):
- """Handle correct close/cleanup of the provider on exit."""
- # nothing to be done
-
- async def search(
- self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
- ) -> SearchResult:
- """
- 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 = SearchResult()
- # TODO !
- return result
-
- async def get_library_artists(self) -> List[Artist]:
- """Retrieve all library artists."""
- if not os.path.isdir(self._music_dir):
- LOGGER.error("music path does not exist: %s", self._music_dir)
- return None
- result = []
- for dirname in os.listdir(self._music_dir):
- dirpath = os.path.join(self._music_dir, dirname)
- if os.path.isdir(dirpath) and not dirpath.startswith("."):
- artist = await self.get_artist(dirpath)
- if artist:
- result.append(artist)
- return result
-
- async def get_library_albums(self) -> List[Album]:
- """Get album folders recursively."""
- result = []
- for artist in await self.get_library_artists():
- for album in await self.get_artist_albums(artist.item_id):
- result.append(album)
- return result
-
- async def get_library_tracks(self) -> List[Track]:
- """Get all tracks recursively."""
- # TODO: support disk subfolders
- result = []
- for album in await self.get_library_albums():
- for track in await self.get_album_tracks(album.item_id):
- result.append(track)
- return result
-
- async def get_library_playlists(self) -> List[Playlist]:
- """Retrieve playlists from disk."""
- if not self._playlists_dir:
- return []
- result = []
- for filename in os.listdir(self._playlists_dir):
- filepath = os.path.join(self._playlists_dir, filename)
- if (
- os.path.isfile(filepath)
- and not filename.startswith(".")
- and filename.lower().endswith(".m3u")
- ):
- playlist = await self.get_playlist(filepath)
- if playlist:
- result.append(playlist)
- return result
-
- async def get_artist(self, prov_artist_id: str) -> Artist:
- """Get full artist details by id."""
- if os.sep not in prov_artist_id:
- itempath = base64.b64decode(prov_artist_id).decode("utf-8")
- else:
- itempath = prov_artist_id
- prov_artist_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
- if not os.path.isdir(itempath):
- LOGGER.error("Artist path does not exist: %s", itempath)
- return None
- name = itempath.split(os.sep)[-1]
- artist = Artist(item_id=prov_artist_id, provider=PROV_ID, name=name)
- artist.provider_ids.add(
- MediaItemProviderId(provider=PROV_ID, item_id=artist.item_id)
- )
- return artist
-
- async def get_album(self, prov_album_id: str) -> Album:
- """Get full album details by id."""
- if os.sep not in prov_album_id:
- itempath = base64.b64decode(prov_album_id).decode("utf-8")
- else:
- itempath = prov_album_id
- prov_album_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
- if not os.path.isdir(itempath):
- LOGGER.error("album path does not exist: %s", itempath)
- return None
- name = itempath.split(os.sep)[-1]
- artistpath = itempath.rsplit(os.sep, 1)[0]
- album = Album(item_id=prov_album_id, provider=PROV_ID)
- album.name, album.version = parse_title_and_version(name)
- album.artist = await self.get_artist(artistpath)
- if not album.artist:
- raise Exception("No album artist ! %s" % artistpath)
- album.provider_ids.add(
- MediaItemProviderId(provider=PROV_ID, item_id=prov_album_id)
- )
- return album
-
- async def get_track(self, prov_track_id: str) -> Track:
- """Get full track details by id."""
- if os.sep not in prov_track_id:
- itempath = base64.b64decode(prov_track_id).decode("utf-8")
- else:
- itempath = prov_track_id
- if not os.path.isfile(itempath):
- LOGGER.error("track path does not exist: %s", itempath)
- return None
- return await self._parse_track(itempath)
-
- async def get_playlist(self, prov_playlist_id: str) -> Playlist:
- """Get full playlist details by id."""
- if os.sep not in prov_playlist_id:
- itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
- else:
- itempath = prov_playlist_id
- prov_playlist_id = base64.b64encode(itempath.encode("utf-8")).decode(
- "utf-8"
- )
- if not os.path.isfile(itempath):
- LOGGER.error("playlist path does not exist: %s", itempath)
- return None
- playlist = Playlist()
- playlist.item_id = prov_playlist_id
- playlist.provider = PROV_ID
- playlist.name = itempath.split(os.sep)[-1].replace(".m3u", "")
- playlist.is_editable = True
- playlist.provider_ids.add(
- MediaItemProviderId(provider=PROV_ID, item_id=prov_playlist_id)
- )
- playlist.owner = "disk"
- playlist.checksum = os.path.getmtime(itempath)
- return playlist
-
- async def get_album_tracks(self, prov_album_id) -> List[Track]:
- """Get album tracks for given album id."""
- result = []
- if os.sep not in prov_album_id:
- albumpath = base64.b64decode(prov_album_id).decode("utf-8")
- else:
- albumpath = prov_album_id
- if not os.path.isdir(albumpath):
- LOGGER.error("album path does not exist: %s", albumpath)
- return []
- album = await self.get_album(albumpath)
- for filename in os.listdir(albumpath):
- filepath = os.path.join(albumpath, filename)
- if os.path.isfile(filepath) and not filepath.startswith("."):
- track = await self._parse_track(filepath)
- if track:
- track.album = album
- 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 = []
- if os.sep not in prov_playlist_id:
- itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
- else:
- itempath = prov_playlist_id
- if not os.path.isfile(itempath):
- LOGGER.error("playlist path does not exist: %s", itempath)
- return result
- index = 0
- with open(itempath) as _file:
- for line in _file.readlines():
- line = line.strip()
- if line and not line.startswith("#"):
- track = await self._parse_track_from_uri(line)
- if track:
- result.append(track)
- index += 1
- return result
-
- async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
- """Get a list of albums for the given artist."""
- result = []
- if os.sep not in prov_artist_id:
- artistpath = base64.b64decode(prov_artist_id).decode("utf-8")
- else:
- artistpath = prov_artist_id
- if not os.path.isdir(artistpath):
- LOGGER.error("artist path does not exist: %s", artistpath)
- return
- for dirname in os.listdir(artistpath):
- dirpath = os.path.join(artistpath, dirname)
- if os.path.isdir(dirpath) and not dirpath.startswith("."):
- album = await self.get_album(dirpath)
- if album:
- result.append(album)
- return result
-
- async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
- """Get a list of random tracks as we have no clue about preference."""
- result = []
- for album in await self.get_artist_albums(prov_artist_id):
- for track in await self.get_album_tracks(album.item_id):
- result.append(track)
- return result
-
- async def get_stream_details(self, item_id: str) -> StreamDetails:
- """Return the content details for the given track when it will be streamed."""
- if os.sep not in item_id:
- track_id = base64.b64decode(item_id).decode("utf-8")
- if not os.path.isfile(track_id):
- return None
- # TODO: retrieve sanple rate and bitdepth
- return StreamDetails(
- type=StreamType.FILE,
- provider=PROV_ID,
- item_id=item_id,
- content_type=ContentType(track_id.split(".")[-1]),
- path=track_id,
- sample_rate=44100,
- bit_depth=16,
- )
-
- async def _parse_track(self, filename):
- """Try to parse a track from a filename with taglib."""
- # pylint: disable=broad-except
- try:
- song = taglib.File(filename)
- except Exception:
- return None # not a media file ?
- prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8")
- track = Track(item_id=prov_item_id, provider=PROV_ID)
- track.duration = song.length
- try:
- name = song.tags["TITLE"][0]
- except KeyError:
- name = filename.split("/")[-1].split(".")[0]
- track.name, track.version = parse_title_and_version(name)
- albumpath = filename.rsplit(os.sep, 1)[0]
- track.album = await self.get_album(albumpath)
- if "ARTIST" in song.tags:
- artists = set()
- for artist_str in song.tags["ARTIST"]:
- local_artist_path = os.path.join(self._music_dir, artist_str)
- if os.path.isfile(local_artist_path):
- artist = await self.get_artist(local_artist_path)
- else:
- fake_artistpath = os.path.join(self._music_dir, artist_str)
- artist = Artist(
- item_id=fake_artistpath, provider=PROV_ID, name=artist_str
- )
- artist.provider_ids.add(
- MediaItemProviderId(
- provider=PROV_ID,
- item_id=base64.b64encode(
- fake_artistpath.encode("utf-8")
- ).decode("utf-8"),
- )
- )
- artists.add(artist)
- track.artists = artists
- else:
- artistpath = filename.rsplit(os.sep, 2)[0]
- artist = await self.get_artist(artistpath)
- track.artists.add(artist)
- if "GENRE" in song.tags:
- track.metadata["genres"] = song.tags["GENRE"]
- if "ISRC" in song.tags and song.tags["ISRC"]:
- track.isrc = song.tags["ISRC"][0]
- if "DISCNUMBER" in song.tags and song.tags["DISCNUMBER"]:
- regexp_numbers = re.findall(r"\d+", song.tags["DISCNUMBER"][0])
- track.disc_number = int(regexp_numbers[0] if regexp_numbers else "0")
- if "TRACKNUMBER" in song.tags and song.tags["TRACKNUMBER"]:
- regexp_numbers = re.findall(r"\d+", song.tags["TRACKNUMBER"][0])
- track.track_number = int(regexp_numbers[0] if regexp_numbers else "0")
- quality_details = ""
- if filename.endswith(".flac"):
- # TODO: get bit depth
- quality = TrackQuality.FLAC_LOSSLESS
- if song.sampleRate > 192000:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
- elif song.sampleRate > 96000:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
- elif song.sampleRate > 48000:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
- quality_details = "%s Khz" % (song.sampleRate / 1000)
- elif filename.endswith(".ogg"):
- quality = TrackQuality.LOSSY_OGG
- quality_details = "%s kbps" % (song.bitrate)
- elif filename.endswith(".m4a"):
- quality = TrackQuality.LOSSY_AAC
- quality_details = "%s kbps" % (song.bitrate)
- else:
- quality = TrackQuality.LOSSY_MP3
- quality_details = "%s kbps" % (song.bitrate)
- track.provider_ids.add(
- MediaItemProviderId(
- provider=PROV_ID,
- item_id=prov_item_id,
- quality=quality,
- details=quality_details,
- )
- )
- return track
-
- async def _parse_track_from_uri(self, uri):
- """Try to parse a track from an uri found in playlist."""
- # pylint: disable=broad-except
- if "://" in uri:
- # track is uri from external provider?
- prov_id = uri.split("://")[0]
- prov_item_id = uri.split("/")[-1].split(".")[0].split(":")[-1]
- try:
- return await self.mass.music.get_track(prov_item_id, prov_id)
- except Exception as exc:
- LOGGER.warning("Could not parse uri %s to track: %s", uri, str(exc))
- return None
- # try to treat uri as filename
- # TODO: filename could be related to musicdir or full path
- track = await self.get_track(uri)
- if track:
- return track
- track = await self.get_track(os.path.join(self._music_dir, uri))
- if track:
- return track
- return None
--- /dev/null
+"""Filesystem musicprovider support for MusicAssistant."""
+import base64
+import os
+import re
+from typing import List, Optional
+import aiofiles
+
+import taglib
+
+from music_assistant.models.errors import InvalidDataError
+from music_assistant.helpers.util import parse_title_and_version
+from music_assistant.models.media_items import (
+ Album,
+ Artist,
+ ContentType,
+ MediaItemProviderId,
+ MediaItemType,
+ MediaQuality,
+ MediaType,
+ Playlist,
+ StreamDetails,
+ StreamType,
+ Track,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class FileSystemProvider(MusicProvider):
+ """
+ Very basic implementation of a musicprovider for local files.
+
+ Assumes files are stored on disk in format <artist>/<album>/<track.ext>
+ Reads ID3 tags from file and falls back to parsing filename
+ Supports m3u files only for playlists
+ Supports having URI's from streaming providers within m3u playlist
+ Should be compatible with LMS
+ """
+
+ # pylint chokes on taglib so ignore these
+ # pylint: disable=unsubscriptable-object,unsupported-membership-test
+
+ def __init__(self, music_dir: str, playlist_dir: str | None = None) -> None:
+ """
+ Initialize the Filesystem provider.
+
+ music_dir: Directory on disk containing music files
+ playlist_dir: Directory on disk containing playlist files (optional)
+
+ """
+ self._attr_id = "filesystem"
+ self._attr_name = "Filesystem"
+ self._playlists_dir = playlist_dir
+ self._music_dir = music_dir
+ self._attr_supported_mediatypes = [
+ MediaType.ARTIST,
+ MediaType.ALBUM,
+ MediaType.TRACK,
+ ]
+ if playlist_dir is not None:
+ self._attr_supported_mediatypes.append(MediaType.PLAYLIST)
+
+ async def setup(self) -> None:
+ """Handle async initialization of the provider."""
+ if not os.path.isdir(self._music_dir):
+ raise FileNotFoundError(f"Music Directory {self._music_dir} does not exist")
+ if self._playlists_dir is not None and not os.path.isdir(self._playlists_dir):
+ raise FileNotFoundError(
+ f"Playlist Directory {self._playlists_dir} does not exist"
+ )
+
+ 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).
+ """
+ # TODO !
+ return []
+
+ async def get_library_artists(self) -> List[Artist]:
+ """Retrieve all library artists."""
+ if not os.path.isdir(self._music_dir):
+ self.logger.error("music path does not exist: %s", self._music_dir)
+ return None
+ result = []
+ for dirname in os.listdir(self._music_dir):
+ dirpath = os.path.join(self._music_dir, dirname)
+ if os.path.isdir(dirpath) and not dirpath.startswith("."):
+ artist = await self.get_artist(dirpath)
+ if artist:
+ result.append(artist)
+ return result
+
+ async def get_library_albums(self) -> List[Album]:
+ """Get album folders recursively."""
+ result = []
+ for artist in await self.get_library_artists():
+ for album in await self.get_artist_albums(artist.item_id):
+ result.append(album)
+ return result
+
+ async def get_library_tracks(self) -> List[Track]:
+ """Get all tracks recursively."""
+ # TODO: support disk subfolders
+ result = []
+ for album in await self.get_library_albums():
+ for track in await self.get_album_tracks(album.item_id):
+ result.append(track)
+ return result
+
+ async def get_library_playlists(self) -> List[Playlist]:
+ """Retrieve playlists from disk."""
+ if not self._playlists_dir:
+ return []
+ result = []
+ for filename in os.listdir(self._playlists_dir):
+ filepath = os.path.join(self._playlists_dir, filename)
+ if (
+ os.path.isfile(filepath)
+ and not filename.startswith(".")
+ and filename.lower().endswith(".m3u")
+ ):
+ playlist = await self.get_playlist(filepath)
+ if playlist:
+ result.append(playlist)
+ return result
+
+ async def get_artist(self, prov_artist_id: str) -> Artist:
+ """Get full artist details by id."""
+ if os.sep not in prov_artist_id:
+ itempath = base64.b64decode(prov_artist_id).decode("utf-8")
+ else:
+ itempath = prov_artist_id
+ prov_artist_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
+ if not os.path.isdir(itempath):
+ self.logger.error("Artist path does not exist: %s", itempath)
+ return None
+ name = itempath.split(os.sep)[-1]
+ artist = Artist(item_id=prov_artist_id, provider=self.id, name=name)
+ artist.provider_ids.append(
+ MediaItemProviderId(provider=self.id, item_id=artist.item_id)
+ )
+ return artist
+
+ async def get_album(self, prov_album_id: str) -> Album:
+ """Get full album details by id."""
+ if os.sep not in prov_album_id:
+ itempath = base64.b64decode(prov_album_id).decode("utf-8")
+ else:
+ itempath = prov_album_id
+ prov_album_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
+ if not os.path.isdir(itempath):
+ self.logger.error("album path does not exist: %s", itempath)
+ return None
+ name = itempath.split(os.sep)[-1]
+ artistpath = itempath.rsplit(os.sep, 1)[0]
+ name, version = parse_title_and_version(name)
+ album = Album(
+ item_id=prov_album_id, provider=self.id, name=name, version=version
+ )
+ album.artist = await self.get_artist(artistpath)
+ if not album.artist:
+ raise InvalidDataError(f"No album artist ! {artistpath}")
+ album.provider_ids.append(
+ MediaItemProviderId(provider=self.id, item_id=prov_album_id)
+ )
+ return album
+
+ async def get_track(self, prov_track_id: str) -> Track:
+ """Get full track details by id."""
+ if os.sep not in prov_track_id:
+ itempath = base64.b64decode(prov_track_id).decode("utf-8")
+ else:
+ itempath = prov_track_id
+ if not os.path.isfile(itempath):
+ self.logger.error("track path does not exist: %s", itempath)
+ return None
+ return await self._parse_track(itempath)
+
+ async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+ """Get full playlist details by id."""
+ if os.sep not in prov_playlist_id:
+ itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
+ else:
+ itempath = prov_playlist_id
+ prov_playlist_id = base64.b64encode(itempath.encode("utf-8")).decode(
+ "utf-8"
+ )
+ if not os.path.isfile(itempath):
+ self.logger.error("playlist path does not exist: %s", itempath)
+ return None
+ name = itempath.split(os.sep)[-1].replace(".m3u", "")
+ playlist = Playlist(prov_playlist_id, provider=self.id, name=name)
+ playlist.is_editable = True
+ playlist.provider_ids.append(
+ MediaItemProviderId(provider=self.id, item_id=prov_playlist_id)
+ )
+ playlist.owner = "disk"
+ playlist.checksum = os.path.getmtime(itempath)
+ return playlist
+
+ async def get_album_tracks(self, prov_album_id) -> List[Track]:
+ """Get album tracks for given album id."""
+ result = []
+ if os.sep not in prov_album_id:
+ albumpath = base64.b64decode(prov_album_id).decode("utf-8")
+ else:
+ albumpath = prov_album_id
+ if not os.path.isdir(albumpath):
+ self.logger.error("album path does not exist: %s", albumpath)
+ return []
+ album = await self.get_album(albumpath)
+ for filename in os.listdir(albumpath):
+ filepath = os.path.join(albumpath, filename)
+ if os.path.isfile(filepath) and not filepath.startswith("."):
+ track = await self._parse_track(filepath)
+ if track:
+ track.album = album
+ 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 = []
+ if os.sep not in prov_playlist_id:
+ itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
+ else:
+ itempath = prov_playlist_id
+ if not os.path.isfile(itempath):
+ self.logger.error("playlist path does not exist: %s", itempath)
+ return result
+ index = 0
+ async with aiofiles.open(itempath, "r") as _file:
+ for line in await _file.readlines():
+ line = line.strip()
+ if line and not line.startswith("#"):
+ track = await self._parse_track_from_uri(line)
+ if track:
+ result.append(track)
+ index += 1
+ return result
+
+ async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
+ """Get a list of albums for the given artist."""
+ result = []
+ if os.sep not in prov_artist_id:
+ artistpath = base64.b64decode(prov_artist_id).decode("utf-8")
+ else:
+ artistpath = prov_artist_id
+ if not os.path.isdir(artistpath):
+ self.logger.error("artist path does not exist: %s", artistpath)
+ return
+ for dirname in os.listdir(artistpath):
+ dirpath = os.path.join(artistpath, dirname)
+ if os.path.isdir(dirpath) and not dirpath.startswith("."):
+ album = await self.get_album(dirpath)
+ if album:
+ result.append(album)
+ return result
+
+ async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
+ """Get a list of random tracks as we have no clue about preference."""
+ result = []
+ for album in await self.get_artist_albums(prov_artist_id):
+ for track in await self.get_album_tracks(album.item_id):
+ result.append(track)
+ return result
+
+ async def get_stream_details(self, item_id: str) -> StreamDetails:
+ """Return the content details for the given track when it will be streamed."""
+ if os.sep not in item_id:
+ track_id = base64.b64decode(item_id).decode("utf-8")
+ if not os.path.isfile(track_id):
+ return None
+ # TODO: retrieve sanple rate and bitdepth
+ return StreamDetails(
+ type=StreamType.FILE,
+ provider=self.id,
+ item_id=item_id,
+ content_type=ContentType(track_id.split(".")[-1]),
+ path=track_id,
+ sample_rate=44100,
+ bit_depth=16,
+ )
+
+ async def _parse_track(self, filename):
+ """Try to parse a track from a filename with taglib."""
+ # pylint: disable=broad-except
+ try:
+ song = taglib.File(filename)
+ except Exception:
+ return None # not a media file ?
+ prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8")
+ try:
+ name = song.tags["TITLE"][0]
+ except KeyError:
+ name = filename.split("/")[-1].split(".")[0]
+ name, version = parse_title_and_version(name)
+ track = Track(
+ item_id=prov_item_id, provider=self.id, name=name, version=version
+ )
+ track.duration = song.length
+ albumpath = filename.rsplit(os.sep, 1)[0]
+ track.album = await self.get_album(albumpath)
+ if "ARTIST" in song.tags:
+ artists = []
+ for artist_str in song.tags["ARTIST"]:
+ local_artist_path = os.path.join(self._music_dir, artist_str)
+ if os.path.isfile(local_artist_path):
+ artist = await self.get_artist(local_artist_path)
+ else:
+ fake_artistpath = os.path.join(self._music_dir, artist_str)
+ artist = Artist(
+ item_id=fake_artistpath, provider=self.id, name=artist_str
+ )
+ artist.provider_ids.append(
+ MediaItemProviderId(
+ provider=self.id,
+ item_id=base64.b64encode(
+ fake_artistpath.encode("utf-8")
+ ).decode("utf-8"),
+ )
+ )
+ artists.append(artist)
+ track.artists = artists
+ else:
+ artistpath = filename.rsplit(os.sep, 2)[0]
+ artist = await self.get_artist(artistpath)
+ track.artists.append(artist)
+ if "GENRE" in song.tags:
+ track.metadata["genres"] = song.tags["GENRE"]
+ if "ISRC" in song.tags and song.tags["ISRC"]:
+ track.isrc = song.tags["ISRC"][0]
+ if "DISCNUMBER" in song.tags and song.tags["DISCNUMBER"]:
+ regexp_numbers = re.findall(r"\d+", song.tags["DISCNUMBER"][0])
+ track.disc_number = int(regexp_numbers[0] if regexp_numbers else "0")
+ if "TRACKNUMBER" in song.tags and song.tags["TRACKNUMBER"]:
+ regexp_numbers = re.findall(r"\d+", song.tags["TRACKNUMBER"][0])
+ track.track_number = int(regexp_numbers[0] if regexp_numbers else "0")
+ quality_details = ""
+ if filename.endswith(".flac"):
+ # TODO: get bit depth
+ quality = MediaQuality.FLAC_LOSSLESS
+ if song.sampleRate > 192000:
+ quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
+ elif song.sampleRate > 96000:
+ quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
+ elif song.sampleRate > 48000:
+ quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
+ quality_details = f"{song.sampleRate / 1000} Khz"
+ elif filename.endswith(".ogg"):
+ quality = MediaQuality.LOSSY_OGG
+ quality_details = f"{song.bitrate} kbps"
+ elif filename.endswith(".m4a"):
+ quality = MediaQuality.LOSSY_AAC
+ quality_details = f"{song.bitrate} kbps"
+ else:
+ quality = MediaQuality.LOSSY_MP3
+ quality_details = f"{song.bitrate} kbps"
+ track.provider_ids.append(
+ MediaItemProviderId(
+ provider=self.id,
+ item_id=prov_item_id,
+ quality=quality,
+ details=quality_details,
+ )
+ )
+ return track
+
+ async def _parse_track_from_uri(self, uri):
+ """Try to parse a track from an uri found in playlist."""
+ # pylint: disable=broad-except
+ if "://" in uri:
+ # track is uri from external provider?
+ try:
+ return await self.mass.music.get_item_by_uri(uri)
+ except Exception as exc:
+ self.logger.warning(
+ "Could not parse uri %s to track: %s", uri, str(exc)
+ )
+ return None
+ # try to treat uri as filename
+ # TODO: filename could be related to musicdir or full path
+ track = await self.get_track(uri)
+ if track:
+ return track
+ track = await self.get_track(os.path.join(self._music_dir, uri))
+ if track:
+ return track
+ return None
--- /dev/null
+"""Qobuz musicprovider support for MusicAssistant."""
+
+import datetime
+import hashlib
+import time
+from typing import List, Optional
+
+from asyncio_throttle import Throttler
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.app_vars import ( # pylint: disable=no-name-in-module
+ get_app_var,
+)
+from music_assistant.models.errors import LoginFailed
+from music_assistant.helpers.util import parse_title_and_version, try_parse_int
+from music_assistant.models.media_items import (
+ Album,
+ AlbumType,
+ Artist,
+ ContentType,
+ MediaItemProviderId,
+ MediaItemType,
+ MediaQuality,
+ MediaType,
+ Playlist,
+ StreamDetails,
+ StreamType,
+ Track,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class QobuzProvider(MusicProvider):
+ """Provider for the Qobux music service."""
+
+ def __init__(self, username: str, password: str) -> None:
+ """Initialize the Spotify provider."""
+ self._attr_id = "qobuz"
+ self._attr_name = "Qobuz"
+ self._attr_supported_mediatypes = [
+ MediaType.ARTIST,
+ MediaType.ALBUM,
+ MediaType.TRACK,
+ MediaType.PLAYLIST,
+ ]
+ self._username = username
+ self._password = password
+ self.__user_auth_info = None
+ self._throttler = Throttler(rate_limit=4, period=1)
+
+ async def setup(self) -> None:
+ """Handle async initialization of the provider."""
+ # 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._username}")
+ # subscribe to stream events so we can report playback to Qobuz
+ self.mass.subscribe(
+ self.on_stream_event, (EventType.STREAM_STARTED, EventType.STREAM_ENDED)
+ )
+
+ 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) -> List[Artist]:
+ """Retrieve all library artists from Qobuz."""
+ params = {"type": "artists"}
+ endpoint = "favorite/getUserFavorites"
+ return [
+ await self._parse_artist(item)
+ for item in await self._get_all_items(endpoint, params, key="artists")
+ if (item and item["id"])
+ ]
+
+ async def get_library_albums(self) -> List[Album]:
+ """Retrieve all library albums from Qobuz."""
+ params = {"type": "albums"}
+ endpoint = "favorite/getUserFavorites"
+ return [
+ await self._parse_album(item)
+ for item in await self._get_all_items(endpoint, params, key="albums")
+ if (item and item["id"])
+ ]
+
+ async def get_library_tracks(self) -> List[Track]:
+ """Retrieve library tracks from Qobuz."""
+ params = {"type": "tracks"}
+ endpoint = "favorite/getUserFavorites"
+ return [
+ await self._parse_track(item)
+ for item in await self._get_all_items(endpoint, params, key="tracks")
+ if (item and item["id"])
+ ]
+
+ async def get_library_playlists(self) -> List[Playlist]:
+ """Retrieve all library playlists from the provider."""
+ endpoint = "playlist/getUserPlaylists"
+ return [
+ await self._parse_playlist(item)
+ for item in await self._get_all_items(endpoint, key="playlists")
+ if (item and item["id"])
+ ]
+
+ 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."""
+ params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
+ endpoint = "playlist/get"
+ return [
+ await self._parse_track(item)
+ for item in await self._get_all_items(endpoint, params, key="tracks")
+ 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."""
+ params = {"artist_id": prov_artist_id, "extra": "albums"}
+ endpoint = "artist/get"
+ return [
+ await self._parse_album(item)
+ for item in await self._get_all_items(endpoint, params, key="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."""
+ params = {
+ "artist_id": prov_artist_id,
+ "extra": "playlists",
+ "offset": 0,
+ "limit": 25,
+ }
+ result = await self._get_data("artist/get", params)
+ 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)
+ params = {"query": artist.name, "limit": 25, "type": "tracks"}
+ searchresult = await self._get_data("catalog/search", params)
+ 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_ids": 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, prov_track_ids):
+ """Add track(s) to playlist."""
+ params = {
+ "playlist_id": prov_playlist_id,
+ "track_ids": ",".join(prov_track_ids),
+ "playlist_track_ids": ",".join(prov_track_ids),
+ }
+ return await self._get_data("playlist/addTracks", params)
+
+ async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
+ """Remove track(s) from playlist."""
+ playlist_track_ids = set()
+ params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
+ for track in await self._get_all_items("playlist/get", params, key="tracks"):
+ if str(track["id"]) in prov_track_ids:
+ playlist_track_ids.add(str(track["playlist_track_id"]))
+ params = {
+ "playlist_id": prov_playlist_id,
+ "playlist_track_ids": ",".join(playlist_track_ids),
+ }
+ return await self._get_data("playlist/deleteTracks", params)
+
+ 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 ?!
+ params = {"format_id": format_id, "track_id": item_id, "intent": "stream"}
+ result = await self._get_data("track/getFileUrl", params, sign_request=True)
+ if result and result.get("url"):
+ streamdata = result
+ break
+ if not streamdata:
+ self.logger.error("Unable to retrieve stream details for track %s", item_id)
+ return None
+ if streamdata["mime_type"] == "audio/mpeg":
+ content_type = ContentType.MPEG
+ elif streamdata["mime_type"] == "audio/flac":
+ content_type = ContentType.FLAC
+ else:
+ self.logger.error("Unsupported mime type for track %s", item_id)
+ return None
+ return StreamDetails(
+ type=StreamType.URL,
+ item_id=str(item_id),
+ provider=self.id,
+ path=streamdata["url"],
+ content_type=content_type,
+ sample_rate=int(streamdata["sampling_rate"] * 1000),
+ bit_depth=streamdata["bit_depth"],
+ details=streamdata, # we need these details for reporting playback
+ )
+
+ async def on_stream_event(self, msg, msg_details):
+ """
+ Received event from mass.
+
+ We use this to report playback start/stop to qobuz.
+ """
+ if not self.__user_auth_info:
+ return
+ # 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"}}
+ if msg == EventType.STREAM_STARTED and msg_details.provider == self.id:
+ # report streaming started to qobuz
+ 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 = msg_details.details["format_id"]
+ timestamp = int(time.time())
+ events = [
+ {
+ "online": True,
+ "sample": False,
+ "intent": "stream",
+ "device_id": device_id,
+ "track_id": str(msg_details.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)
+ elif msg == EventType.STREAM_ENDED and msg_details.provider == self.id:
+ # report streaming ended to qobuz
+ user_id = self.__user_auth_info["user"]["id"]
+ params = {
+ "user_id": user_id,
+ "track_id": str(msg_details.item_id),
+ "duration": try_parse_int(msg_details.seconds_played),
+ }
+ await self._get_data("/track/reportStreamingEnd", params)
+
+ async def _parse_artist(self, artist_obj):
+ """Parse qobuz artist object to generic layout."""
+ artist = Artist(
+ item_id=str(artist_obj["id"]), provider=self.id, name=artist_obj["name"]
+ )
+ artist.provider_ids.append(
+ MediaItemProviderId(provider=self.id, item_id=str(artist_obj["id"]))
+ )
+ artist.metadata["image"] = self.__get_image(artist_obj)
+ if artist_obj.get("biography"):
+ artist.metadata["biography"] = artist_obj["biography"].get("content", "")
+ if artist_obj.get("url"):
+ artist.metadata["qobuz_url"] = artist_obj["url"]
+ 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.id, 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.provider_ids.append(
+ MediaItemProviderId(
+ provider=self.id,
+ item_id=str(album_obj["id"]),
+ quality=quality,
+ details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit',
+ available=album_obj["streamable"] and album_obj["displayable"],
+ )
+ )
+
+ if artist_obj:
+ album.artist = artist_obj
+ else:
+ album.artist = await self._parse_artist(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["genre"] = album_obj["genre"]["name"]
+ album.metadata["image"] = self.__get_image(album_obj)
+ if len(album_obj["upc"]) == 13:
+ # qobuz writes ean as upc ?!
+ album.metadata["ean"] = album_obj["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("hires"):
+ album.metadata["hires"] = "true"
+ if album_obj.get("url"):
+ album.metadata["qobuz_url"] = album_obj["url"]
+ if album_obj.get("description"):
+ album.metadata["description"] = album_obj["description"]
+ return album
+
+ async def _parse_track(self, track_obj):
+ """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.id,
+ name=name,
+ version=version,
+ disc_number=track_obj["media_number"],
+ track_number=track_obj["track_number"],
+ duration=track_obj["duration"],
+ )
+ 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.id, 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("hires"):
+ track.metadata["hires"] = "true"
+ if track_obj.get("url"):
+ track.metadata["qobuz_url"] = track_obj["url"]
+ if track_obj.get("isrc"):
+ track.isrc = track_obj["isrc"]
+ if track_obj.get("performers"):
+ track.metadata["performers"] = track_obj["performers"]
+ 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
+ track.metadata["image"] = self.__get_image(track_obj)
+ # 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.provider_ids.append(
+ MediaItemProviderId(
+ provider=self.id,
+ item_id=str(track_obj["id"]),
+ quality=quality,
+ 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.id,
+ name=playlist_obj["name"],
+ owner=playlist_obj["owner"]["name"],
+ )
+ playlist.provider_ids.append(
+ MediaItemProviderId(provider=self.id, item_id=str(playlist_obj["id"]))
+ )
+ playlist.is_editable = (
+ playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"]
+ or playlist_obj["is_collaborative"]
+ )
+ playlist.metadata["image"] = self.__get_image(playlist_obj)
+ if playlist_obj.get("url"):
+ playlist.metadata["qobuz_url"] = playlist_obj["url"]
+ playlist.checksum = 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._username,
+ "password": self._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"]
+ )
+ return details["user_auth_token"]
+
+ async def _get_all_items(self, endpoint, params=None, key="tracks"):
+ """Get all items from a paged list."""
+ if not params:
+ params = {}
+ limit = 50
+ offset = 0
+ all_items = []
+ while True:
+ params["limit"] = limit
+ params["offset"] = offset
+ result = await self._get_data(endpoint, params=params)
+ offset += limit
+ if not result:
+ break
+ if not result.get(key) or not result[key].get("items"):
+ break
+ all_items += result[key]["items"]
+ if len(result[key]["items"]) < limit:
+ break
+ return all_items
+
+ async def _get_data(self, endpoint, params=None, sign_request=False):
+ """Get data from api."""
+ if not params:
+ params = {}
+ url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
+ headers = {"X-App-Id": get_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(params.keys())
+ keys.sort()
+ for key in keys:
+ signing_data += f"{key}{params[key]}"
+ request_ts = str(time.time())
+ request_sig = signing_data + request_ts + get_app_var(1)
+ request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
+ params["request_ts"] = request_ts
+ params["request_sig"] = request_sig
+ params["app_id"] = get_app_var(0)
+ params["user_auth_token"] = await self._auth_token()
+ async with self._throttler:
+ async with self.mass.http_session.get(
+ url, headers=headers, params=params, 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
+
+ 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"] = get_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
-"""Qobuz musicprovider support for MusicAssistant."""
-import datetime
-import hashlib
-import logging
-import time
-from typing import List, Optional
-
-from asyncio_throttle import Throttler
-from music_assistant.constants import (
- CONF_PASSWORD,
- CONF_USERNAME,
- EVENT_STREAM_ENDED,
- EVENT_STREAM_STARTED,
-)
-from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all
-from music_assistant.helpers.util import parse_title_and_version, try_parse_int
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import (
- Album,
- AlbumType,
- Artist,
- MediaItemProviderId,
- MediaType,
- Playlist,
- Radio,
- SearchResult,
- Track,
- TrackQuality,
-)
-from music_assistant.models.provider import MusicProvider
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-PROV_ID = "qobuz"
-PROV_NAME = "Qobuz"
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = [
- ConfigEntry(
- entry_key=CONF_USERNAME,
- entry_type=ConfigEntryType.STRING,
- description=CONF_USERNAME,
- ),
- ConfigEntry(
- entry_key=CONF_PASSWORD,
- entry_type=ConfigEntryType.PASSWORD,
- description=CONF_PASSWORD,
- ),
-]
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = QobuzProvider()
- await mass.register_provider(prov)
-
-
-class QobuzProvider(MusicProvider):
- """Provider for the Qobux music service."""
-
- # pylint: disable=abstract-method
-
- __user_auth_info = None
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- @property
- def supported_mediatypes(self) -> List[MediaType]:
- """Return MediaTypes the provider supports."""
- return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK]
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider based on config."""
- # pylint: disable=attribute-defined-outside-init
- config = self.mass.config.get_provider_config(self.id)
- if not config[CONF_USERNAME] or not config[CONF_PASSWORD]:
- LOGGER.debug("Username and password not set. Abort load of provider.")
- return False
- self.__username = config[CONF_USERNAME]
- self.__password = config[CONF_PASSWORD]
-
- self.__user_auth_info = None
- self.__logged_in = False
- self._throttler = Throttler(rate_limit=4, period=1)
- self.mass.eventbus.add_listener(
- self.mass_event, (EVENT_STREAM_STARTED, EVENT_STREAM_ENDED)
- )
- return True
-
- async def search(
- self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
- ) -> SearchResult:
- """
- 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 = SearchResult()
- 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"
- searchresult = await self._get_data("catalog/search", params)
- if searchresult:
- if "artists" in searchresult:
- result.artists = [
- await self._parse_artist(item)
- for item in searchresult["artists"]["items"]
- if (item and item["id"])
- ]
- if "albums" in searchresult:
- result.albums = [
- await self._parse_album(item)
- for item in searchresult["albums"]["items"]
- if (item and item["id"])
- ]
- if "tracks" in searchresult:
- result.tracks = [
- await self._parse_track(item)
- for item in searchresult["tracks"]["items"]
- if (item and item["id"])
- ]
- if "playlists" in searchresult:
- result.playlists = [
- await self._parse_playlist(item)
- for item in searchresult["playlists"]["items"]
- if (item and item["id"])
- ]
- return result
-
- async def get_library_artists(self) -> List[Artist]:
- """Retrieve all library artists from Qobuz."""
- params = {"type": "artists"}
- endpoint = "favorite/getUserFavorites"
- return [
- await self._parse_artist(item)
- for item in await self._get_all_items(endpoint, params, key="artists")
- if (item and item["id"])
- ]
-
- async def get_library_albums(self) -> List[Album]:
- """Retrieve all library albums from Qobuz."""
- params = {"type": "albums"}
- endpoint = "favorite/getUserFavorites"
- return [
- await self._parse_album(item)
- for item in await self._get_all_items(endpoint, params, key="albums")
- if (item and item["id"])
- ]
-
- async def get_library_tracks(self) -> List[Track]:
- """Retrieve library tracks from Qobuz."""
- params = {"type": "tracks"}
- endpoint = "favorite/getUserFavorites"
- return [
- await self._parse_track(item)
- for item in await self._get_all_items(endpoint, params, key="tracks")
- if (item and item["id"])
- ]
-
- async def get_library_playlists(self) -> List[Playlist]:
- """Retrieve all library playlists from the provider."""
- endpoint = "playlist/getUserPlaylists"
- return [
- await self._parse_playlist(item)
- for item in await self._get_all_items(endpoint, key="playlists")
- if (item and item["id"])
- ]
-
- async def get_radios(self) -> List[Radio]:
- """Retrieve library/subscribed radio stations from the provider."""
- return [] # TODO
-
- 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."""
- params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
- endpoint = "playlist/get"
- return [
- await self._parse_track(item)
- for item in await self._get_all_items(endpoint, params, key="tracks")
- 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."""
- params = {"artist_id": prov_artist_id, "extra": "albums"}
- endpoint = "artist/get"
- return [
- await self._parse_album(item)
- for item in await self._get_all_items(endpoint, params, key="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."""
- params = {
- "artist_id": prov_artist_id,
- "extra": "playlists",
- "offset": 0,
- "limit": 25,
- }
- result = await self._get_data("artist/get", params)
- 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)
- params = {"query": artist.name, "limit": 25, "type": "tracks"}
- searchresult = await self._get_data("catalog/search", params)
- 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_ids": 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, prov_track_ids):
- """Add track(s) to playlist."""
- params = {
- "playlist_id": prov_playlist_id,
- "track_ids": ",".join(prov_track_ids),
- "playlist_track_ids": ",".join(prov_track_ids),
- }
- return await self._get_data("playlist/addTracks", params)
-
- async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
- """Remove track(s) from playlist."""
- playlist_track_ids = set()
- params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
- for track in await self._get_all_items("playlist/get", params, key="tracks"):
- if str(track["id"]) in prov_track_ids:
- playlist_track_ids.add(str(track["playlist_track_id"]))
- params = {
- "playlist_id": prov_playlist_id,
- "playlist_track_ids": ",".join(playlist_track_ids),
- }
- return await self._get_data("playlist/deleteTracks", params)
-
- 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 ?!
- params = {"format_id": format_id, "track_id": item_id, "intent": "stream"}
- result = await self._get_data("track/getFileUrl", params, sign_request=True)
- if result and result.get("url"):
- streamdata = result
- break
- if not streamdata:
- LOGGER.error("Unable to retrieve stream details for track %s", item_id)
- return None
- if streamdata["mime_type"] == "audio/mpeg":
- content_type = ContentType.MPEG
- elif streamdata["mime_type"] == "audio/flac":
- content_type = ContentType.FLAC
- else:
- LOGGER.error("Unsupported mime type for track %s", item_id)
- return None
- return StreamDetails(
- type=StreamType.URL,
- item_id=str(item_id),
- provider=PROV_ID,
- path=streamdata["url"],
- content_type=content_type,
- sample_rate=int(streamdata["sampling_rate"] * 1000),
- bit_depth=streamdata["bit_depth"],
- details=streamdata, # we need these details for reporting playback
- )
-
- async def mass_event(self, msg, msg_details):
- """
- Received event from mass.
-
- We use this to report playback start/stop to qobuz.
- """
- if not self.__user_auth_info:
- return
- # 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"}}
- if msg == EVENT_STREAM_STARTED and msg_details.provider == PROV_ID:
- # report streaming started to qobuz
- 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 = msg_details.details["format_id"]
- timestamp = int(time.time())
- events = [
- {
- "online": True,
- "sample": False,
- "intent": "stream",
- "device_id": device_id,
- "track_id": str(msg_details.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)
- elif msg == EVENT_STREAM_ENDED and msg_details.provider == PROV_ID:
- # report streaming ended to qobuz
- # if msg_details.details < 6:
- # return ????????????? TODO
- user_id = self.__user_auth_info["user"]["id"]
- params = {
- "user_id": user_id,
- "track_id": str(msg_details.item_id),
- "duration": try_parse_int(msg_details.seconds_played),
- }
- await self._get_data("/track/reportStreamingEnd", params)
-
- async def _parse_artist(self, artist_obj):
- """Parse qobuz artist object to generic layout."""
- artist = Artist(
- item_id=str(artist_obj["id"]), provider=PROV_ID, name=artist_obj["name"]
- )
- artist.provider_ids.add(
- MediaItemProviderId(provider=PROV_ID, item_id=str(artist_obj["id"]))
- )
- artist.metadata["image"] = self.__get_image(artist_obj)
- if artist_obj.get("biography"):
- artist.metadata["biography"] = artist_obj["biography"].get("content", "")
- if artist_obj.get("url"):
- artist.metadata["qobuz_url"] = artist_obj["url"]
- 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"])
- album = Album(item_id=str(album_obj["id"]), provider=PROV_ID)
- if album_obj["maximum_sampling_rate"] > 192:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
- elif album_obj["maximum_sampling_rate"] > 96:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
- elif album_obj["maximum_sampling_rate"] > 48:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
- elif album_obj["maximum_bit_depth"] > 16:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1
- elif album_obj.get("format_id", 0) == 5:
- quality = TrackQuality.LOSSY_AAC
- else:
- quality = TrackQuality.FLAC_LOSSLESS
- album.provider_ids.add(
- MediaItemProviderId(
- provider=PROV_ID,
- item_id=str(album_obj["id"]),
- quality=quality,
- details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit',
- available=album_obj["streamable"] and album_obj["displayable"],
- )
- )
- album.name, album.version = parse_title_and_version(
- album_obj["title"], album_obj.get("version")
- )
- if artist_obj:
- album.artist = artist_obj
- else:
- album.artist = await self._parse_artist(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["genre"] = album_obj["genre"]["name"]
- album.metadata["image"] = self.__get_image(album_obj)
- if len(album_obj["upc"]) == 13:
- # qobuz writes ean as upc ?!
- album.metadata["ean"] = album_obj["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("hires"):
- album.metadata["hires"] = "true"
- if album_obj.get("url"):
- album.metadata["qobuz_url"] = album_obj["url"]
- if album_obj.get("description"):
- album.metadata["description"] = album_obj["description"]
- return album
-
- async def _parse_track(self, track_obj):
- """Parse qobuz track object to generic layout."""
- track = Track(
- item_id=str(track_obj["id"]),
- provider=PROV_ID,
- disc_number=track_obj["media_number"],
- track_number=track_obj["track_number"],
- duration=track_obj["duration"],
- )
- if track_obj.get("performer") and "Various " not in track_obj["performer"]:
- artist = await self._parse_artist(track_obj["performer"])
- if artist:
- track.artists.add(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.add(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()
- artist.name = name
- artist.item_id = name
- track.artists.add(artist)
- # TODO: fix grabbing composer from details
- track.name, track.version = parse_title_and_version(
- track_obj["title"], track_obj.get("version")
- )
- if "album" in track_obj:
- album = await self._parse_album(track_obj["album"])
- if album:
- track.album = album
- if track_obj.get("hires"):
- track.metadata["hires"] = "true"
- if track_obj.get("url"):
- track.metadata["qobuz_url"] = track_obj["url"]
- if track_obj.get("isrc"):
- track.isrc = track_obj["isrc"]
- if track_obj.get("performers"):
- track.metadata["performers"] = track_obj["performers"]
- 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
- track.metadata["image"] = self.__get_image(track_obj)
- # get track quality
- if track_obj["maximum_sampling_rate"] > 192:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
- elif track_obj["maximum_sampling_rate"] > 96:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
- elif track_obj["maximum_sampling_rate"] > 48:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
- elif track_obj["maximum_bit_depth"] > 16:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1
- elif track_obj.get("format_id", 0) == 5:
- quality = TrackQuality.LOSSY_AAC
- else:
- quality = TrackQuality.FLAC_LOSSLESS
- track.provider_ids.add(
- MediaItemProviderId(
- provider=PROV_ID,
- item_id=str(track_obj["id"]),
- quality=quality,
- 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=PROV_ID,
- name=playlist_obj["name"],
- owner=playlist_obj["owner"]["name"],
- )
- playlist.provider_ids.add(
- MediaItemProviderId(provider=PROV_ID, item_id=str(playlist_obj["id"]))
- )
- playlist.is_editable = (
- playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"]
- or playlist_obj["is_collaborative"]
- )
- playlist.metadata["image"] = self.__get_image(playlist_obj)
- if playlist_obj.get("url"):
- playlist.metadata["qobuz_url"] = playlist_obj["url"]
- playlist.checksum = 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.__username,
- "password": self.__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
- LOGGER.info(
- "Succesfully logged in to Qobuz as %s", details["user"]["display_name"]
- )
- return details["user_auth_token"]
-
- async def _get_all_items(self, endpoint, params=None, key="tracks"):
- """Get all items from a paged list."""
- if not params:
- params = {}
- limit = 50
- offset = 0
- all_items = []
- while True:
- params["limit"] = limit
- params["offset"] = offset
- result = await self._get_data(endpoint, params=params)
- offset += limit
- if not result:
- break
- if not result.get(key) or not result[key].get("items"):
- break
- all_items += result[key]["items"]
- if len(result[key]["items"]) < limit:
- break
- return all_items
-
- async def _get_data(self, endpoint, params=None, sign_request=False):
- """Get data from api."""
- if not params:
- params = {}
- url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint
- headers = {"X-App-Id": get_app_var(0)}
- if endpoint != "user/login":
- auth_token = await self._auth_token()
- if not auth_token:
- LOGGER.debug("Not logged in")
- return None
- headers["X-User-Auth-Token"] = auth_token
- if sign_request:
- signing_data = "".join(endpoint.split("/"))
- keys = list(params.keys())
- keys.sort()
- for key in keys:
- signing_data += "%s%s" % (key, params[key])
- request_ts = str(time.time())
- request_sig = signing_data + request_ts + get_app_var(1)
- request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
- params["request_ts"] = request_ts
- params["request_sig"] = request_sig
- params["app_id"] = get_app_var(0)
- params["user_auth_token"] = await self._auth_token()
- async with self._throttler:
- async with self.mass.http_session.get(
- url, headers=headers, params=params, verify_ssl=False
- ) as response:
- result = await response.json()
- if "error" in result or (
- "status" in result and "error" in result["status"]
- ):
- LOGGER.error("%s - %s", endpoint, result)
- 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 = "http://www.qobuz.com/api.json/0.2/%s" % endpoint
- params["app_id"] = get_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"]
- ):
- 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
-"""Player provider for Sonos speakers."""
-
-from .sonos import SonosProvider
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = SonosProvider()
- await mass.register_provider(prov)
+++ /dev/null
-"""Player provider for Sonos speakers."""
-
-import asyncio
-import logging
-import time
-from typing import List
-
-import soco
-from music_assistant.helpers.util import create_task
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
-from music_assistant.models.player_queue import QueueItem
-from music_assistant.models.provider import PlayerProvider
-
-PROV_ID = "sonos"
-PROV_NAME = "Sonos"
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = [] # we don't have any provider config entries (for now)
-PLAYER_FEATURES = [PlayerFeature.QUEUE, PlayerFeature.CROSSFADE, PlayerFeature.GAPLESS]
-PLAYER_CONFIG_ENTRIES = [] # we don't have any player config entries (for now)
-
-
-class SonosProvider(PlayerProvider):
- """Support for Sonos speakers."""
-
- # pylint: disable=abstract-method
-
- _discovery_running = False
- _tasks = []
- _players = {}
- _report_progress_tasks = []
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider."""
- self.mass.tasks.add("Run Sonos discovery", self.__run_discovery, periodic=1800)
-
- async def on_stop(self):
- """Handle correct close/cleanup of the provider on exit."""
- for task in self._tasks:
- task.cancel()
-
- async def cmd_play_uri(self, player_id: str, uri: str):
- """
- Play the specified uri/url on the goven player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.play_uri, uri)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_stop(self, player_id: str) -> None:
- """
- Send STOP command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.stop)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_play(self, player_id: str) -> None:
- """
- Send STOP command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.play)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_pause(self, player_id: str):
- """
- Send PAUSE command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.pause)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_next(self, player_id: str):
- """
- Send NEXT TRACK command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.next)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_previous(self, player_id: str):
- """
- Send PREVIOUS TRACK command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.previous)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_power_on(self, player_id: str) -> None:
- """
- Send POWER ON command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- # power is not supported so abuse mute instead
- player.soco.mute = False
- player.powered = True
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_power_off(self, player_id: str) -> None:
- """
- Send POWER OFF command to given player.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- # power is not supported so abuse mute instead
- player.soco.mute = True
- player.powered = False
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
- """
- Send volume level command to given player.
-
- :param player_id: player_id of the player to handle the command.
- :param volume_level: volume level to set (0..100).
- """
- player = self._players.get(player_id)
- if player:
- player.soco.volume = volume_level
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_volume_mute(self, player_id: str, is_muted=False):
- """
- Send volume MUTE command to given player.
-
- :param player_id: player_id of the player to handle the command.
- :param is_muted: bool with new mute state.
- """
- player = self._players.get(player_id)
- if player:
- player.soco.mute = is_muted
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_queue_play_index(self, player_id: str, index: int):
- """
- Play item at index X on player's queue.
-
- :param player_id: player_id of the player to handle the command.
- :param index: (int) index of the queue item that should start playing
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.play_from_queue, index)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_queue_load(
- self, player_id: str, queue_items: List[QueueItem], repeat: bool = False
- ):
- """
- Load/overwrite given items in the player's queue implementation.
-
- :param player_id: player_id of the player to handle the command.
- :param queue_items: a list of QueueItems
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.clear_queue)
- for pos, item in enumerate(queue_items):
- create_task(player.soco.add_uri_to_queue, item.stream_url, pos)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_queue_insert(
- self, player_id: str, queue_items: List[QueueItem], insert_at_index: int
- ):
- """
- Insert new items at position X into existing queue.
-
- If insert_at_index 0 or None, will start playing newly added item(s)
- :param player_id: player_id of the player to handle the command.
- :param queue_items: a list of QueueItems
- :param insert_at_index: queue position to insert new items
- """
- player = self._players.get(player_id)
- if player:
- for pos, item in enumerate(queue_items):
- create_task(
- player.soco.add_uri_to_queue, item.stream_url, insert_at_index + pos
- )
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_queue_append(self, player_id: str, queue_items: List[QueueItem]):
- """
- Append new items at the end of the queue.
-
- :param player_id: player_id of the player to handle the command.
- :param queue_items: a list of QueueItems
- """
- player_queue = self.mass.players.get_player_queue(player_id)
- if player_queue:
- return await self.cmd_queue_insert(
- player_id, queue_items, len(player_queue.items)
- )
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- async def cmd_queue_clear(self, player_id: str):
- """
- Clear the player's queue.
-
- :param player_id: player_id of the player to handle the command.
- """
- player = self._players.get(player_id)
- if player:
- create_task(player.soco.clear_queue)
- else:
- LOGGER.warning("Received command for unavailable player: %s", player_id)
-
- def __run_discovery(self):
- """Background Sonos discovery and handler, runs in executor thread."""
- if self._discovery_running:
- return
- self._discovery_running = True
- LOGGER.debug("Sonos discovery started...")
- discovered_devices = soco.discover()
- if discovered_devices is None:
- discovered_devices = []
- new_device_ids = [item.uid for item in discovered_devices]
- cur_player_ids = [item.player_id for item in self._players.values()]
- # remove any disconnected players...
- for player in list(self._players.values()):
- if not player.is_group and player.soco.uid not in new_device_ids:
- create_task(self.mass.players.remove_player(player.player_id))
- for sub in player.subscriptions:
- sub.unsubscribe()
- self._players.pop(player, None)
- # process new players
- for device in discovered_devices:
- if device.uid not in cur_player_ids and device.is_visible:
- self.__device_discovered(device)
- # handle groups
- if len(discovered_devices) > 0:
- self.__process_groups(discovered_devices[0].all_groups)
- else:
- self.__process_groups([])
-
- def __device_discovered(self, soco_device: soco.SoCo):
- """Handle discovered Sonos player."""
- speaker_info = soco_device.get_speaker_info(True)
- player = Player(
- player_id=soco_device.uid,
- provider_id=PROV_ID,
- name=soco_device.player_name,
- features=PLAYER_FEATURES,
- config_entries=PLAYER_CONFIG_ENTRIES,
- device_info=DeviceInfo(
- model=speaker_info["model_name"],
- address=speaker_info["mac_address"],
- manufacturer=PROV_NAME,
- ),
- )
- # store soco object on player
- player.soco = soco_device
- player.media_position_updated_at = 0
- # handle subscriptions to events
- player.subscriptions = []
-
- def subscribe(service, _callback):
- queue = ProcessSonosEventQueue(soco_device.uid, _callback)
- sub = service.subscribe(auto_renew=True, event_queue=queue)
- player.subscriptions.append(sub)
-
- subscribe(soco_device.avTransport, self.__player_event)
- subscribe(soco_device.renderingControl, self.__player_event)
- subscribe(soco_device.zoneGroupTopology, self.__topology_changed)
- create_task(self.mass.players.add_player(player))
- return player
-
- def __player_event(self, player_id: str, event):
- """Handle a SoCo player event."""
- player = self._players[player_id]
- if event:
- variables = event.variables
- if "volume" in variables:
- player.volume_level = int(variables["volume"]["Master"])
- if "mute" in variables:
- player.muted = variables["mute"]["Master"] == "1"
- else:
- player.volume_level = player.soco.volume
- player.muted = player.soco.mute
- transport_info = player.soco.get_current_transport_info()
- current_transport_state = transport_info.get("current_transport_state")
- if current_transport_state == "TRANSITIONING":
- return
- if player.soco.is_playing_tv or player.soco.is_playing_line_in:
- player.powered = False
- else:
- new_state = __convert_state(current_transport_state)
- player.state = new_state
- track_info = player.soco.get_current_track_info()
- player.current_uri = track_info["uri"]
- position_info = player.soco.avTransport.GetPositionInfo(
- [("InstanceID", 0), ("Channel", "Master")]
- )
- rel_time = __timespan_secs(position_info.get("RelTime"))
- player.elapsed_time = rel_time
- if player.state == PlayerState.PLAYING:
- create_task(self._report_progress(player_id))
- player.update_state()
-
- def __process_groups(self, sonos_groups):
- """Process all sonos groups."""
- all_group_ids = []
- for group in sonos_groups:
- all_group_ids.append(group.uid)
- if group.uid not in self._players:
- # new group player
- group_player = self.__device_discovered(group.coordinator)
- else:
- group_player = self._players[group.uid]
- # check members
- group_player.is_group_player = True
- group_player.name = group.label
- group_player.group_childs = [item.uid for item in group.members]
- create_task(self.mass.players.update_player(group_player))
-
- async def __topology_changed(self, player_id, event=None):
- """Received topology changed event from one of the sonos players."""
- # pylint: disable=unused-argument
- # Schedule discovery to work out the changes.
- create_task(self.__run_discovery)
-
- async def _report_progress(self, player_id: str):
- """Report current progress while playing."""
- if player_id in self._report_progress_tasks:
- return # already running
- # sonos does not send instant updates of the player's progress (elapsed time)
- # so we need to send it in periodically
- player = self._players[player_id]
- player.should_poll = True
- while player and player.state == PlayerState.PLAYING:
- time_diff = time.time() - player.media_position_updated_at
- adjusted_current_time = player.elapsed_time + time_diff
- player.elapsed_time = adjusted_current_time
- await asyncio.sleep(1)
- player.should_poll = False
- self._report_progress_tasks.pop(player_id, None)
-
-
-def __convert_state(sonos_state: str) -> PlayerState:
- """Convert Sonos state to PlayerState."""
- if sonos_state == "PLAYING":
- return PlayerState.PLAYING
- if sonos_state == "TRANSITIONING":
- return PlayerState.PLAYING
- if sonos_state == "PAUSED_PLAYBACK":
- return PlayerState.PAUSED
- return PlayerState.IDLE
-
-
-def __timespan_secs(timespan):
- """Parse a time-span into number of seconds."""
- if timespan in ("", "NOT_IMPLEMENTED", None):
- return None
- return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":"))))
-
-
-class ProcessSonosEventQueue:
- """Queue like object for dispatching sonos events."""
-
- def __init__(self, player_id, callback_handler):
- """Initialize Sonos event queue."""
- self._callback_handler = callback_handler
- self._player_id = player_id
-
- def put(self, item, block=True, timeout=None):
- """Process event."""
- # pylint: disable=unused-argument
- self._callback_handler(self._player_id, item)
"""Spotify musicprovider support for MusicAssistant."""
import asyncio
import json
-import logging
import os
import platform
import time
from typing import List, Optional
from asyncio_throttle import Throttler
-from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
-from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all
+
+from music_assistant.helpers.app_vars import ( # noqa # pylint: disable=no-name-in-module
+ get_app_var,
+)
+from music_assistant.models.errors import LoginFailed
from music_assistant.helpers.util import parse_title_and_version
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import (
+from music_assistant.models.media_items import (
Album,
AlbumType,
Artist,
+ ContentType,
MediaItemProviderId,
+ MediaItemType,
+ MediaQuality,
MediaType,
Playlist,
- Radio,
- SearchResult,
+ StreamDetails,
+ StreamType,
Track,
- TrackQuality,
)
from music_assistant.models.provider import MusicProvider
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-PROV_ID = "spotify"
-PROV_NAME = "Spotify"
-
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = [
- ConfigEntry(
- entry_key=CONF_USERNAME,
- entry_type=ConfigEntryType.STRING,
- label=CONF_USERNAME,
- description="desc_spotify_username",
- ),
- ConfigEntry(
- entry_key=CONF_PASSWORD,
- entry_type=ConfigEntryType.PASSWORD,
- label=CONF_PASSWORD,
- description="desc_spotify_password",
- ),
-]
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = SpotifyProvider()
- await mass.register_provider(prov)
class SpotifyProvider(MusicProvider):
- """Implementation for the Spotify MusicProvider."""
-
- # pylint: disable=abstract-method
+ """Implementation of a Spotify MusicProvider."""
- __auth_token = None
- sp_user = None
- _username = None
- _password = None
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- @property
- def supported_mediatypes(self) -> List[MediaType]:
- """Return MediaTypes the provider supports."""
- return [
- MediaType.ALBUM,
+ def __init__(self, username: str, password: str) -> None:
+ """Initialize the Spotify provider."""
+ self._attr_id = "spotify"
+ self._attr_name = "Spotify"
+ self._attr_supported_mediatypes = [
MediaType.ARTIST,
- MediaType.PLAYLIST,
- # MediaType.RADIO, # TODO!
+ MediaType.ALBUM,
MediaType.TRACK,
+ MediaType.PLAYLIST
+ # TODO: Return spotify radio
]
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider based on config."""
- config = self.mass.config.get_provider_config(self.id)
- # pylint: disable=attribute-defined-outside-init
- self._cur_user = None
- self.sp_user = None
- if not config[CONF_USERNAME] or not config[CONF_PASSWORD]:
- LOGGER.debug("Username and password not set. Abort load of provider.")
- return False
- self._username = config[CONF_USERNAME]
- self._password = config[CONF_PASSWORD]
- self.__auth_token = {}
+ self._username = username
+ self._password = password
+ self._auth_token = None
+ self._sp_user = None
self._throttler = Throttler(rate_limit=4, period=1)
- token = await self.get_token()
- return token is not None
+ async def setup(self) -> None:
+ """Handle async initialization of the provider."""
+ if not self._username or not self._password:
+ raise LoginFailed("Invalid login credentials")
+ # try to get a token, raise if that fails
+ token = await self.get_token()
+ if not token:
+ raise LoginFailed(f"Login failed for user {self._username}")
async def search(
self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
- ) -> SearchResult:
+ ) -> List[MediaItemType]:
"""
Perform search on musicprovider.
: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 = SearchResult()
+ result = []
searchtypes = []
if MediaType.ARTIST in media_types:
searchtypes.append("artist")
searchtypes.append("playlist")
searchtype = ",".join(searchtypes)
params = {"q": search_query, "type": searchtype, "limit": limit}
- searchresult = await self._get_data("search", params=params)
- if searchresult:
+ if searchresult := await self._get_data("search", params=params):
if "artists" in searchresult:
- result.artists = [
+ result += [
await self._parse_artist(item)
for item in searchresult["artists"]["items"]
if (item and item["id"])
]
if "albums" in searchresult:
- result.albums = [
+ result += [
await self._parse_album(item)
for item in searchresult["albums"]["items"]
if (item and item["id"])
]
if "tracks" in searchresult:
- result.tracks = [
+ result += [
await self._parse_track(item)
for item in searchresult["tracks"]["items"]
if (item and item["id"])
]
if "playlists" in searchresult:
- result.playlists = [
+ result += [
await self._parse_playlist(item)
for item in searchresult["playlists"]["items"]
if (item and item["id"])
if (item and item["id"])
]
- async def get_radios(self) -> List[Radio]:
- """Retrieve library/subscribed radio stations from the provider."""
- return [] # TODO: Return spotify radio
-
async def get_artist(self, prov_artist_id) -> Artist:
"""Get full artist details by id."""
- artist_obj = await self._get_data("artists/%s" % prov_artist_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("albums/%s" % prov_album_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("tracks/%s" % prov_track_id)
+ track_obj = await self._get_data("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:
"""Add track(s) to playlist."""
track_uris = []
for track_id in prov_track_ids:
- track_uris.append("spotify:track:%s" % track_id)
+ 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)
"""Remove track(s) from playlist."""
track_uris = []
for track_id in prov_track_ids:
- track_uris.append({"uri": "spotify:track:%s" % track_id})
+ 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
# make sure that the token is still valid by just requesting it
await self.get_token()
spotty = self.get_spotty_binary()
- spotty_exec = (
- '%s -n temp -c "%s" -b 320 --pass-through --single-track spotify://track:%s'
- % (
- spotty,
- self.mass.config.data_path,
- track.item_id,
- )
- )
+ spotty_exec = f'{spotty} -n temp -c "/tmp" -b 320 --pass-through --single-track spotify://track:{track.item_id}'
return StreamDetails(
type=StreamType.EXECUTABLE,
item_id=track.item_id,
- provider=PROV_ID,
+ provider=self.id,
path=spotty_exec,
content_type=ContentType.OGG,
sample_rate=44100,
artist = Artist(
item_id=artist_obj["id"], provider=self.id, name=artist_obj["name"]
)
- artist.provider_ids.add(
- MediaItemProviderId(provider=PROV_ID, item_id=artist_obj["id"])
+ artist.provider_ids.append(
+ MediaItemProviderId(provider=self.id, item_id=artist_obj["id"])
)
if "genres" in artist_obj:
artist.metadata["genres"] = artist_obj["genres"]
async def _parse_album(self, album_obj):
"""Parse spotify album object to generic layout."""
- album = Album(item_id=album_obj["id"], provider=self.id)
- album.name, album.version = parse_title_and_version(album_obj["name"])
+ name, version = parse_title_and_version(album_obj["name"])
+ album = Album(
+ item_id=album_obj["id"], provider=self.id, name=name, version=version
+ )
for artist in album_obj["artists"]:
album.artist = await self._parse_artist(artist)
if album.artist:
album.metadata["spotify_url"] = album_obj["external_urls"]["spotify"]
if album_obj.get("explicit"):
album.metadata["explicit"] = str(album_obj["explicit"]).lower()
- album.provider_ids.add(
+ album.provider_ids.append(
MediaItemProviderId(
- provider=PROV_ID,
+ provider=self.id,
item_id=album_obj["id"],
- quality=TrackQuality.LOSSY_OGG,
+ quality=MediaQuality.LOSSY_OGG,
)
)
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.id,
+ name=name,
+ version=version,
duration=track_obj["duration_ms"] / 1000,
disc_number=track_obj["disc_number"],
track_number=track_obj["track_number"],
)
if artist:
- track.artists.add(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.add(artist)
- track.name, track.version = parse_title_and_version(track_obj["name"])
+ track.artists.append(artist)
+
track.metadata["explicit"] = str(track_obj["explicit"]).lower()
if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]:
track.isrc = track_obj["external_ids"]["isrc"]
track.metadata["explicit"] = True
if track_obj.get("external_urls"):
track.metadata["spotify_url"] = track_obj["external_urls"]["spotify"]
- track.provider_ids.add(
+ if track_obj.get("popularity"):
+ track.metadata["popularity"] = track_obj["popularity"]
+ track.provider_ids.append(
MediaItemProviderId(
- provider=PROV_ID,
+ provider=self.id,
item_id=track_obj["id"],
- quality=TrackQuality.LOSSY_OGG,
+ quality=MediaQuality.LOSSY_OGG,
available=not track_obj["is_local"] and track_obj["is_playable"],
)
)
async def _parse_playlist(self, playlist_obj):
"""Parse spotify playlist object to generic layout."""
- playlist = Playlist(item_id=playlist_obj["id"], provider=self.id)
- playlist.provider_ids.add(
- MediaItemProviderId(provider=PROV_ID, item_id=playlist_obj["id"])
+ playlist = Playlist(
+ item_id=playlist_obj["id"],
+ provider=self.id,
+ name=playlist_obj["name"],
+ owner=playlist_obj["owner"]["display_name"],
+ )
+ playlist.provider_ids.append(
+ MediaItemProviderId(provider=self.id, item_id=playlist_obj["id"])
)
- playlist.name = playlist_obj["name"]
- playlist.owner = playlist_obj["owner"]["display_name"]
playlist.is_editable = (
- playlist_obj["owner"]["id"] == self.sp_user["id"]
+ playlist_obj["owner"]["id"] == self._sp_user["id"]
or playlist_obj["collaborative"]
)
if playlist_obj.get("images"):
async def get_token(self):
"""Get auth token on spotify."""
# return existing token if we have one in memory
- if self.__auth_token and (
- self.__auth_token["expiresAt"] > int(time.time()) + 20
- ):
- return self.__auth_token
+ if self._auth_token and (self._auth_token["expiresAt"] > int(time.time()) + 20):
+ return self._auth_token
tokeninfo = {}
if not self._username or not self._password:
return tokeninfo
# retrieve token with spotty
tokeninfo = await self._get_token()
if tokeninfo:
- self.__auth_token = tokeninfo
- self.sp_user = await self._get_data("me")
- LOGGER.info("Succesfully logged in to Spotify as %s", self.sp_user["id"])
- self.__auth_token = tokeninfo
+ self._auth_token = tokeninfo
+ self._sp_user = await self._get_data("me")
+ self.logger.info(
+ "Succesfully logged in to Spotify as %s", self._sp_user["id"]
+ )
+ self._auth_token = tokeninfo
else:
- LOGGER.error("Login failed for user %s", self._username)
+ self.logger.error("Login failed for user %s", self._username)
return tokeninfo
async def _get_token(self):
"-p",
self._password,
"-c",
- self.mass.config.data_path,
+ "/tmp",
"--disable-discovery",
]
spotty = await asyncio.create_subprocess_exec(
try:
result = json.loads(stdout)
except JSONDecodeError:
- LOGGER.warning("Error while retrieving Spotify token!")
+ self.logger.warning("Error while retrieving Spotify token!")
return None
# transform token info to spotipy compatible format
if result and "accessToken" in result:
"""Get data from api."""
if not params:
params = {}
- url = "https://api.spotify.com/v1/%s" % endpoint
+ url = f"https://api.spotify.com/v1/{endpoint}"
params["market"] = "from_token"
params["country"] = "from_token"
token = await self.get_token()
if not token:
return None
- headers = {"Authorization": "Bearer %s" % token["accessToken"]}
+ headers = {"Authorization": f'Bearer {token["accessToken"]}'}
async with self._throttler:
async with self.mass.http_session.get(
url, headers=headers, params=params, verify_ssl=False
) as response:
result = await response.json()
if not result or "error" in result:
- LOGGER.error("%s - %s", endpoint, result)
+ self.logger.error("%s - %s", endpoint, result)
result = None
return result
"""Delete data from api."""
if not params:
params = {}
- url = "https://api.spotify.com/v1/%s" % endpoint
+ url = f"https://api.spotify.com/v1/{endpoint}"
token = await self.get_token()
if not token:
return None
- headers = {"Authorization": "Bearer %s" % token["accessToken"]}
+ headers = {"Authorization": f'Bearer {token["accessToken"]}'}
async with self.mass.http_session.delete(
url, headers=headers, params=params, json=data, verify_ssl=False
) as response:
"""Put data on api."""
if not params:
params = {}
- url = "https://api.spotify.com/v1/%s" % endpoint
+ url = f"https://api.spotify.com/v1/{endpoint}"
token = await self.get_token()
if not token:
return None
- headers = {"Authorization": "Bearer %s" % token["accessToken"]}
+ headers = {"Authorization": f'Bearer {token["accessToken"]}'}
async with self.mass.http_session.put(
url, headers=headers, params=params, json=data, verify_ssl=False
) as response:
"""Post data on api."""
if not params:
params = {}
- url = "https://api.spotify.com/v1/%s" % endpoint
+ url = f"https://api.spotify.com/v1/{endpoint}"
token = await self.get_token()
if not token:
return None
- headers = {"Authorization": "Bearer %s" % token["accessToken"]}
+ headers = {"Authorization": f'Bearer {token["accessToken"]}'}
async with self.mass.http_session.post(
url, headers=headers, params=params, json=data, verify_ssl=False
) as response:
+++ /dev/null
-"""Squeezebox emulated player provider."""
-
-import asyncio
-import logging
-from typing import List
-
-from music_assistant.constants import CONF_CROSSFADE_DURATION
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import callback, create_task
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
-from music_assistant.models.player_queue import QueueItem
-from music_assistant.models.provider import PlayerProvider
-
-from .constants import PROV_ID, PROV_NAME
-from .discovery import DiscoveryProtocol
-from .socket_client import SqueezeEvent, SqueezeSocketClient
-
-CONF_LAST_POWER = "last_power"
-CONF_LAST_VOLUME = "last_volume"
-
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = [] # we don't have any provider config entries (for now)
-PLAYER_FEATURES = [PlayerFeature.QUEUE, PlayerFeature.CROSSFADE, PlayerFeature.GAPLESS]
-PLAYER_CONFIG_ENTRIES = [] # we don't have any player config entries (for now)
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = PySqueezeProvider()
- await mass.register_provider(prov)
-
-
-class PySqueezeProvider(PlayerProvider):
- """Python implementation of SlimProto server."""
-
- _tasks = []
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider. Called on startup."""
- # start slimproto server
- create_task(asyncio.start_server(self._client_connected, "0.0.0.0", 3483))
-
- # setup discovery
- create_task(self.start_discovery())
-
- async def start_discovery(self):
- """Start discovery for players."""
- transport, _ = await self.mass.loop.create_datagram_endpoint(
- lambda: DiscoveryProtocol(self.mass.web.port),
- local_addr=("0.0.0.0", 3483),
- )
- try:
- while True:
- await asyncio.sleep(60) # serve forever
- finally:
- transport.close()
-
- async def _client_connected(self, reader, writer):
- """Handle a client connection on the socket."""
- addr = writer.get_extra_info("peername")
- LOGGER.debug("Socket client connected: %s", addr)
- socket_client = SqueezeSocketClient(self.mass, reader, writer)
-
- def handle_event(event: SqueezeEvent, socket_client: SqueezeSocketClient):
- player_id = socket_client.player_id
- if not player_id:
- return
- # always check if we already have this player as it might be reconnected
- player = self.mass.players.get_player(player_id)
- if not player:
- player = SqueezePlayer(self.mass, socket_client)
- player.set_socket_client(socket_client)
- # just update, the playermanager will take care of adding it if it's a new player
- player.handle_socket_client_event(event)
-
- socket_client.register_callback(handle_event)
-
-
-class SqueezePlayer(Player):
- """Squeezebox player."""
-
- def __init__(self, mass: MusicAssistant, socket_client: SqueezeSocketClient):
- """Initialize."""
- super().__init__()
- self.mass = mass
- self._socket_client = socket_client
-
- @property
- def available(self) -> bool:
- """Return current availablity of player."""
- return self._socket_client.connected
-
- @property
- def should_poll(self) -> bool:
- """Return True if this player should be polled for state updates."""
- return False
-
- @property
- def socket_client(self):
- """Return the uinderluing socket client for the player."""
- return self._socket_client
-
- def set_socket_client(self, socket_client: SqueezeSocketClient):
- """Set a (new) socket client to this player."""
- self._socket_client = socket_client
-
- async def on_remove(self) -> None:
- """Call when player is removed from the player manager."""
- self.socket_client.disconnect()
-
- @property
- def player_id(self) -> str:
- """Return player id (=mac address) of the player."""
- return self.socket_client.player_id
-
- @property
- def provider_id(self) -> str:
- """Return provider id of this player."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return name of the player."""
- return self.socket_client.name
-
- @property
- def volume_level(self):
- """Return current volume level of player."""
- return self.socket_client.volume_level
-
- @property
- def powered(self):
- """Return current power state of player."""
- return self.socket_client.powered
-
- @property
- def muted(self):
- """Return current mute state of player."""
- return self.socket_client.muted
-
- @property
- def state(self):
- """Return current state of player."""
- return PlayerState(self.socket_client.state)
-
- @property
- def elapsed_time(self):
- """Return elapsed_time of current playing track in (fractions of) seconds."""
- return self.socket_client.elapsed_seconds
-
- @property
- def elapsed_milliseconds(self) -> int:
- """Return (realtime) elapsed time of current playing media in milliseconds."""
- return self.socket_client.elapsed_milliseconds
-
- @property
- def current_uri(self):
- """Return uri of currently loaded track."""
- return self.socket_client.current_uri
-
- @property
- def features(self) -> List[PlayerFeature]:
- """Return list of features this player supports."""
- return PLAYER_FEATURES
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return player specific config entries (if any)."""
- return PLAYER_CONFIG_ENTRIES
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return the device info for this player."""
- return DeviceInfo(
- model=self.socket_client.device_type,
- address=self.socket_client.device_address,
- )
-
- async def cmd_stop(self):
- """Send stop command to player."""
- return await self.socket_client.cmd_stop()
-
- async def cmd_play(self):
- """Send play (unpause) command to player."""
- return await self.socket_client.cmd_play()
-
- async def cmd_pause(self):
- """Send pause command to player."""
- return await self.socket_client.cmd_pause()
-
- async def cmd_power_on(self) -> None:
- """Send POWER ON command to player."""
- # save power and volume state in cache
- cache_str = f"squeezebox_player_{self.player_id}"
- await self.mass.cache.set(cache_str, (True, self.volume_level))
- return await self.socket_client.cmd_power(True)
-
- async def cmd_power_off(self) -> None:
- """Send POWER OFF command to player."""
- # save power and volume state in cache
- cache_str = f"squeezebox_player_{self.player_id}"
- await self.mass.cache.set(cache_str, (False, self.volume_level))
- return await self.socket_client.cmd_power(False)
-
- async def cmd_volume_set(self, volume_level: int):
- """Send new volume level command to player."""
- return await self.socket_client.cmd_volume_set(volume_level)
-
- async def cmd_mute(self, muted: bool = False):
- """Send mute command to player."""
- return await self.socket_client.cmd_mute(muted)
-
- async def cmd_play_uri(self, uri: str):
- """Request player to start playing a single uri."""
- crossfade = self.mass.config.player_settings[self.player_id][
- CONF_CROSSFADE_DURATION
- ]
- return await self.socket_client.play_uri(uri, crossfade_duration=crossfade)
-
- async def cmd_next(self):
- """Send NEXT TRACK command to player."""
- queue = self.mass.players.get_player_queue(self.player_id)
- if queue:
- new_track = queue.get_item(queue.cur_index + 1)
- if new_track:
- return await self.cmd_play_uri(new_track.stream_url)
-
- async def cmd_previous(self):
- """Send PREVIOUS TRACK command to player."""
- queue = self.mass.players.get_player_queue(self.player_id)
- if queue:
- new_track = queue.get_item(queue.cur_index - 1)
- if new_track:
- return await self.cmd_play_uri(new_track.stream_url)
-
- async def cmd_queue_play_index(self, index: int):
- """
- Play item at index X on player's queue.
-
- :param index: (int) index of the queue item that should start playing
- """
- queue = self.mass.players.get_player_queue(self.player_id)
- if queue:
- new_track = queue.get_item(index)
- if new_track:
- return await self.cmd_play_uri(new_track.stream_url)
-
- async def cmd_queue_load(self, queue_items: List[QueueItem], repeat: bool = False):
- """
- Load/overwrite given items in the player's queue implementation.
-
- :param queue_items: a list of QueueItems
- """
- if queue_items:
- await self.cmd_play_uri(queue_items[0].stream_url)
- return await self.cmd_play_uri(queue_items[0].stream_url)
-
- async def cmd_queue_insert(
- self, queue_items: List[QueueItem], insert_at_index: int
- ):
- """
- Insert new items at position X into existing queue.
-
- If insert_at_index 0 or None, will start playing newly added item(s)
- :param queue_items: a list of QueueItems
- :param insert_at_index: queue position to insert new items
- """
- # queue handled by built-in queue controller
- # we only check the start index
- queue = self.mass.players.get_player_queue(self.player_id)
- if queue and insert_at_index == queue.cur_index:
- return await self.cmd_queue_play_index(insert_at_index)
-
- async def cmd_queue_append(self, queue_items: List[QueueItem]):
- """
- Append new items at the end of the queue.
-
- :param queue_items: a list of QueueItems
- """
- # automagically handled by built-in queue controller
-
- async def cmd_queue_update(self, queue_items: List[QueueItem]):
- """
- Overwrite the existing items in the queue, used for reordering.
-
- :param queue_items: a list of QueueItems
- """
- # automagically handled by built-in queue controller
-
- async def cmd_queue_clear(self):
- """Clear the player's queue."""
- # queue is handled by built-in queue controller but send stop
- return await self.cmd_stop()
-
- async def restore_states(self):
- """Restore power/volume states."""
- cache_str = f"squeezebox_player_{self.player_id}"
- cache_data = await self.mass.cache.get(cache_str)
- last_power, last_volume = cache_data if cache_data else (False, 40)
- await self.socket_client.cmd_volume_set(last_volume)
- await self.socket_client.cmd_power(last_power)
-
- @callback
- def handle_socket_client_event(self, event: SqueezeEvent):
- """Process incoming event from the socket client."""
- if event == SqueezeEvent.CONNECTED:
- # restore previous power/volume
- create_task(self.restore_states())
- elif event == SqueezeEvent.DECODER_READY:
- # tell player to load next queue track
- queue = self.mass.players.get_player_queue(self.player_id)
- if queue:
- next_item = queue.next_item
- if next_item:
- crossfade = self.mass.config.player_settings[self.player_id][
- CONF_CROSSFADE_DURATION
- ]
- create_task(
- self.socket_client.play_uri(
- next_item.stream_url,
- send_flush=False,
- crossfade_duration=crossfade,
- )
- )
- self.update_state()
+++ /dev/null
-"""Constants for Squeezebox emulation."""
-
-PROV_ID = "squeezebox"
-PROV_NAME = "Squeezebox emulation"
+++ /dev/null
-"""Squeezebox emulation discovery implementation."""
-
-import logging
-import socket
-import struct
-from collections import OrderedDict
-
-from music_assistant.helpers.util import get_hostname, get_ip
-
-LOGGER = logging.getLogger("squeezebox")
-
-
-class Datagram:
- """Description of a discovery datagram."""
-
- @classmethod
- def decode(cls, data):
- """Decode a datagram message."""
- if data[0] == "e":
- return TLVDiscoveryRequestDatagram(data)
- if data[0] == "E":
- return TLVDiscoveryResponseDatagram(data)
- if data[0] == "d":
- return ClientDiscoveryDatagram(data)
- if data[0] == "h":
- pass # Hello!
- if data[0] == "i":
- pass # IR
- if data[0] == "2":
- pass # i2c?
- if data[0] == "a":
- pass # ack!
-
-
-class ClientDiscoveryDatagram(Datagram):
- """Description of a client discovery datagram."""
-
- device = None
- firmware = None
- client = None
-
- def __init__(self, data):
- """Initialize class."""
- msg = struct.unpack("!cxBB8x6B", data.encode())
- assert msg[0] == "d"
- self.device = msg[1]
- self.firmware = hex(msg[2])
- self.client = ":".join(["%02x" % (x,) for x in msg[3:]])
-
- def __repr__(self):
- """Print the class contents."""
- return "<%s device=%r firmware=%r client=%r>" % (
- self.__class__.__name__,
- self.device,
- self.firmware,
- self.client,
- )
-
-
-class DiscoveryResponseDatagram(Datagram):
- """Description of a discovery response datagram."""
-
- def __init__(self, hostname, port):
- """Initialize class."""
- # pylint: disable=unused-argument
- hostname = hostname[:16].encode("UTF-8")
- hostname += (16 - len(hostname)) * "\x00"
- self.packet = struct.pack("!c16s", "D", hostname).decode()
-
-
-class TLVDiscoveryRequestDatagram(Datagram):
- """Description of a discovery request datagram."""
-
- def __init__(self, data):
- """Initialize class."""
- requestdata = OrderedDict()
- assert data[0] == "e"
- idx = 1
- length = len(data) - 5
- while idx <= length:
- typ, _len = struct.unpack_from("4sB", data.encode(), idx)
- if _len:
- val = data[idx + 5 : idx + 5 + _len]
- idx += 5 + _len
- else:
- val = None
- idx += 5
- typ = typ.decode()
- requestdata[typ] = val
- self.data = requestdata
-
- def __repr__(self):
- """Pretty print class."""
- return "<%s data=%r>" % (self.__class__.__name__, self.data.items())
-
-
-class TLVDiscoveryResponseDatagram(Datagram):
- """Description of a TLV discovery response datagram."""
-
- def __init__(self, responsedata):
- """Initialize class."""
- parts = ["E"] # new discovery format
- for typ, value in responsedata.items():
- if value is None:
- value = ""
- elif len(value) > 255:
- # Response too long, truncating to 255 bytes
- value = value[:255]
- parts.extend((typ, chr(len(value)), value))
- self.packet = "".join(parts)
-
-
-class DiscoveryProtocol:
- """Description of a discovery protocol."""
-
- def __init__(self, web_port):
- """Initialze class."""
- self.web_port = web_port
- self.transport = None
-
- def connection_made(self, transport):
- """Call on connection."""
- self.transport = transport
- # Allow receiving multicast broadcasts
- sock = self.transport.get_extra_info("socket")
- group = socket.inet_aton("239.255.255.250")
- mreq = struct.pack("4sL", group, socket.INADDR_ANY)
- sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
-
- @classmethod
- def error_received(cls, exc):
- """Call on Error."""
- LOGGER.error(exc)
-
- @classmethod
- def connection_lost(cls, *args, **kwargs):
- """Call on Connection lost."""
- # pylint: disable=unused-argument
- LOGGER.debug("Connection lost to discovery")
-
- def build_tlv_response(self, requestdata):
- """Build TLV Response message."""
- responsedata = OrderedDict()
- for typ, value in requestdata.items():
- if typ == "NAME":
- # send full host name - no truncation
- value = get_hostname()
- elif typ == "IPAD":
- # send ipaddress as a string only if it is set
- value = get_ip()
- # :todo: IPv6
- if value == "0.0.0.0":
- # do not send back an ip address
- typ = None
- elif typ == "JSON":
- # send port as a string
- json_port = self.web_port
- value = str(json_port)
- elif typ == "VERS":
- # send server version
- value = "7.9"
- elif typ == "UUID":
- # send server uuid
- value = "musicassistant"
- else:
- LOGGER.debug("Unexpected information request: %r", typ)
- typ = None
- if typ:
- responsedata[typ] = value
- return responsedata
-
- def datagram_received(self, data, addr):
- """Datagram received callback."""
- # pylint: disable=broad-except
- try:
- data = data.decode()
- dgram = Datagram.decode(data)
- if isinstance(dgram, ClientDiscoveryDatagram):
- self.send_discovery_response(addr)
- elif isinstance(dgram, TLVDiscoveryRequestDatagram):
- resonsedata = self.build_tlv_response(dgram.data)
- self.send_tlv_discovery_response(resonsedata, addr)
- except Exception as exc:
- LOGGER.exception(exc)
-
- def send_discovery_response(self, addr):
- """Send discovery response message."""
- dgram = DiscoveryResponseDatagram(get_hostname(), 3483)
- self.transport.sendto(dgram.packet.encode(), addr)
-
- def send_tlv_discovery_response(self, resonsedata, addr):
- """Send TLV discovery response message."""
- dgram = TLVDiscoveryResponseDatagram(resonsedata)
- self.transport.sendto(dgram.packet.encode(), addr)
+++ /dev/null
-"""Socketclient implementation for Squeezebox emulated player provider."""
-
-import asyncio
-import logging
-import re
-import struct
-import time
-from enum import Enum
-from typing import Callable
-
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task, run_periodic
-
-from .constants import PROV_ID
-
-LOGGER = logging.getLogger(PROV_ID)
-
-
-# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO
-DEVICE_TYPE = {
- 2: "squeezebox",
- 3: "softsqueeze",
- 4: "squeezebox2",
- 5: "transporter",
- 6: "softsqueeze3",
- 7: "receiver",
- 8: "squeezeslave",
- 9: "controller",
- 10: "boom",
- 11: "softboom",
- 12: "squeezeplay",
-}
-
-STATE_PLAYING = "playing"
-STATE_IDLE = "idle"
-STATE_PAUSED = "paused"
-
-
-class SqueezeEvent(Enum):
- """Enum with the events that can happen in the socket client."""
-
- CONNECTED = 0
- STATE_UPDATED = 1
- DECODER_READY = 2
- DISCONNECTED = 3
-
-
-class SqueezeSocketClient:
- """Squeezebox socket client."""
-
- def __init__(
- self,
- mass: MusicAssistant,
- reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter,
- event_callback: Callable = None,
- ):
- """Initialize the socket client."""
- self.mass = mass
- self._reader = reader
- self._writer = writer
- self._player_id = ""
- self._device_type = ""
- self._device_name = ""
- self._last_volume = 0
- self._last_heartbeat = 0
- self._volume_control = PySqueezeVolume()
- self._powered = False
- self._muted = False
- self._state = STATE_IDLE
- self._elapsed_seconds = 0
- self._elapsed_milliseconds = 0
- self._current_uri = ""
- self._connected = True
- self._event_callbacks = []
- self._tasks = [
- create_task(self._socket_reader()),
- create_task(self._send_heartbeat()),
- ]
-
- def disconnect(self) -> None:
- """Disconnect socket client."""
- for task in self._tasks:
- if not task.cancelled():
- task.cancel()
-
- def register_callback(self, callb: Callable):
- """Register event callback. Returns function to deregister."""
-
- def unregister():
- self._event_callbacks.remove(callb)
-
- self._event_callbacks.append(callb)
- return unregister
-
- def signal_event(self, event):
- """Signal event to registered listeners."""
- for listener in self._event_callbacks:
- listener(event, self)
-
- @property
- def connected(self):
- """Return connection state of the socket."""
- return self._connected
-
- @property
- def player_id(self) -> str:
- """Return player id (=mac address) of the player."""
- return self._player_id
-
- @property
- def device_type(self) -> str:
- """Return device type of the player."""
- return self._device_type
-
- @property
- def device_address(self) -> str:
- """Return device IP address of the player."""
- dev_address = self._writer.get_extra_info("peername")
- return dev_address[0] if dev_address else ""
-
- @property
- def name(self) -> str:
- """Return name of the player."""
- if self._device_name:
- return self._device_name
- return f"{self.device_type}: {self.player_id}"
-
- @property
- def volume_level(self):
- """Return current volume level of player."""
- return self._volume_control.volume
-
- @property
- def powered(self):
- """Return current power state of player."""
- return self._powered
-
- @property
- def muted(self):
- """Return current mute state of player."""
- return self._muted
-
- @property
- def state(self):
- """Return current state of player."""
- return self._state
-
- @property
- def elapsed_seconds(self):
- """Return elapsed_time of current playing track in (fractions of) seconds."""
- return self._elapsed_seconds
-
- @property
- def elapsed_milliseconds(self) -> int:
- """Return (realtime) elapsed time of current playing media in milliseconds."""
- return self._elapsed_milliseconds + int(
- (time.time() * 1000) - (self._last_heartbeat * 1000)
- )
-
- @property
- def current_uri(self):
- """Return uri of currently loaded track."""
- return self._current_uri
-
- async def _initialize_player(self):
- """Set some startup settings for the player."""
- # send version
- await self._send_frame(b"vers", b"7.8")
- await self._send_frame(b"setd", struct.pack("B", 0))
- await self._send_frame(b"setd", struct.pack("B", 4))
-
- async def cmd_stop(self):
- """Send stop command to player."""
- await self.send_strm(b"q")
-
- async def cmd_play(self):
- """Send play (unpause) command to player."""
- await self.send_strm(b"u")
-
- async def cmd_pause(self):
- """Send pause command to player."""
- await self.send_strm(b"p")
-
- async def cmd_power(self, powered: bool = True):
- """Send power command to player."""
- # power is not supported so abuse mute instead
- power_int = 1 if powered else 0
- await self._send_frame(b"aude", struct.pack("2B", power_int, 1))
- self._powered = powered
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- async def cmd_volume_set(self, volume_level: int):
- """Send new volume level command to player."""
- self._volume_control.volume = volume_level
- old_gain = self._volume_control.old_gain()
- new_gain = self._volume_control.new_gain()
- await self._send_frame(
- b"audg",
- struct.pack("!LLBBLL", old_gain, old_gain, 1, 255, new_gain, new_gain),
- )
-
- async def cmd_mute(self, muted: bool = False):
- """Send mute command to player."""
- muted_int = 0 if muted else 1
- await self._send_frame(b"aude", struct.pack("2B", muted_int, 0))
- self.muted = muted
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- async def play_uri(
- self, uri: str, send_flush: bool = True, crossfade_duration: int = 0
- ):
- """Request player to start playing a single uri."""
- if send_flush:
- await self.send_strm(b"f", autostart=b"0")
- self._current_uri = uri
- self._powered = True
- enable_crossfade = crossfade_duration > 0
- command = b"s"
- # we use direct stream for now so let the player do the messy work with buffers
- autostart = b"3"
- trans_type = b"1" if enable_crossfade else b"0"
- uri = "/stream" + uri.split("/stream")[1]
- # extract host and port from uri
- regex = "(?:http.*://)?(?P<host>[^:/ ]+).?(?P<port>[0-9]*).*"
- regex_result = re.search(regex, uri)
- host = regex_result.group("host") # 'www.abc.com'
- port = regex_result.group("port") # '123'
- if not port and uri.startswith("https"):
- port = 443
- elif not port:
- port = 80
- headers = f"Connection: close\r\nAccept: */*\r\nHost: {host}:{port}\r\n"
- httpreq = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers)
- await self.send_strm(
- command,
- autostart=autostart,
- trans_type=trans_type,
- trans_duration=crossfade_duration,
- httpreq=httpreq.encode("utf-8"),
- )
-
- @run_periodic(5)
- async def _send_heartbeat(self):
- """Send periodic heartbeat message to player."""
- if not self._connected:
- return
- timestamp = int(time.time())
- await self.send_strm(b"t", replay_gain=timestamp, flags=0)
-
- async def _send_frame(self, command, data):
- """Send command to Squeeze player."""
- if self._reader.at_eof() or self._writer.is_closing():
- LOGGER.debug("Socket is disconnected.")
- self._connected = False
- return
- packet = struct.pack("!H", len(data) + 4) + command + data
- try:
- self._writer.write(packet)
- await self._writer.drain()
- except ConnectionResetError:
- self._connected = False
- self.signal_event(SqueezeEvent.DISCONNECTED)
-
- async def _socket_reader(self):
- """Handle incoming data from socket."""
- buffer = b""
- # keep reading bytes from the socket
- while not (self._reader.at_eof() or self._writer.is_closing()):
- data = await self._reader.read(64)
- # handle incoming data from socket
- buffer = buffer + data
- del data
- if len(buffer) > 8:
- # construct operation and
- operation, length = buffer[:4], buffer[4:8]
- plen = struct.unpack("!I", length)[0] + 8
- if len(buffer) >= plen:
- packet, buffer = buffer[8:plen], buffer[plen:]
- operation = operation.strip(b"!").strip().decode().lower()
- handler = getattr(self, f"_process_{operation}", None)
- if handler is None:
- LOGGER.warning("No handler for %s", operation)
- else:
- handler(packet)
- # EOF reached: socket is disconnected
- LOGGER.debug("Socket disconnected: %s", self._writer.get_extra_info("peername"))
- self._connected = False
- self.signal_event(SqueezeEvent.DISCONNECTED)
-
- async def send_strm(
- self,
- command=b"q",
- formatbyte=b"f",
- autostart=b"0",
- samplesize=b"?",
- samplerate=b"?",
- channels=b"?",
- endian=b"?",
- threshold=0,
- spdif=b"0",
- trans_duration=0,
- trans_type=b"0",
- flags=0x40,
- output_threshold=0,
- replay_gain=0,
- server_port=8095,
- server_ip=0,
- httpreq=b"",
- ):
- """Create stream request message based on given arguments."""
- data = struct.pack(
- "!cccccccBcBcBBBLHL",
- command,
- autostart,
- formatbyte,
- samplesize,
- samplerate,
- channels,
- endian,
- threshold,
- spdif,
- trans_duration,
- trans_type,
- flags,
- output_threshold,
- 0,
- replay_gain,
- server_port,
- server_ip,
- )
- await self._send_frame(b"strm", data + httpreq)
-
- def _process_helo(self, data):
- """Process incoming HELO event from player (player connected)."""
- # pylint: disable=unused-variable
- # player connected
- (dev_id, rev, mac) = struct.unpack("BB6s", data[:8])
- device_mac = ":".join("%02x" % x for x in mac)
- self._player_id = str(device_mac).lower()
- self._device_type = DEVICE_TYPE.get(dev_id, "unknown device")
- LOGGER.debug("Player connected: %s", self.name)
- create_task(self._initialize_player())
- self.signal_event(SqueezeEvent.CONNECTED)
-
- def _process_stat(self, data):
- """Redirect incoming STAT event from player to correct method."""
- event = data[:4].decode()
- event_data = data[4:]
- if event == b"\x00\x00\x00\x00":
- # Presumed informational stat message
- return
- event_handler = getattr(self, "_process_stat_%s" % event.lower(), None)
- if event_handler is None:
- LOGGER.debug("Unhandled event: %s - event_data: %s", event, event_data)
- else:
- create_task(event_handler, data[4:])
-
- def _process_stat_aude(self, data):
- """Process incoming stat AUDe message (power level and mute)."""
- (spdif_enable, dac_enable) = struct.unpack("2B", data[:4])
- powered = spdif_enable or dac_enable
- self._powered = powered
- self._muted = not powered
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- def _process_stat_audg(self, data):
- """Process incoming stat AUDg message (volume level)."""
- # TODO: process volume level
- LOGGER.debug("AUDg received - Volume level: %s", data)
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- def _process_stat_stmd(self, data):
- """Process incoming stat STMd message (decoder ready)."""
- # pylint: disable=unused-argument
- LOGGER.debug("STMd received - Decoder Ready for next track.")
- self.signal_event(SqueezeEvent.DECODER_READY)
-
- def _process_stat_stmf(self, data):
- """Process incoming stat STMf message (connection closed)."""
- # pylint: disable=unused-argument
- LOGGER.debug("STMf received - connection closed.")
- self._state = STATE_IDLE
- self._elapsed_milliseconds = 0
- self._elapsed_seconds = 0
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- @classmethod
- def _process_stat_stmo(cls, data):
- """
- Process incoming stat STMo message.
-
- No more decoded (uncompressed) data to play; triggers rebuffering.
- """
- # pylint: disable=unused-argument
- LOGGER.warning("STMo received - output underrun.")
-
- def _process_stat_stmp(self, data):
- """Process incoming stat STMp message: Pause confirmed."""
- # pylint: disable=unused-argument
- LOGGER.debug("STMp received - pause confirmed.")
- self._state = STATE_PAUSED
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- def _process_stat_stmr(self, data):
- """Process incoming stat STMr message: Resume confirmed."""
- # pylint: disable=unused-argument
- LOGGER.debug("STMr received - resume confirmed.")
- self._state = STATE_PLAYING
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- def _process_stat_stms(self, data):
- # pylint: disable=unused-argument
- """Process incoming stat STMs message: Playback of new track has started."""
- LOGGER.debug("STMs received - playback of new track has started.")
- self._state = STATE_PLAYING
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- def _process_stat_stmt(self, data):
- """Process incoming stat STMt message: heartbeat from client."""
- # pylint: disable=unused-variable
- self._last_heartbeat = time.time()
- (
- num_crlf,
- mas_initialized,
- mas_mode,
- rptr,
- wptr,
- bytes_received_h,
- bytes_received_l,
- signal_strength,
- jiffies,
- output_buffer_size,
- output_buffer_fullness,
- elapsed_seconds,
- voltage,
- elapsed_milliseconds,
- timestamp,
- error_code,
- ) = struct.unpack("!BBBLLLLHLLLLHLLH", data)
- if self.state == STATE_PLAYING:
- # elapsed seconds is weird when player is buffering etc.
- # only rely on it if player is playing
- self._elapsed_milliseconds = elapsed_milliseconds
- if self._elapsed_seconds != elapsed_seconds:
- self._elapsed_seconds = elapsed_seconds
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- def _process_stat_stmu(self, data):
- """Process incoming stat STMu message: Buffer underrun: Normal end of playback."""
- # pylint: disable=unused-argument
- LOGGER.debug("STMu received - end of playback.")
- self._state = STATE_IDLE
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
- def _process_stat_stml(self, data):
- """Process incoming stat STMl message: Buffer threshold reached."""
- # pylint: disable=unused-argument
- LOGGER.debug("STMl received - Buffer threshold reached.")
- # autoplay 0 or 2: start playing by send unpause command when buffer full
- # create_task(self.send_strm(b"u"))
-
- def _process_stat_stmn(self, data):
- """Process incoming stat STMn message: player couldn't decode stream."""
- # pylint: disable=unused-argument
- LOGGER.debug("STMn received - player couldn't decode stream.")
- # request next track when this happens
- self.signal_event(SqueezeEvent.DECODER_READY)
-
- def _process_resp(self, data):
- """Process incoming RESP message: Response received at player."""
- LOGGER.debug("RESP received - Response received at player.")
- # send continue (used when autoplay 1 or 3)
- create_task(self._send_frame, b"cont", b"0")
-
- def _process_setd(self, data):
- """Process incoming SETD message: Get/set player firmware settings."""
- cmd_id = data[0]
- if cmd_id == 0:
- # received player name
- data = data[1:].decode()
- self._device_name = data
- self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-
-class PySqueezeVolume:
- """Represents a sound volume. This is an awful lot more complex than it sounds."""
-
- minimum = 0
- maximum = 100
- step = 1
-
- # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source
- # i don't know how much magic it contains, or any way I can test it
- old_map = [
- 0,
- 1,
- 1,
- 1,
- 2,
- 2,
- 2,
- 3,
- 3,
- 4,
- 5,
- 5,
- 6,
- 6,
- 7,
- 8,
- 9,
- 9,
- 10,
- 11,
- 12,
- 13,
- 14,
- 15,
- 16,
- 16,
- 17,
- 18,
- 19,
- 20,
- 22,
- 23,
- 24,
- 25,
- 26,
- 27,
- 28,
- 29,
- 30,
- 32,
- 33,
- 34,
- 35,
- 37,
- 38,
- 39,
- 40,
- 42,
- 43,
- 44,
- 46,
- 47,
- 48,
- 50,
- 51,
- 53,
- 54,
- 56,
- 57,
- 59,
- 60,
- 61,
- 63,
- 65,
- 66,
- 68,
- 69,
- 71,
- 72,
- 74,
- 75,
- 77,
- 79,
- 80,
- 82,
- 84,
- 85,
- 87,
- 89,
- 90,
- 92,
- 94,
- 96,
- 97,
- 99,
- 101,
- 103,
- 104,
- 106,
- 108,
- 110,
- 112,
- 113,
- 115,
- 117,
- 119,
- 121,
- 123,
- 125,
- 127,
- 128,
- ]
-
- # new gain parameters, from the same place
- total_volume_range = -50 # dB
- step_point = (
- -1
- ) # Number of steps, up from the bottom, where a 2nd volume ramp kicks in.
- step_fraction = (
- 1 # fraction of totalVolumeRange where alternate volume ramp kicks in.
- )
-
- def __init__(self):
- """Initialize class."""
- self.volume = 50
-
- def increment(self):
- """Increment the volume."""
- self.volume += self.step
- if self.volume > self.maximum:
- self.volume = self.maximum
-
- def decrement(self):
- """Decrement the volume."""
- self.volume -= self.step
- if self.volume < self.minimum:
- self.volume = self.minimum
-
- def old_gain(self):
- """Return the "Old" gain value as required by the squeezebox."""
- return self.old_map[self.volume]
-
- def decibels(self):
- """Return the "new" gain value."""
- # pylint: disable=invalid-name
-
- step_db = self.total_volume_range * self.step_fraction
- max_volume_db = 0 # different on the boom?
-
- # Equation for a line:
- # y = mx+b
- # y1 = mx1+b, y2 = mx2+b.
- # y2-y1 = m(x2 - x1)
- # y2 = m(x2 - x1) + y1
- slope_high = max_volume_db - step_db / (100.0 - self.step_point)
- slope_low = step_db - self.total_volume_range / (self.step_point - 0.0)
- x2 = self.volume
- if x2 > self.step_point:
- m = slope_high
- x1 = 100
- y1 = max_volume_db
- else:
- m = slope_low
- x1 = 0
- y1 = self.total_volume_range
- return m * (x2 - x1) + y1
-
- def new_gain(self):
- """Return new gainvalue of the volume control."""
- decibel = self.decibels()
- floatmult = 10 ** (decibel / 20.0)
- # avoid rounding errors somehow
- if -30 <= decibel <= 0:
- return int(floatmult * (1 << 8) + 0.5) * (1 << 8)
- return int((floatmult * (1 << 16)) + 0.5)
--- /dev/null
+"""Tune-In musicprovider support for MusicAssistant."""
+from typing import List, Optional
+
+from asyncio_throttle import Throttler
+
+from music_assistant.models.media_items import (
+ ContentType,
+ MediaItemProviderId,
+ MediaItemType,
+ MediaQuality,
+ MediaType,
+ Radio,
+ StreamDetails,
+ StreamType,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class TuneInProvider(MusicProvider):
+ """Provider implementation for Tune In."""
+
+ def __init__(self, username: str | None) -> None:
+ """Initialize the provider."""
+ self._attr_id = "tunein"
+ self._attr_name = "Tune-in Radio"
+ self._attr_supported_mediatypes = [MediaType.RADIO]
+ self._username = username
+ self._throttler = Throttler(rate_limit=1, period=1)
+
+ async def setup(self) -> None:
+ """Handle async initialization of the provider."""
+ # we have nothing to setup
+
+ 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 = []
+ # TODO: search for radio stations
+ return result
+
+ async def get_library_radios(self) -> List[Radio]:
+ """Retrieve library/subscribed radio stations from the provider."""
+ params = {"c": "presets"}
+ result = await self._get_data("Browse.ashx", params)
+ if result and "body" in result:
+ return [
+ await self._parse_radio(item)
+ for item in result["body"]
+ if item["type"] == "audio"
+ ]
+ return []
+
+ async def get_radio(self, prov_radio_id: str) -> Radio:
+ """Get radio station details."""
+ prov_radio_id = prov_radio_id.split("--")[0]
+ radio = None
+ 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]
+ radio = await self._parse_radio(item)
+ return radio
+
+ async def _parse_radio(self, details: dict) -> 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]
+ radio = Radio(item_id=details["preset_id"], provider=self.id, name=name)
+ # parse stream urls and format
+ stream_info = await self._get_stream_urls(radio.item_id)
+ for stream in stream_info["body"]:
+ if stream["media_type"] == "aac":
+ quality = MediaQuality.LOSSY_AAC
+ elif stream["media_type"] == "ogg":
+ quality = MediaQuality.LOSSY_OGG
+ else:
+ quality = MediaQuality.LOSSY_MP3
+ radio.provider_ids.append(
+ MediaItemProviderId(
+ provider=self.id,
+ item_id=f'{details["preset_id"]}--{stream["media_type"]}',
+ quality=quality,
+ details=stream["url"],
+ )
+ )
+ # image
+ if "image" in details:
+ radio.metadata["image"] = details["image"]
+ elif "logo" in details:
+ radio.metadata["image"] = details["logo"]
+ return radio
+
+ async def _get_stream_urls(self, radio_id):
+ """Return the stream urls for the given radio id."""
+ radio_id = radio_id.split("--")[0]
+ params = {"id": radio_id}
+ res = await self._get_data("Tune.ashx", params)
+ return res
+
+ async def get_stream_details(self, item_id: str) -> StreamDetails:
+ """Get streamdetails for a radio station."""
+ radio_id = item_id.split("--")[0]
+ if len(item_id.split("--")) > 1:
+ media_type = item_id.split("--")[1]
+ else:
+ media_type = ""
+ stream_info = await self._get_stream_urls(radio_id)
+ for stream in stream_info["body"]:
+ if stream["media_type"] == media_type or not media_type:
+ return StreamDetails(
+ type=StreamType.URL,
+ item_id=item_id,
+ provider=self.id,
+ path=stream["url"],
+ content_type=ContentType(stream["media_type"]),
+ sample_rate=44100,
+ bit_depth=16,
+ media_type=MediaType.RADIO,
+ details=stream,
+ )
+ return None
+
+ async def _get_data(self, endpoint, params=None):
+ """Get data from api."""
+ if not params:
+ params = {}
+ url = f"https://opml.radiotime.com/{endpoint}"
+ params["render"] = "json"
+ params["formats"] = "ogg,aac,wma,mp3"
+ params["username"] = self._username
+ params["partnerId"] = "1"
+ async with self._throttler:
+ async with self.mass.http_session.get(
+ url, params=params, verify_ssl=False
+ ) as response:
+ result = await response.json()
+ if not result or "error" in result:
+ self.logger.error(url)
+ self.logger.error(params)
+ result = None
+ return result
+++ /dev/null
-"""Tune-In musicprovider support for MusicAssistant."""
-import logging
-from typing import List, Optional
-
-from asyncio_throttle import Throttler
-from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import (
- MediaItemProviderId,
- MediaType,
- Radio,
- SearchResult,
- TrackQuality,
-)
-from music_assistant.models.provider import MusicProvider
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-PROV_ID = "tunein"
-PROV_NAME = "TuneIn Radio"
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = [
- ConfigEntry(
- entry_key=CONF_USERNAME,
- entry_type=ConfigEntryType.STRING,
- description=CONF_USERNAME,
- ),
- ConfigEntry(
- entry_key=CONF_PASSWORD,
- entry_type=ConfigEntryType.PASSWORD,
- description=CONF_PASSWORD,
- ),
-]
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = TuneInProvider()
- await mass.register_provider(prov)
-
-
-class TuneInProvider(MusicProvider):
- """Provider implementation for Tune In."""
-
- # pylint: disable=abstract-method
-
- _username = None
- _password = None
- _throttler = None
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- @property
- def supported_mediatypes(self) -> List[MediaType]:
- """Return MediaTypes the provider supports."""
- return [MediaType.RADIO]
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider based on config."""
- # pylint: disable=attribute-defined-outside-init
- config = self.mass.config.get_provider_config(self.id)
- if not config[CONF_USERNAME] or not config[CONF_PASSWORD]:
- LOGGER.debug("Username and password not set. Abort load of provider.")
- return False
- self._username = config[CONF_USERNAME]
- self._password = config[CONF_PASSWORD]
- self._throttler = Throttler(rate_limit=1, period=1)
- return True
-
- async def search(
- self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
- ) -> SearchResult:
- """
- 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 = SearchResult()
- # TODO: search for radio stations
- return result
-
- async def get_library_radios(self) -> List[Radio]:
- """Retrieve library/subscribed radio stations from the provider."""
- params = {"c": "presets"}
- result = await self._get_data("Browse.ashx", params)
- if result and "body" in result:
- return [
- await self._parse_radio(item)
- for item in result["body"]
- if item["type"] == "audio"
- ]
- return []
-
- async def get_radio(self, prov_radio_id: str) -> Radio:
- """Get radio station details."""
- radio = None
- 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]
- radio = await self._parse_radio(item)
- return radio
-
- async def _parse_radio(self, details: dict) -> Radio:
- """Parse Radio object from json obj returned from api."""
- radio = Radio(item_id=details["preset_id"], provider=PROV_ID)
- if "name" in details:
- radio.name = details["name"]
- else:
- # parse name from text attr
- name = details["text"]
- if " | " in name:
- name = name.split(" | ")[1]
- name = name.split(" (")[0]
- radio.name = name
- # parse stream urls and format
- stream_info = await self._get_stream_urls(radio.item_id)
- for stream in stream_info["body"]:
- if stream["media_type"] == "aac":
- quality = TrackQuality.LOSSY_AAC
- elif stream["media_type"] == "ogg":
- quality = TrackQuality.LOSSY_OGG
- else:
- quality = TrackQuality.LOSSY_MP3
- radio.provider_ids.add(
- MediaItemProviderId(
- provider=PROV_ID,
- item_id="%s--%s" % (details["preset_id"], stream["media_type"]),
- quality=quality,
- details=stream["url"],
- )
- )
- # image
- if "image" in details:
- radio.metadata["image"] = details["image"]
- elif "logo" in details:
- radio.metadata["image"] = details["logo"]
- return radio
-
- async def _get_stream_urls(self, radio_id):
- """Return the stream urls for the given radio id."""
- params = {"id": radio_id}
- res = await self._get_data("Tune.ashx", params)
- return res
-
- async def get_stream_details(self, item_id: str) -> StreamDetails:
- """Get streamdetails for a radio station."""
- radio_id = item_id.split("--")[0]
- if len(item_id.split("--")) > 1:
- media_type = item_id.split("--")[1]
- else:
- media_type = ""
- stream_info = await self._get_stream_urls(radio_id)
- for stream in stream_info["body"]:
- if stream["media_type"] == media_type or not media_type:
- return StreamDetails(
- type=StreamType.URL,
- item_id=item_id,
- provider=PROV_ID,
- path=stream["url"],
- content_type=ContentType(stream["media_type"]),
- sample_rate=44100,
- bit_depth=16,
- media_type=MediaType.RADIO,
- details=stream,
- )
- return None
-
- async def _get_data(self, endpoint, params=None):
- """Get data from api."""
- if not params:
- params = {}
- url = "https://opml.radiotime.com/%s" % endpoint
- params["render"] = "json"
- params["formats"] = "ogg,aac,wma,mp3"
- params["username"] = self._username
- params["partnerId"] = "1"
- async with self._throttler:
- async with self.mass.http_session.get(
- url, params=params, verify_ssl=False
- ) as response:
- result = await response.json()
- if not result or "error" in result:
- LOGGER.error(url)
- LOGGER.error(params)
- result = None
- return result
+++ /dev/null
-"""Group player provider: enables grouping of all Players."""
-
-import asyncio
-import logging
-from typing import List
-
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task
-from music_assistant.models.config_entry import (
- ConfigEntry,
- ConfigEntryType,
- ConfigValueOption,
-)
-from music_assistant.models.player import DeviceInfo, Player, PlayerState
-from music_assistant.models.provider import PlayerProvider
-
-PROV_ID = "universal_group"
-PROV_NAME = "Universal Group player"
-LOGGER = logging.getLogger(PROV_ID)
-
-CONF_PLAYER_COUNT = "group_player_count"
-CONF_PLAYERS = "group_player_players"
-CONF_MASTER = "group_player_master"
-
-CONFIG_ENTRIES = [
- ConfigEntry(
- entry_key=CONF_PLAYER_COUNT,
- entry_type=ConfigEntryType.INT,
- description=CONF_PLAYER_COUNT,
- default_value=1,
- range=(0, 10),
- )
-]
-
-
-async def setup(mass):
- """Perform async setup of this Plugin/Provider."""
- prov = GroupPlayerProvider()
- await mass.register_provider(prov)
-
-
-class GroupPlayerProvider(PlayerProvider):
- """PlayerProvider which allows users to group players."""
-
- @property
- def id(self) -> str:
- """Return provider ID for this provider."""
- return PROV_ID
-
- @property
- def name(self) -> str:
- """Return provider Name for this provider."""
- return PROV_NAME
-
- @property
- def config_entries(self) -> List[ConfigEntry]:
- """Return Config Entries for this provider."""
- return CONFIG_ENTRIES
-
- async def on_start(self) -> bool:
- """Handle initialization of the provider based on config."""
- conf = self.mass.config.player_providers[PROV_ID]
- for index in range(conf[CONF_PLAYER_COUNT]):
- player = GroupPlayer(self.mass, index)
- await self.mass.players.add_player(player)
- return True
-
- async def on_stop(self):
- """Handle correct close/cleanup of the provider on exit. Called on shutdown."""
- for player in self.players:
- await player.cmd_stop()
-
-
-class GroupPlayer(Player):
- """Model for a group player."""
-
- def __init__(self, mass: MusicAssistant, player_index: int):
- """Initialize."""
- super().__init__()
- self.mass = mass
- self._player_index = player_index
- self._player_id = f"{PROV_ID}_{player_index}"
- self._provider_id = PROV_ID
- self._name = f"{PROV_NAME} {player_index}"
- self._powered = False
- self._state = PlayerState.IDLE
- self._available = True
- self._current_uri = ""
- self._volume_level = 0
- self._muted = False
- self.connected_clients = {}
- self.stream_task = None
- self.sync_task = None
- self._config_entries = self.__get_config_entries()
- self._group_childs = self.__get_group_childs()
-
- @property
- def player_id(self) -> str:
- """Return player id of this player."""
- return self._player_id
-
- @property
- def provider_id(self) -> str:
- """Return provider id of this player."""
- return self._provider_id
-
- @property
- def name(self) -> str:
- """Return name of the player."""
- return self._name
-
- @property
- def powered(self) -> bool:
- """Return current power state of player."""
- return self._powered
-
- @property
- def state(self) -> PlayerState:
- """Return current PlayerState of player."""
- return self._state
-
- @property
- def available(self) -> bool:
- """Return current availablity of player."""
- return True
-
- @property
- def current_uri(self) -> str:
- """Return currently loaded uri of player (if any)."""
- return self._current_uri
-
- @property
- def volume_level(self) -> int:
- """Return current volume level of player (scale 0..100)."""
- return self._volume_level
-
- @property
- def muted(self) -> bool:
- """Return current mute state of player."""
- return self._muted
-
- @property
- def elapsed_time(self):
- """Return elapsed time for first child player."""
- if self.state in [PlayerState.PLAYING, PlayerState.PAUSED]:
- for player_id in self.group_childs:
- player = self.mass.players.get_player(player_id)
- if player:
- return player.elapsed_time
- return 0
-
- @property
- def should_poll(self):
- """Return True if this player should be polled for state."""
- return True
-
- @property
- def is_group_player(self) -> bool:
- """Return True if this player is a group player."""
- return True
-
- @property
- def group_childs(self):
- """Return group childs of this group player."""
- return self._group_childs
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return deviceinfo."""
- return DeviceInfo(
- model="Group Player",
- manufacturer=PROV_ID,
- )
-
- @property
- def config_entries(self):
- """Return config entries for this group player."""
- return self._config_entries
-
- async def on_poll(self) -> None:
- """Call when player is periodically polled by the player manager (should_poll=True)."""
- self._config_entries = self.__get_config_entries()
- self._group_childs = self.__get_group_childs()
- self.update_state()
-
- def __get_group_childs(self):
- """Return group childs of this group player."""
- player_conf = self.mass.config.get_player_config(self.player_id)
- if player_conf and player_conf.get(CONF_PLAYERS):
- return player_conf[CONF_PLAYERS]
- return []
-
- def __get_config_entries(self):
- """Return config entries for this group player."""
- all_players = [
- ConfigValueOption(text=item.name, value=item.player_id)
- for item in self.mass.players
- if item.player_id is not self._player_id
- ]
- selected_players_ids = self.mass.config.get_player_config(self.player_id).get(
- CONF_PLAYERS, []
- )
- # selected_players_ids = []
- selected_players = []
- for player_id in selected_players_ids:
- if player := self.mass.players.get_player(player_id):
- selected_players.append(
- ConfigValueOption(text=player.name, value=player.player_id)
- )
- default_master = ""
- if selected_players:
- default_master = selected_players[0]["value"]
- return [
- ConfigEntry(
- entry_key=CONF_PLAYERS,
- entry_type=ConfigEntryType.STRING,
- default_value=[],
- options=all_players,
- label=CONF_PLAYERS,
- description="group_player_players_desc",
- multi_value=True,
- ),
- ConfigEntry(
- entry_key=CONF_MASTER,
- entry_type=ConfigEntryType.STRING,
- default_value=default_master,
- options=selected_players,
- label=CONF_MASTER,
- description="group_player_master_desc",
- multi_value=False,
- depends_on=CONF_PLAYERS,
- ),
- ]
-
- # SERVICE CALLS / PLAYER COMMANDS
-
- async def cmd_play_uri(self, uri: str):
- """Play the specified uri/url on the player."""
- await self.cmd_stop()
- self._current_uri = uri
- self._state = PlayerState.PLAYING
- self._powered = True
- # forward this command to each child player
- # TODO: Only start playing on powered players ?
- # Monitor if a child turns on and join it to the sync ?
- for child_player_id in self.group_childs:
- child_player = self.mass.players.get_player(child_player_id)
- if child_player:
- queue_stream_uri = f"{self.mass.web.stream_url}/group/{self.player_id}?player_id={child_player_id}"
- await child_player.cmd_play_uri(queue_stream_uri)
- self.update_state()
- self.stream_task = create_task(self.queue_stream_task())
-
- async def cmd_stop(self) -> None:
- """Send STOP command to player."""
- self._state = PlayerState.IDLE
- if self.stream_task:
- # cancel existing stream task if any
- self.stream_task.cancel()
- self.connected_clients = {}
- await asyncio.sleep(0.5)
- if self.sync_task:
- self.sync_task.cancel()
- # forward this command to each child player
- # TODO: Only forward to powered child players
- for child_player_id in self.group_childs:
- child_player = self.mass.players.get_player(child_player_id)
- if child_player:
- await child_player.cmd_stop()
- self.update_state()
-
- async def cmd_play(self) -> None:
- """Send PLAY command to player."""
- if not self.state == PlayerState.PAUSED:
- return
- # forward this command to each child player
- for child_player_id in self.group_childs:
- child_player = self.mass.players.get_player(child_player_id)
- if child_player:
- await child_player.cmd_play()
- self._state = PlayerState.PLAYING
- self.update_state()
-
- async def cmd_pause(self):
- """Send PAUSE command to player."""
- # forward this command to each child player
- for child_player_id in self.group_childs:
- child_player = self.mass.players.get_player(child_player_id)
- if child_player:
- await child_player.cmd_pause()
- self._state = PlayerState.PAUSED
- self.update_state()
-
- async def cmd_power_on(self) -> None:
- """Send POWER ON command to player."""
- self._powered = True
- self.update_state()
-
- async def cmd_power_off(self) -> None:
- """Send POWER OFF command to player."""
- self._powered = False
- self.update_state()
-
- async def cmd_volume_set(self, volume_level: int) -> None:
- """
- Send volume level command to player.
-
- :param volume_level: volume level to set (0..100).
- """
- # this is already handled by the player manager
-
- async def cmd_volume_mute(self, is_muted=False):
- """
- Send volume MUTE command to given player.
-
- :param is_muted: bool with new mute state.
- """
- for child_player_id in self.group_childs:
- self.mass.players.cmd_volume_mute(child_player_id)
- self.muted = is_muted
-
- async def subscribe_stream_client(self, child_player_id):
- """Handle streaming to all players of a group. Highly experimental."""
-
- # each connected client gets its own Queue to which audio chunks (flac) are sent
- try:
- # report this client as connected
- queue = asyncio.Queue()
- self.connected_clients[child_player_id] = queue
- LOGGER.debug(
- "[%s] child player connected: %s",
- self.player_id,
- child_player_id,
- )
- # yield flac chunks from stdout to the http streamresponse
- while True:
- chunk = await queue.get()
- yield chunk
- queue.task_done()
- if not chunk:
- break
- except (GeneratorExit, Exception): # pylint: disable=broad-except
- LOGGER.warning(
- "[%s] child player aborted stream: %s", self.player_id, child_player_id
- )
- self.connected_clients.pop(child_player_id, None)
- else:
- self.connected_clients.pop(child_player_id, None)
- LOGGER.debug(
- "[%s] child player completed streaming: %s",
- self.player_id,
- child_player_id,
- )
-
- async def queue_stream_task(self):
- """Handle streaming queue to connected child players."""
- ticks = 0
- while ticks < 60 and (len(self.connected_clients) != len(self.group_childs)):
- # TODO: Support situation where not all clients of the group are powered
- await asyncio.sleep(0.1)
- ticks += 1
- if not self.connected_clients:
- LOGGER.warning("no clients!")
- return
- LOGGER.debug(
- "start queue stream with %s connected clients", len(self.connected_clients)
- )
- self.sync_task = create_task(self.__synchronize_players())
-
- async for audio_chunk in self.mass.streams.queue_stream_flac(self.player_id):
-
- # make sure we still have clients connected
- if not self.connected_clients:
- LOGGER.warning("no more clients!")
- return
-
- # send the audio chunk to all connected players
- tasks = []
- for _queue in self.connected_clients.values():
- tasks.append(create_task(_queue.put(audio_chunk)))
- # wait for clients to consume the data
- await asyncio.wait(tasks)
-
- if not self.connected_clients:
- LOGGER.warning("no more clients!")
- return
- self.sync_task.cancel()
-
- async def __synchronize_players(self):
- """Handle drifting/lagging by monitoring progress and compare to master player."""
-
- master_player_id = self.mass.config.player_settings[self.player_id].get(
- CONF_MASTER
- )
- master_player = self.mass.players.get_player(master_player_id)
- if not master_player:
- LOGGER.warning("Synchronization of playback aborted: no master player.")
- return
- LOGGER.debug(
- "Synchronize playback of group using master player %s", master_player.name
- )
-
- # wait until master is playing
- while master_player.state != PlayerState.PLAYING:
- await asyncio.sleep(0.1)
- await asyncio.sleep(0.5)
-
- prev_lags = {}
- prev_drifts = {}
-
- while self.connected_clients:
-
- # check every 0.5 seconds for player sync
- await asyncio.sleep(0.5)
-
- for child_player_id in self.connected_clients:
-
- if child_player_id == master_player_id:
- continue
- child_player = self.mass.players.get_player(child_player_id)
-
- if (
- not child_player
- or child_player.state != PlayerState.PLAYING
- or child_player.elapsed_milliseconds is None
- ):
- continue
-
- if child_player_id not in prev_lags:
- prev_lags[child_player_id] = []
- if child_player_id not in prev_drifts:
- prev_drifts[child_player_id] = []
-
- # calculate lag (player is too slow in relation to the master)
- lag = (
- master_player.elapsed_milliseconds
- - child_player.elapsed_milliseconds
- )
- prev_lags[child_player_id].append(lag)
- if len(prev_lags[child_player_id]) == 5:
- # if we have 5 samples calclate the average lag
- avg_lag = sum(prev_lags[child_player_id]) / len(
- prev_lags[child_player_id]
- )
- prev_lags[child_player_id] = []
- if avg_lag > 25:
- LOGGER.debug(
- "child player %s is lagging behind with %s milliseconds",
- child_player_id,
- avg_lag,
- )
- # we correct the lag by pausing the master player for a very short time
- await master_player.cmd_pause()
- # sending the command takes some time, account for that too
- if avg_lag > 20:
- sleep_time = avg_lag - 20
- await asyncio.sleep(sleep_time / 1000)
- create_task(master_player.cmd_play())
- break # no more processing this round if we've just corrected a lag
-
- # calculate drift (player is going faster in relation to the master)
- drift = (
- child_player.elapsed_milliseconds
- - master_player.elapsed_milliseconds
- )
- prev_drifts[child_player_id].append(drift)
- if len(prev_drifts[child_player_id]) == 5:
- # if we have 5 samples calculate the average drift
- avg_drift = sum(prev_drifts[child_player_id]) / len(
- prev_drifts[child_player_id]
- )
- prev_drifts[child_player_id] = []
-
- if avg_drift > 25:
- LOGGER.debug(
- "child player %s is drifting ahead with %s milliseconds",
- child_player_id,
- avg_drift,
- )
- # we correct the drift by pausing the player for a very short time
- # this is not the best approach but works with all Players
- # temporary solution until I find something better like sending more/less pcm chunks
- await child_player.cmd_pause()
- # sending the command takes some time, account for that too
- if avg_drift > 20:
- sleep_time = drift - 20
- await asyncio.sleep(sleep_time / 1000)
- await child_player.cmd_play()
- break # no more processing this round if we've just corrected a lag
+++ /dev/null
-{
- "enabled": "Enabled",
- "name": "Name",
- "username": "Username",
- "password": "Password",
- "enable_player": "Enable this player",
- "custom_name": "Custom name",
- "max_sample_rate": "Maximum sample rate",
- "volume_normalisation": "Enable Volume normalisation",
- "target_volume": "Target Volume level",
- "desc_player_name": "Set a custom name for this player.",
- "crossfade_duration": "Enable crossfade",
- "group_delay": "Correction of groupdelay",
- "security": "Security",
- "app_tokens": "App tokens",
- "power_control": "Power Control",
- "volume_control": "Volume Control",
-
- "desc_sample_rate": "Set the maximum sample rate this player can handle.",
- "desc_volume_normalisation": "Enable R128 volume normalisation to play music at an equally loud volume.",
- "desc_target_volume": "Set the preferred target volume level in LUFS. The R128 default is -22 LUFS.",
- "desc_crossfade": "Enable crossfading of Queue tracks by setting a crossfade duration in seconds.",
- "desc_enable_provider": "Enable this provider.",
- "desc_base_username": "Username to access this Music Assistant server.",
- "desc_base_password": "A password to protect this Music Assistant server. Can be left blank but this is extremely dangerous if this server is reachable from outside.",
- "desc_group_delay": "Only used on grouped playback. Adjust the delay of the grouped playback on this player",
- "desc_power_control": "Use an external device as power control for this player.",
- "desc_volume_control": "Use an external device as volume control for this player.",
-
- "Universal Group player": "Universal Group Player",
- "group_player_count": "Number of group players",
- "group_player_count_desc": "Select how many Universal group players should be created.",
- "group_player_players": "Players in group",
- "group_player_players_desc": "Select the players that should be part of this group.",
- "group_player_master": "Group master",
- "group_player_master_desc": "Select the player that should act as group master.",
-
- "desc_spotify_username": "Username for your Spotify account",
- "desc_spotify_password": "Password for your Spotify account",
-
- "file_prov_music_path": "Music path",
- "file_prov_music_path_desc": "Path on disk to your music files.",
- "file_prov_playlists_path": "Playlists path",
- "file_prov_playlists_path_desc": "Path on disk to your playlists (.m3u) files."
-}
\ No newline at end of file
+++ /dev/null
-{
- "enabled": "Ingeschakeld",
- "name": "Naam",
- "username": "Gebruikersnaam",
- "password": "Wachtwoord",
- "enable_player": "Deze speler inschakelen",
- "custom_name": "Aangepaste name",
- "max_sample_rate": "Maximale sample rate",
- "volume_normalisation": "Volume normalisering inschakelen",
- "target_volume": "Doel volume",
- "desc_player_name": "Stel een aangepaste naam in voor deze speler.",
- "crossfade_duration": "Crossfade inschakelen",
- "security": "Beveiliging",
- "app_tokens": "App tokens",
- "group_delay": "Correctie van groepsvertraging",
- "power_control": "Power Control",
- "volume_control": "Volume Control",
-
- "desc_sample_rate": "Stel de maximale sample rate in die deze speler aankan.",
- "desc_volume_normalisation": "R128 volume normalisatie inschakelen om muziek altijd op een gelijk volume af te spelen.",
- "desc_target_volume": "Selecteer het gewenste doelvolume in LUFS. De R128 standaard is -22 LUFS.",
- "desc_crossfade": "Crossfade inschakelen door het instellen van een crossfade duur in seconden.",
- "desc_enable_provider": "Deze provider inschakelen.",
- "desc_base_username": "Gebruikersnaam waarmee deze server beveiligd moet worden.",
- "desc_base_password": "Wachtwoord waarmee deze server beveiligd moet worden. Mag worden leeggelaten maar dit is extreem gevaarlijk indien je besluit de server extern toegankelijk te maken.",
- "desc_group_delay": "Gebruikt bij afspelen in groep. Pas de vertraging aan voor deze player.",
- "desc_power_control": "Gebruik een extern apparaat als aan/uit control voor deze speler.",
- "desc_volume_control": "Gebruik een extern apparaat als volume control voor deze speler.",
-
- "Universal Group player": "Universele groep speler",
- "group_player_count": "Aantal groep spelers",
- "group_player_count_desc": "Selecteer hoeveel groep spelers er aangemaakt moeten worden.",
- "group_player_players": "Groepsspelers",
- "group_player_players_desc": "Selecteer de spelers die deel uitmaken van deze groep.",
- "group_player_master": "Groepsbeheerder",
- "group_player_master_desc": "Selecteer de speler die dient als groepsbeheerder.",
-
- "desc_spotify_username": "Gebruikersnaam van jouw Spotify account",
- "desc_spotify_password": "Wachtwoord van jouw Spotify account",
-
- "file_prov_music_path": "Muzieklocatie",
- "file_prov_music_path_desc": "Locatie op schijf waar jouw muziekbestanden staan.",
- "file_prov_playlists_path": "Playlists locatie",
- "file_prov_playlists_path_desc": "Locatie op schijf waar jouw playlists (.m3u) staan."
-}
\ No newline at end of file
+++ /dev/null
-"""
-The web module handles serving the custom websocket api on a custom port (default is 8095).
-
-All MusicAssistant clients communicate locally with this websockets api.
-The server is intended to be used locally only and not exposed outside,
-so it is HTTP only. Secure remote connections will be offered by a remote connect broker.
-"""
-import logging
-import os
-import uuid
-from json.decoder import JSONDecodeError
-from typing import Callable, List, Tuple
-
-import aiofiles
-import aiohttp_cors
-import jwt
-import music_assistant.web.api as api
-from aiohttp import web
-from aiohttp.web_exceptions import HTTPNotFound, HTTPUnauthorized
-from music_assistant.constants import (
- CONF_KEY_SECURITY_LOGIN,
- CONF_PASSWORD,
- CONF_USERNAME,
-)
-from music_assistant.constants import __version__ as MASS_VERSION
-from music_assistant.helpers.datetime import future_timestamp
-from music_assistant.helpers.encryption import decrypt_string
-from music_assistant.helpers.errors import AuthenticationError
-from music_assistant.helpers.images import get_thumb_file
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import get_hostname, get_ip
-from music_assistant.helpers.web import APIRoute, create_api_route
-
-from .json_rpc import json_rpc_endpoint
-from .stream import routes as stream_routes
-
-LOGGER = logging.getLogger("webserver")
-
-
-class WebServer:
- """Webserver and json/websocket api."""
-
- def __init__(self, mass: MusicAssistant, port: int) -> None:
- """Initialize class."""
- self.jwt_key = None
- self.app = None
- self.mass = mass
- self._port = port
- # load/create/update config
- self._hostname = get_hostname().lower()
- self._ip_address = get_ip()
- self.config = mass.config.base["web"]
- self._runner = None
- self.api_routes: List[APIRoute] = []
-
- async def setup(self) -> None:
- """Perform async setup."""
- self.jwt_key = await decrypt_string(self.mass.config.stored_config["jwt_key"])
- self.app = web.Application()
- self.app["mass"] = self.mass
- self.app["ws_clients"] = []
- # add all routes
- self.app.add_routes(stream_routes)
- self.app.router.add_route("*", "/jsonrpc.js", json_rpc_endpoint)
- self.app.router.add_view("/ws", api.WebSocketApi)
-
- # Add server discovery on info including CORS support
- cors = aiohttp_cors.setup(
- self.app,
- defaults={
- "*": aiohttp_cors.ResourceOptions(
- allow_credentials=True,
- allow_headers="*",
- )
- },
- )
- cors.add(self.app.router.add_get("/info", self.info))
- cors.add(self.app.router.add_post("/login", self.login))
- cors.add(self.app.router.add_post("/setup", self.first_setup))
- cors.add(self.app.router.add_get("/thumb", self.image_thumb))
- self.app.router.add_route("*", "/api/{tail:.+}", api.handle_api_request)
- # Host the frontend app
- webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static/")
- if os.path.isdir(webdir):
- self.app.router.add_get("/", self.index)
- self.app.router.add_static("/", webdir, append_version=True)
-
- self._runner = web.AppRunner(self.app, access_log=None)
- await self._runner.setup()
- # set host to None to bind to all addresses on both IPv4 and IPv6
- http_site = web.TCPSite(self._runner, host=None, port=self.port)
- await http_site.start()
- self.add_api_routes()
- LOGGER.info("Started Music Assistant server on port %s", self.port)
-
- async def stop(self) -> None:
- """Stop the webserver."""
- for ws_client in self.app["ws_clients"]:
- await ws_client.close(message=b"server shutdown")
-
- def add_api_routes(self) -> None:
- """Register all methods decorated as api_route."""
- for cls in [
- api,
- self.mass.music,
- self.mass.players,
- self.mass.config,
- self.mass.library,
- self.mass.tasks,
- ]:
- for item in dir(cls):
- func = getattr(cls, item)
- if not hasattr(func, "api_path"):
- continue
- # method is decorated with our api decorator
- self.register_api_route(func.api_path, func, func.api_method)
-
- def register_api_route(
- self,
- path: str,
- handler: Callable,
- method: str = "GET",
- ) -> None:
- """Dynamically register a path/route on the API."""
- route = create_api_route(path, handler, method)
- # TODO: swagger generation
- self.api_routes.append(route)
-
- @property
- def hostname(self) -> str:
- """Return the hostname for this Music Assistant instance."""
- if not self._hostname.endswith(".local"):
- # probably running in docker, use mdns name instead
- return f"mass_{self.server_id}.local"
- return self._hostname
-
- @property
- def ip_address(self) -> str:
- """Return the local IP(v4) address for this Music Assistant instance."""
- return self._ip_address
-
- @property
- def port(self) -> int:
- """Return the port for this Music Assistant instance."""
- return self._port
-
- @property
- def stream_url(self) -> str:
- """Return the base stream URL for this Music Assistant instance."""
- # dns resolving often fails on stream devices so use IP-address
- return f"http://{self.ip_address}:{self.port}/stream"
-
- @property
- def address(self) -> str:
- """Return the base HTTP address for this Music Assistant instance."""
- return f"http://{self.ip_address}:{self.port}"
-
- @property
- def server_id(self) -> str:
- """Return the device ID for this Music Assistant Server."""
- return self.mass.config.stored_config["server_id"]
-
- @property
- def discovery_info(self) -> dict:
- """Return discovery info for this Music Assistant server."""
- return {
- "id": self.server_id,
- "address": self.address,
- "hostname": self.hostname,
- "ip_address": self.ip_address,
- "port": self.port,
- "version": MASS_VERSION,
- "friendly_name": self.mass.config.stored_config["friendly_name"],
- "initialized": self.mass.config.stored_config["initialized"],
- }
-
- async def index(self, request: web.Request) -> web.FileResponse:
- """Get the index page."""
- # pylint: disable=unused-argument
- html_app = os.path.join(
- os.path.dirname(os.path.abspath(__file__)), "static/index.html"
- )
- return web.FileResponse(html_app)
-
- async def info(self, request: web.Request) -> web.Response:
- """Return server discovery info."""
- return web.json_response(self.discovery_info)
-
- async def login(self, request: web.Request) -> web.Response:
- """
- Validate given credentials and return JWT token.
-
- If app_id is provided, a long lived token will be issued which can be withdrawn by the user.
- """
- try:
- data = await request.post()
- if not data:
- data = await request.json()
- except JSONDecodeError:
- data = await request.json()
- username = data["username"]
- password = data["password"]
- app_id = data.get("app_id", "")
- verified = self.mass.config.security.validate_credentials(username, password)
- if verified:
- client_id = str(uuid.uuid4())
- token_info = {
- "username": username,
- "server_id": self.server_id,
- "client_id": client_id,
- "app_id": app_id,
- }
- if app_id:
- token_info["enabled"] = True
- token_info["exp"] = future_timestamp(days=365 * 10)
- else:
- token_info["exp"] = future_timestamp(hours=8)
- token = jwt.encode(token_info, self.jwt_key, algorithm="HS256")
- if app_id:
- self.mass.config.security.add_app_token(token_info)
- token_info["token"] = token
- return web.json_response(token_info)
- raise HTTPUnauthorized(reason="Invalid credentials")
-
- async def first_setup(self, request: web.Request) -> web.Response:
- """Handle first-time server setup through onboarding wizard."""
- try:
- data = await request.post()
- if not data:
- data = await request.json()
- except JSONDecodeError:
- data = await request.json()
- username = data["username"]
- password = data["password"]
- if self.mass.config.stored_config["initialized"]:
- raise AuthenticationError("Already initialized")
- # save credentials in config
- self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_USERNAME] = username
- self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_PASSWORD] = password
- self.mass.config.stored_config["initialized"] = True
- self.mass.config.save()
- # fix discovery info
- await self.mass.setup_discovery()
- return web.json_response(self.discovery_info)
-
- async def image_thumb(self, request: web.Request) -> web.Response:
- """Get (resized) thumb image for given URL."""
- url = request.query.get("url")
- size = int(request.query.get("size", 150))
-
- img_file = await get_thumb_file(self.mass, url, size)
- if img_file:
- async with aiofiles.open(img_file, "rb") as _file:
- img_data = await _file.read()
- headers = {
- "Content-Type": "image/png",
- "Cache-Control": "public, max-age=604800",
- }
- return web.Response(body=img_data, headers=headers)
- raise KeyError("Invalid url!")
-
- def get_api_handler(self, path: str, method: str) -> Tuple[APIRoute, dict]:
- """Find API route match for given path."""
- matchpath = path.replace("/api/", "")
- for route in self.api_routes:
- match = route.match(matchpath, method)
- if match:
- return match[0], match[1]
- raise HTTPNotFound(reason="Invalid path: %s" % path)
+++ /dev/null
-"""Custom API implementation using websockets."""
-
-import asyncio
-import logging
-import os
-from base64 import b64encode
-from typing import Any, Dict, Optional, Union
-
-import aiofiles
-import jwt
-import ujson
-from aiohttp import WSMsgType, web
-from aiohttp.http_websocket import WSMessage
-from music_assistant.helpers.errors import AuthenticationError
-from music_assistant.helpers.images import get_image_url, get_thumb_file
-from music_assistant.helpers.logger import HistoryLogHandler
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.web import (
- api_route,
- async_json_response,
- async_json_serializer,
- parse_arguments,
-)
-from music_assistant.models.media_types import MediaType
-
-LOGGER = logging.getLogger("api")
-
-
-@api_route("log")
-async def get_log(tail: int = 200) -> str:
- """Return current application log."""
- for handler in logging.getLogger().handlers:
- if isinstance(handler, HistoryLogHandler):
- return handler.get_history()[-tail:]
-
-
-@api_route("images/{media_type}/{provider}/{item_id}")
-async def get_media_item_image_url(
- mass: MusicAssistant, media_type: MediaType, provider: str, item_id: str
-) -> str:
- """Return image URL for given media item."""
- if provider == "url":
- return None
- return await get_image_url(mass, item_id, provider, media_type)
-
-
-@api_route("images/thumb")
-async def get_image_thumb(mass: MusicAssistant, url: str, size: int = 150) -> str:
- """Get (resized) thumb image for given URL as base64 string."""
- img_file = await get_thumb_file(mass, url, size)
- if img_file:
- async with aiofiles.open(img_file, "rb") as _file:
- img_data = await _file.read()
- return "data:image/png;base64," + b64encode(img_data).decode()
- raise KeyError("Invalid url!")
-
-
-@api_route("images/provider-icons/{provider_id}")
-async def get_provider_icon(provider_id: str) -> str:
- """Get Provider icon as base64 string."""
- base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- icon_path = os.path.join(base_dir, "providers", provider_id, "icon.png")
- if os.path.isfile(icon_path):
- async with aiofiles.open(icon_path, "rb") as _file:
- img_data = await _file.read()
- return "data:image/png;base64," + b64encode(img_data).decode()
- raise KeyError("Invalid provider: %s" % provider_id)
-
-
-@api_route("images/provider-icons")
-async def get_provider_icons(mass: MusicAssistant) -> Dict[str, str]:
- """Get Provider icons as base64 strings."""
- return {
- prov.id: await get_provider_icon(prov.id)
- for prov in mass.get_providers(include_unavailable=True)
- }
-
-
-async def handle_api_request(request: web.Request):
- """Handle API requests."""
- mass: MusicAssistant = request.app["mass"]
- LOGGER.debug("Handling %s", request.path)
-
- # check auth token
- auth_token = request.headers.get("Authorization", "").split("Bearer ")[-1]
- if not auth_token:
- raise web.HTTPUnauthorized(
- reason="Missing authorization token",
- )
- try:
- token_info = jwt.decode(auth_token, mass.web.jwt_key, algorithms=["HS256"])
- except jwt.InvalidTokenError as exc:
- LOGGER.exception(exc, exc_info=exc)
- msg = "Invalid authorization token, " + str(exc)
- raise web.HTTPUnauthorized(reason=msg)
- if mass.config.security.is_token_revoked(token_info):
- raise web.HTTPUnauthorized(reason="Token is revoked")
- mass.config.security.set_last_login(token_info["client_id"])
-
- # handle request
- handler, path_params = mass.web.get_api_handler(request.path, request.method)
- data = await request.json() if request.can_read_body else {}
- # execute handler and return results
- try:
- all_params = {**path_params, **request.query, **data}
- params = parse_arguments(mass, handler.signature, all_params)
- res = handler.target(**params)
- if asyncio.iscoroutine(res):
- res = await res
- except Exception as exc: # pylint: disable=broad-except
- LOGGER.debug("Error while handling %s", request.path, exc_info=exc)
- raise web.HTTPInternalServerError(reason=str(exc))
- return await async_json_response(res)
-
-
-class WebSocketApi(web.View):
- """RPC-like API implementation using websockets."""
-
- def __init__(self, request: web.Request):
- """Initialize."""
- super().__init__(request)
- self.authenticated = False
- self.ws_client: Optional[web.WebSocketResponse] = None
-
- @property
- def mass(self) -> MusicAssistant:
- """Return MusicAssistant instance."""
- return self.request.app["mass"]
-
- async def get(self):
- """Handle GET."""
- ws_client = web.WebSocketResponse()
- self.ws_client = ws_client
- await ws_client.prepare(self.request)
- self.request.app["ws_clients"].append(ws_client)
- await self._send_json(msg_type="info", data=self.mass.web.discovery_info)
-
- # add listener for mass events
- remove_listener = self.mass.eventbus.add_listener(self._handle_mass_event)
-
- # handle incoming messages
- try:
- async for msg in ws_client:
- await self.__handle_msg(msg)
- finally:
- # websocket disconnected
- remove_listener()
- self.request.app["ws_clients"].remove(ws_client)
- LOGGER.debug("websocket connection closed: %s", self.request.remote)
-
- return ws_client
-
- async def __handle_msg(self, msg: WSMessage):
- """Handle incoming message."""
- try:
- if msg.type == WSMsgType.error:
- LOGGER.warning(
- "ws connection closed with exception %s", self.ws_client.exception()
- )
- return
- if msg.type != WSMsgType.text:
- return
- if msg.data == "close":
- await self.ws_client.close()
- return
- # process message
- json_msg = msg.json(loads=ujson.loads)
- # handle auth command
- if json_msg["type"] == "auth":
- token_info = jwt.decode(
- json_msg["data"], self.mass.web.jwt_key, algorithms=["HS256"]
- )
- if self.mass.config.security.is_token_revoked(token_info):
- raise AuthenticationError("Token is revoked")
- self.authenticated = True
- self.mass.config.security.set_last_login(token_info["client_id"])
- # TODO: store token/app_id on ws_client obj and periodically check if token is expired or revoked
- await self._send_json(
- msg_type="result",
- msg_id=json_msg.get("id"),
- data=token_info,
- )
- elif not self.authenticated:
- raise AuthenticationError("Not authenticated")
- # handle regular command
- elif json_msg["type"] == "command":
- await self._handle_command(
- json_msg["data"],
- msg_id=json_msg.get("id"),
- )
- except AuthenticationError as exc: # pylint:disable=broad-except
- # disconnect client on auth errors
- await self._send_json(
- msg_type="error", msg_id=json_msg.get("id"), data=str(exc)
- )
- await self.ws_client.close(message=str(exc).encode())
- except Exception as exc: # pylint:disable=broad-except
- # log the error only
- await self._send_json(
- msg_type="error", msg_id=json_msg.get("id"), data=str(exc)
- )
- LOGGER.error("Error with WS client", exc_info=exc)
-
- async def _handle_command(
- self,
- cmd_data: Union[str, dict],
- msg_id: Any = None,
- ):
- """Handle websocket command."""
- # Command may be provided as string or a dict
- if isinstance(cmd_data, str):
- path = cmd_data
- method = "GET"
- params = {}
- else:
- path = cmd_data["path"]
- method = cmd_data.get("method", "GET")
- params = {x: cmd_data[x] for x in cmd_data if x not in ["path", "method"]}
- LOGGER.debug("Handling command %s/%s", method, path)
- # work out handler for the given path/command
- route, path_params = self.mass.web.get_api_handler(path, method)
- args = parse_arguments(self.mass, route.signature, {**params, **path_params})
- res = route.target(**args)
- if asyncio.iscoroutine(res):
- res = await res
- # return result of command to client
- return await self._send_json(msg_type="result", msg_id=msg_id, data=res)
-
- async def _send_json(
- self,
- msg_type: str,
- msg_id: Optional[int] = None,
- data: Optional[Any] = None,
- ):
- """Send message (back) to websocket client."""
- await self.ws_client.send_str(
- await async_json_serializer({"type": msg_type, "id": msg_id, "data": data})
- )
-
- async def _handle_mass_event(self, event: str, event_data: Any):
- """Broadcast events to connected client."""
- if not self.authenticated:
- return
- try:
- await self._send_json(
- msg_type="event",
- data={"event": event, "event_data": event_data},
- )
- except ConnectionResetError as exc:
- LOGGER.debug("Error while sending message to api client", exc_info=exc)
- await self.ws_client.close()
+++ /dev/null
-"""JSON RPC API endpoint (mostly) compatible with LMS."""
-
-from aiohttp.web import Request, Response
-from music_assistant.helpers.web import require_local_subnet
-
-
-@require_local_subnet
-async def json_rpc_endpoint(request: Request):
- """
- Implement basic jsonrpc interface compatible with LMS.
-
- for some compatability with tools that talk to LMS
- only support for basic commands
- """
- # pylint: disable=too-many-branches
- data = await request.json()
- params = data["params"]
- player_id = params[0]
- cmds = params[1]
- cmd_str = " ".join(cmds)
- if cmd_str == "play":
- await request.app["mass"].players.cmd_play(player_id)
- elif cmd_str == "pause":
- await request.app["mass"].players.cmd_pause(player_id)
- elif cmd_str == "stop":
- await request.app["mass"].players.cmd_stop(player_id)
- elif cmd_str == "next":
- await request.app["mass"].players.cmd_next(player_id)
- elif cmd_str == "previous":
- await request.app["mass"].players.cmd_previous(player_id)
- elif "power" in cmd_str:
- powered = cmds[1] if len(cmds) > 1 else False
- if powered:
- await request.app["mass"].players.cmd_power_on(player_id)
- else:
- await request.app["mass"].players.cmd_power_off(player_id)
- elif cmd_str == "playlist index +1":
- await request.app["mass"].players.cmd_next(player_id)
- elif cmd_str == "playlist index -1":
- await request.app["mass"].players.cmd_previous(player_id)
- elif "mixer volume" in cmd_str and "+" in cmds[2]:
- player = request.app["mass"].players.get_player(player_id)
- volume_level = player.volume_level + int(cmds[2].split("+")[1])
- await request.app["mass"].players.cmd_volume_set(player_id, volume_level)
- elif "mixer volume" in cmd_str and "-" in cmds[2]:
- player = request.app["mass"].players.get_player(player_id)
- volume_level = player.volume_level - int(cmds[2].split("-")[1])
- await request.app["mass"].players.cmd_volume_set(player_id, volume_level)
- elif "mixer volume" in cmd_str:
- await request.app["mass"].players.cmd_volume_set(player_id, cmds[2])
- elif cmd_str == "mixer muting 1":
- await request.app["mass"].players.cmd_volume_mute(player_id, True)
- elif cmd_str == "mixer muting 0":
- await request.app["mass"].players.cmd_volume_mute(player_id, False)
- elif cmd_str == "button volup":
- await request.app["mass"].players.cmd_volume_up(player_id)
- elif cmd_str == "button voldown":
- await request.app["mass"].players.cmd_volume_down(player_id)
- elif cmd_str == "button power":
- await request.app["mass"].players.cmd_power_toggle(player_id)
- else:
- return Response(text="command not supported")
- return Response(text="success")
+++ /dev/null
-"""
-StreamManager: handles all audio streaming to players.
-
-Either by sending tracks one by one or send one continuous stream
-of music with crossfade/gapless support (queue stream).
-
-All audio is processed by SoX and/or ffmpeg, using various subprocess streams.
-"""
-
-import asyncio
-import logging
-from typing import AsyncGenerator, Optional, Tuple
-
-from aiohttp.web import Request, Response, RouteTableDef, StreamResponse
-from aiohttp.web_exceptions import HTTPNotFound
-from music_assistant.constants import (
- CONF_MAX_SAMPLE_RATE,
- EVENT_STREAM_ENDED,
- EVENT_STREAM_STARTED,
-)
-from music_assistant.helpers.audio import (
- analyze_audio,
- crossfade_pcm_parts,
- get_sox_args,
- get_stream_details,
- strip_silence,
-)
-from music_assistant.helpers.process import AsyncProcess
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task
-from music_assistant.helpers.web import require_local_subnet
-from music_assistant.models.player_queue import PlayerQueue
-from music_assistant.models.streamdetails import ContentType, StreamDetails
-
-routes = RouteTableDef()
-
-LOGGER = logging.getLogger("stream")
-
-
-@routes.get("/stream/queue/{player_id}")
-@require_local_subnet
-async def stream_queue(request: Request):
- """Stream all items in player's queue as continuous stream in FLAC audio format."""
- mass: MusicAssistant = request.app["mass"]
- player_id = request.match_info["player_id"]
- player_queue = mass.players.get_player_queue(player_id)
- if not player_queue:
- raise HTTPNotFound(reason="invalid player_id")
-
- # prepare request
- resp = StreamResponse(
- status=200, reason="OK", headers={"Content-Type": "audio/flac"}
- )
- await resp.prepare(request)
-
- player_conf = player_queue.player.config
- # determine sample rate and pcm format for the queue stream, depending on player capabilities
- player_max_sample_rate = player_conf.get(CONF_MAX_SAMPLE_RATE, 48000)
- sample_rate = min(player_max_sample_rate, 96000)
- if player_max_sample_rate > 96000:
- # assume that highest possible quality is needed
- # if player supports sample rates > 96000
- # we use float64 PCM format internally which is heavy on CPU
- pcm_format = ContentType.PCM_F64LE
- elif sample_rate > 48000:
- # prefer internal PCM_S32LE format
- pcm_format = ContentType.PCM_S32LE
- else:
- # fallback to 24 bits
- pcm_format = ContentType.PCM_S24LE
-
- args = [
- "sox",
- "-t",
- pcm_format.sox_format(),
- "-c",
- "2",
- "-r",
- str(sample_rate),
- "-",
- "-t",
- "flac",
- "-",
- ]
- async with AsyncProcess(args, enable_write=True) as sox_proc:
-
- LOGGER.info(
- "Start Queue Stream for player %s",
- player_queue.player.name,
- )
-
- # feed stdin with pcm samples
- async def fill_buffer():
- """Feed audio data into sox stdin for processing."""
- async for audio_chunk in get_pcm_queue_stream(
- mass, player_queue, sample_rate, pcm_format
- ):
- await sox_proc.write(audio_chunk)
- del audio_chunk
-
- fill_buffer_task = create_task(fill_buffer())
-
- # start delivering audio chunks
- try:
- async for audio_chunk in sox_proc.iterate_chunks(None):
- await resp.write(audio_chunk)
- except (asyncio.CancelledError, GeneratorExit) as err:
- LOGGER.debug(
- "Queue stream aborted for: %s",
- player_queue.player.name,
- )
- fill_buffer_task.cancel()
- raise err
- else:
- LOGGER.debug(
- "Queue stream finished for: %s",
- player_queue.player.name,
- )
- return resp
-
-
-@routes.get("/stream/queue/{player_id}/{queue_item_id}")
-@require_local_subnet
-async def stream_single_queue_item(request: Request):
- """Stream a single queue item."""
- mass: MusicAssistant = request.app["mass"]
- player_id = request.match_info["player_id"]
- queue_item_id = request.match_info["queue_item_id"]
- player_queue = mass.players.get_player_queue(player_id)
- if not player_queue:
- raise HTTPNotFound(reason="invalid player_id")
- if player_queue.use_queue_stream:
- # redirect request if player switched to queue streaming
- return await stream_queue(request)
- LOGGER.debug("Stream request for %s", player_queue.player.name)
-
- queue_item = player_queue.by_item_id(queue_item_id)
- if not queue_item:
- raise HTTPNotFound(reason="invalid queue_item_id")
-
- streamdetails = await get_stream_details(mass, queue_item, player_id)
-
- # prepare request
- resp = StreamResponse(
- status=200,
- reason="OK",
- headers={"Content-Type": "audio/flac"},
- )
- await resp.prepare(request)
-
- # start streaming
- LOGGER.debug(
- "Start streaming %s (%s) on player %s",
- queue_item_id,
- queue_item.name,
- player_queue.player.name,
- )
-
- async for _, audio_chunk in get_media_stream(mass, streamdetails, ContentType.FLAC):
- await resp.write(audio_chunk)
- del audio_chunk
- LOGGER.debug(
- "Finished streaming %s (%s) on player %s",
- queue_item_id,
- queue_item.name,
- player_queue.player.name,
- )
-
- return resp
-
-
-@routes.get("/stream/group/{group_player_id}")
-@require_local_subnet
-async def stream_group(request: Request):
- """Handle streaming to all players of a group. Highly experimental."""
- group_player_id = request.match_info["group_player_id"]
- if not request.app["mass"].players.get_player_queue(group_player_id):
- return Response(text="invalid player id", status=404)
- child_player_id = request.rel_url.query.get("player_id", request.remote)
-
- # prepare request
- resp = StreamResponse(
- status=200, reason="OK", headers={"Content-Type": "audio/flac"}
- )
- await resp.prepare(request)
-
- # stream queue
- player = request.app["mass"].players.get_player(group_player_id)
- async for audio_chunk in player.subscribe_stream_client(child_player_id):
- await resp.write(audio_chunk)
- return resp
-
-
-async def get_media_stream(
- mass: MusicAssistant,
- streamdetails: StreamDetails,
- output_format: Optional[ContentType] = None,
- resample: Optional[int] = None,
- chunk_size: Optional[int] = None,
-) -> AsyncGenerator[Tuple[bool, bytes], None]:
- """Get the audio stream for the given streamdetails."""
-
- mass.eventbus.signal(EVENT_STREAM_STARTED, streamdetails)
- args = get_sox_args(streamdetails, output_format, resample)
- async with AsyncProcess(args) as sox_proc:
-
- LOGGER.debug(
- "start media stream for: %s/%s (%s)",
- streamdetails.provider,
- streamdetails.item_id,
- streamdetails.type,
- )
-
- # yield chunks from stdout
- # we keep 1 chunk behind to detect end of stream properly
- try:
- prev_chunk = b""
- async for chunk in sox_proc.iterate_chunks(chunk_size):
- if prev_chunk:
- yield (False, prev_chunk)
- prev_chunk = chunk
- # send last chunk
- yield (True, prev_chunk)
- except (asyncio.CancelledError, GeneratorExit) as err:
- LOGGER.debug(
- "media stream aborted for: %s/%s",
- streamdetails.provider,
- streamdetails.item_id,
- )
- raise err
- else:
- LOGGER.debug(
- "finished media stream for: %s/%s",
- streamdetails.provider,
- streamdetails.item_id,
- )
- await mass.database.mark_item_played(
- streamdetails.item_id, streamdetails.provider
- )
- finally:
- mass.eventbus.signal(EVENT_STREAM_ENDED, streamdetails)
- # send analyze job to background worker
- if streamdetails.loudness is None:
- uri = f"{streamdetails.provider}://{streamdetails.media_type.value}/{streamdetails.item_id}"
- mass.tasks.add(
- f"Analyze audio for {uri}", analyze_audio(mass, streamdetails)
- )
-
-
-async def get_pcm_queue_stream(
- mass: MusicAssistant,
- player_queue: PlayerQueue,
- sample_rate,
- pcm_format: ContentType,
- channels: int = 2,
-) -> AsyncGenerator[bytes, None]:
- """Stream the PlayerQueue's tracks as constant feed in PCM raw audio."""
- last_fadeout_data = b""
- queue_index = None
- # get crossfade details
- fade_length = player_queue.crossfade_duration
- if pcm_format == ContentType.PCM_F64LE:
- bit_depth = 64
- elif pcm_format in [ContentType.PCM_F32LE, ContentType.PCM_S32LE]:
- bit_depth = 32
- elif pcm_format == ContentType.PCM_S24LE:
- bit_depth = 24
- else:
- bit_depth = 16
- pcm_args = [pcm_format.sox_format(), "-c", "2", "-r", str(sample_rate)]
- sample_size = int(sample_rate * (bit_depth / 8) * channels) # 1 second
- buffer_size = sample_size * fade_length if fade_length else sample_size * 10
- # stream queue tracks one by one
- while True:
- # get the (next) track in queue
- if queue_index is None:
- # report start of queue playback so we can calculate current track/duration etc.
- queue_index = await player_queue.queue_stream_start()
- else:
- queue_index = await player_queue.queue_stream_next(queue_index)
- queue_track = player_queue.get_item(queue_index)
- if not queue_track:
- LOGGER.debug("no (more) tracks in queue")
- break
-
- # get streamdetails
- streamdetails = await get_stream_details(
- mass, queue_track, player_queue.queue_id
- )
-
- LOGGER.debug(
- "Start Streaming queue track: %s (%s) for player %s",
- queue_track.item_id,
- queue_track.name,
- player_queue.player.name,
- )
- fade_in_part = b""
- cur_chunk = 0
- prev_chunk = None
- bytes_written = 0
- # handle incoming audio chunks
- async for is_last_chunk, chunk in get_media_stream(
- mass,
- streamdetails,
- pcm_format,
- resample=sample_rate,
- chunk_size=buffer_size,
- ):
- cur_chunk += 1
-
- # HANDLE FIRST PART OF TRACK
- if not chunk and bytes_written == 0:
- # stream error: got empy first chunk
- LOGGER.error("Stream error on track %s", queue_track.item_id)
- # prevent player queue get stuck by just skipping to the next track
- queue_track.duration = 0
- continue
- if cur_chunk <= 2 and not last_fadeout_data:
- # no fadeout_part available so just pass it to the output directly
- yield chunk
- bytes_written += len(chunk)
- del chunk
- elif cur_chunk == 1 and last_fadeout_data:
- prev_chunk = chunk
- del chunk
- # HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN
- elif cur_chunk == 2 and last_fadeout_data:
- # combine the first 2 chunks and strip off silence
- first_part = await strip_silence(prev_chunk + chunk, pcm_args)
- if len(first_part) < buffer_size:
- # part is too short after the strip action?!
- # so we just use the full first part
- first_part = prev_chunk + chunk
- fade_in_part = first_part[:buffer_size]
- remaining_bytes = first_part[buffer_size:]
- del first_part
- # do crossfade
- crossfade_part = await crossfade_pcm_parts(
- fade_in_part, last_fadeout_data, pcm_args, fade_length
- )
- # send crossfade_part
- yield crossfade_part
- bytes_written += len(crossfade_part)
- del crossfade_part
- del fade_in_part
- last_fadeout_data = b""
- # also write the leftover bytes from the strip action
- yield remaining_bytes
- bytes_written += len(remaining_bytes)
- del remaining_bytes
- del chunk
- prev_chunk = None # needed to prevent this chunk being sent again
- # HANDLE LAST PART OF TRACK
- elif prev_chunk and is_last_chunk:
- # last chunk received so create the last_part
- # with the previous chunk and this chunk
- # and strip off silence
- last_part = await strip_silence(prev_chunk + chunk, pcm_args, True)
- if len(last_part) < buffer_size:
- # part is too short after the strip action
- # so we just use the entire original data
- last_part = prev_chunk + chunk
- if not player_queue.crossfade_enabled or len(last_part) < buffer_size:
- # crossfading is not enabled or not enough data,
- # so just pass the (stripped) audio data
- if not player_queue.crossfade_enabled:
- LOGGER.warning(
- "Not enough data for crossfade: %s", len(last_part)
- )
-
- yield last_part
- bytes_written += len(last_part)
- del last_part
- del chunk
- else:
- # handle crossfading support
- # store fade section to be picked up for next track
- last_fadeout_data = last_part[-buffer_size:]
- remaining_bytes = last_part[:-buffer_size]
- # write remaining bytes
- if remaining_bytes:
- yield remaining_bytes
- bytes_written += len(remaining_bytes)
- del last_part
- del remaining_bytes
- del chunk
- # MIDDLE PARTS OF TRACK
- else:
- # middle part of the track
- # keep previous chunk in memory so we have enough
- # samples to perform the crossfade
- if prev_chunk:
- yield prev_chunk
- bytes_written += len(prev_chunk)
- prev_chunk = chunk
- else:
- prev_chunk = chunk
- del chunk
- # end of the track reached
- # update actual duration to the queue for more accurate now playing info
- accurate_duration = bytes_written / sample_size
- queue_track.duration = accurate_duration
- LOGGER.debug(
- "Finished Streaming queue track: %s (%s) on queue %s",
- queue_track.item_id,
- queue_track.name,
- player_queue.player.name,
- )
- # end of queue reached, pass last fadeout bits to final output
- if last_fadeout_data:
- yield last_fadeout_data
- del last_fadeout_data
- # END OF QUEUE STREAM
-argparse==1.4.0
-async-timeout==4.0.2
-pychromecast==10.3.0
-aiohttp[speedups]==3.8.1
-asyncio-throttle==1.0.2
-aiofiles==0.8.0
-aiosqlite==0.17.0
-pytaglib==1.5.0
-python-slugify==6.1.1
-memory-tempfile==2.2.3
-aiorun==2021.10.1
-soco==0.27.1
-pillow==9.0.1
-aiohttp_cors==0.7.0
-unidecode==1.3.4
-PyJWT==2.3.0
-zeroconf==0.38.4
-passlib==1.7.4
-cryptography==36.0.2
-ujson==5.1.0
-mashumaro==3.0
-typing-inspect==0.6.0; python_version < '3.8'
-uvloop==0.16.0; sys_platform != 'win32'
+argparse>=1.3.0,<=1.4
+async-timeout>=3.0,<=4.0.2
+aiohttp[speedups]>=3.7.0
+asyncio-throttle>=1.0,<=1.0.2
+aiofiles>=0.7,<=0.8.0
+databases>=0.5,<=0.5.5
+aiosqlite>=0.13,<=0.17
+pytaglib>=1.4,<=1.5
+python-slugify>=4.0,<=6.1.1
+memory-tempfile<=2.2.3
+aiorun>=2021.10,<=2021.10.1
+pillow>=8.0,<=9.0.1
+unidecode>=1.0,<=1.3.4
+ujson>=4.0,<=5.1.0
+mashumaro>=3.0,<=3.1
+uvloop>=0.15.0; sys_platform != 'win32'
from setuptools import find_packages, setup
-import music_assistant.constants as mass_const
-
PROJECT_NAME = "Music Assistant"
PROJECT_PACKAGE_NAME = "music_assistant"
+PROJECT_VERSION = "1.0.0"
+PROJECT_REQ_PYTHON_VERSION = "3.9"
PROJECT_LICENSE = "Apache License 2.0"
PROJECT_AUTHOR = "Marcel van der Veldt"
PROJECT_URL = "https://music-assistant.github.io/"
GITHUB_PATH = f"{PROJECT_GITHUB_USERNAME}/{PROJECT_GITHUB_REPOSITORY}"
GITHUB_URL = f"https://github.com/{GITHUB_PATH}"
-DOWNLOAD_URL = f"{GITHUB_URL}/archive/{mass_const.__version__}.zip"
+DOWNLOAD_URL = f"{GITHUB_URL}/archive/{PROJECT_VERSION}.zip"
PROJECT_URLS = {
"Bug Reports": f"{GITHUB_URL}/issues",
"Website": "https://music-assistant.github.io/",
setup(
name=PROJECT_PACKAGE_NAME,
- version=mass_const.__version__,
+ version=PROJECT_VERSION,
url=PROJECT_URL,
download_url=DOWNLOAD_URL,
project_urls=PROJECT_URLS,
include_package_data=True,
zip_safe=False,
install_requires=REQUIRES,
- python_requires=f">={mass_const.REQUIRED_PYTHON_VER}",
+ python_requires=f">={PROJECT_REQ_PYTHON_VERSION}",
test_suite="tests",
entry_points={
"console_scripts": [
[tox]
-envlist = py37, py38, lint, mypy
+envlist = py38, py310, lint, mypy
skip_missing_interpreters = True
[gh-actions]
python =
- 3.7: py37, lint, mypy
- 3.8: py38
+ 3.9: py39, lint, mypy
+ 3.10: py310
[testenv:lint]
basepython = python3