From: Marcel van der Veldt Date: Sun, 1 Aug 2021 14:44:18 +0000 (+0200) Subject: 0.0.30 (#142) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f5d4df747f3eee080162bb4a558013d446389c48;p=music-assistant-server.git 0.0.30 (#142) * Add dedicated task manager and eventbus * fix typos * some refactoring * bump requirements --- diff --git a/.vscode/settings.json b/.vscode/settings.json index 889b7d54..efcfe8f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "python.linting.pylintEnabled": true, "python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/setup.cfg"], "python.linting.enabled": true, - "python.pythonPath": "/Users/marcelvanderveldt/Workdir/music-assistant/server/.venv/bin/python3.9", + "python.pythonPath": ".venv/bin/python3.9", "python.linting.flake8Enabled": true, "python.linting.flake8Args": ["--config=${workspaceFolder}/setup.cfg"], "python.linting.mypyEnabled": false, diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 89733dd5..31a11b9d 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -46,7 +46,6 @@ 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_QUEUE_TIME_UPDATED = "queue time updated" EVENT_SHUTDOWN = "application shutdown" EVENT_PROVIDER_REGISTERED = "provider registered" EVENT_PROVIDER_UNREGISTERED = "provider unregistered" @@ -55,6 +54,7 @@ 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" diff --git a/music_assistant/helpers/alert.mp3 b/music_assistant/helpers/alert.mp3 new file mode 100644 index 00000000..133b3ac7 Binary files /dev/null and b/music_assistant/helpers/alert.mp3 differ diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py new file mode 100644 index 00000000..dab87cd5 --- /dev/null +++ b/music_assistant/helpers/audio.py @@ -0,0 +1,206 @@ +"""Various helpers for audio manipulation.""" + +import asyncio +import logging +from typing import List, Tuple + +from music_assistant.helpers.process import AsyncProcess +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 + +LOGGER = logging.getLogger("audio") + + +async def crossfade_pcm_parts( + fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int +) -> bytes: + """Crossfade two chunks of pcm/raw audio using sox.""" + # 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)] + 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 += [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 + ["-"] + async with AsyncProcess(args, enable_write=False) as sox_proc: + crossfade_part, _ = await sox_proc.communicate() + fadeinfile.close() + fadeoutfile.close() + del fadeinfile + del fadeoutfile + return crossfade_part + + +async def strip_silence(audio_data: bytes, pcm_args: List[str], reverse=False) -> bytes: + """Strip silence from (a chunk of) pcm audio.""" + args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"] + if reverse: + args.append("reverse") + args += ["silence", "1", "0.1", "1%"] + if reverse: + args.append("reverse") + async with AsyncProcess(args, enable_write=True) as sox_proc: + stripped_data, _ = await sox_proc.communicate(audio_data) + return stripped_data + + +async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> None: + """Analyze track audio, for now we only calculate EBU R128 loudness.""" + + if streamdetails.loudness is not None: + # only when needed we do the analyze stuff + return + + # only when needed we do the analyze stuff + LOGGER.debug( + "Start analyzing track %s/%s", + streamdetails.provider, + streamdetails.item_id, + ) + # calculate BS.1770 R128 integrated loudness with ffmpeg + if streamdetails.type == StreamType.EXECUTABLE: + proc_args = ( + "%s | ffmpeg -i pipe: -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'" + % streamdetails.path + ) + else: + proc_args = ( + "ffmpeg -i '%s' -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'" + % streamdetails.path + ) + audio_data = b"" + if streamdetails.media_type == MediaType.RADIO: + proc_args = "ffmpeg -i pipe: -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'" + # for radio we collect ~10 minutes of audio data to process + async with mass.http_session.get(streamdetails.path) as response: + async for chunk, _ in response.content.iter_chunks(): + audio_data += chunk + if len(audio_data) >= 20000: + break + + proc = await asyncio.create_subprocess_shell( + proc_args, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE if audio_data else None, + ) + value, _ = await proc.communicate(audio_data or None) + loudness = float(value.decode().strip()) + await mass.database.set_track_loudness( + streamdetails.item_id, streamdetails.provider, loudness + ) + LOGGER.debug( + "Integrated loudness of %s/%s is: %s", + streamdetails.provider, + streamdetails.item_id, + loudness, + ) + + +async def get_stream_details( + mass: MusicAssistant, queue_item: QueueItem, player_id: str = "" +) -> StreamDetails: + """ + Get streamdetails for the given media_item. + + This is called just-in-time when a player/queue 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. + """ + if queue_item.provider == "url": + # special case: a plain url was added to the queue + if queue_item.streamdetails is not None: + streamdetails = queue_item.streamdetails + else: + streamdetails = StreamDetails( + type=StreamType.URL, + provider="url", + item_id=queue_item.item_id, + path=queue_item.uri, + content_type=ContentType(queue_item.uri.split(".")[-1]), + sample_rate=44100, + bit_depth=16, + ) + 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: + 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 + ): + if not prov_media.available: + continue + # get streamdetails from provider + music_prov = mass.get_provider(prov_media.provider) + if not music_prov or not music_prov.available: + continue # provider temporary unavailable ? + + streamdetails: StreamDetails = await music_prov.get_stream_details( + prov_media.item_id + ) + if streamdetails: + try: + streamdetails.content_type = ContentType(streamdetails.content_type) + except KeyError: + LOGGER.warning("Invalid content type!") + else: + break + + if streamdetails: + # set player_id on the streamdetails so we know what players stream + streamdetails.player_id = player_id + # get gain correct / replaygain + if not queue_item.name == "alert": + loudness, gain_correct = await get_gain_correct( + mass, player_id, streamdetails.item_id, streamdetails.provider + ) + streamdetails.gain_correct = gain_correct + streamdetails.loudness = loudness + # set streamdetails as attribute on the media_item + # this way the app knows what content is playing + queue_item.streamdetails = streamdetails + return streamdetails + return None + + +async def get_gain_correct( + mass: MusicAssistant, player_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"]: + return 0 + target_gain = int(player_conf["target_volume"]) + track_loudness = await mass.database.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) + if fallback_track_loudness is None: + # fallback to some (hopefully sane) average value for now + fallback_track_loudness = -8.5 + gain_correct = target_gain - fallback_track_loudness + else: + gain_correct = target_gain - track_loudness + gain_correct = round(gain_correct, 2) + return (track_loudness, gain_correct) diff --git a/music_assistant/helpers/cache.py b/music_assistant/helpers/cache.py index dca7611a..fc7ea370 100644 --- a/music_assistant/helpers/cache.py +++ b/music_assistant/helpers/cache.py @@ -9,7 +9,8 @@ import time from typing import Awaitable import aiosqlite -from music_assistant.helpers.util import run_periodic +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.helpers.util import create_task LOGGER = logging.getLogger("cache") @@ -19,13 +20,13 @@ class Cache: _db = None - def __init__(self, mass): + 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._mem_cache = {} - async def setup(self): + 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( @@ -35,7 +36,7 @@ class Cache: await db_conn.commit() await db_conn.execute("VACUUM;") await db_conn.commit() - self.mass.add_job(self.auto_cleanup()) + self.mass.tasks.add("Cleanup cache", self.auto_cleanup, periodic=3600) async def get(self, cache_key, checksum="", default=None): """ @@ -102,13 +103,11 @@ class Cache: await db_conn.execute(sql_query, (cache_key,)) await db_conn.commit() - @run_periodic(3600) async def auto_cleanup(self): """Sceduled auto cleanup task.""" # for now we simply rest the memory cache self._mem_cache = {} cur_timestamp = int(time.time()) - LOGGER.debug("Running cleanup...") sql_query = "SELECT id, expires FROM simplecache" async with aiosqlite.connect(self._dbfile, timeout=600) as db_conn: db_conn.row_factory = aiosqlite.Row @@ -122,7 +121,6 @@ class Cache: await db_conn.execute(sql_query, (cache_id,)) # compact db await db_conn.commit() - LOGGER.debug("Auto cleanup done") @staticmethod def _get_checksum(stringinput): @@ -146,7 +144,7 @@ async def cached( if cache_result is not None: return cache_result result = await coro_func(*args) - asyncio.create_task(cache.set(cache_key, result, checksum, expires)) + create_task(cache.set(cache_key, result, checksum, expires)) return result @@ -165,7 +163,7 @@ def use_cache(cache_days=14, cache_checksum=None): if cachedata is not None: return cachedata result = await func(*args, **kwargs) - asyncio.create_task( + create_task( method_class.cache.set( cache_str, result, diff --git a/music_assistant/helpers/datetime.py b/music_assistant/helpers/datetime.py new file mode 100644 index 00000000..3373c0bf --- /dev/null +++ b/music_assistant/helpers/datetime.py @@ -0,0 +1,40 @@ +"""Helpers for date and time.""" + +import datetime + +LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo + + +def utc() -> datetime.datetime: + """Get current UTC datetime.""" + return datetime.datetime.now(datetime.timezone.utc) + + +def utc_timestamp() -> float: + """Return UTC timestamp in seconds as float.""" + return utc().timestamp() + + +def now() -> datetime.datetime: + """Get current datetime in local timezone.""" + return datetime.datetime.now(LOCAL_TIMEZONE) + + +def now_timestamp() -> float: + """Return current datetime as timestamp in local timezone.""" + return now().timestamp() + + +def future_timestamp(**kwargs) -> float: + """Return current timestamp + timedelta.""" + return (now() + datetime.timedelta(**kwargs)).timestamp() + + +def from_utc_timestamp(timestamp: float) -> datetime.datetime: + """Return datetime from UTC timestamp.""" + return datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc) + + +def iso_from_utc_timestamp(timestamp: float) -> str: + """Return ISO 8601 datetime string from UTC timestamp.""" + return from_utc_timestamp(timestamp).isoformat() diff --git a/music_assistant/helpers/errors.py b/music_assistant/helpers/errors.py new file mode 100644 index 00000000..076fd86c --- /dev/null +++ b/music_assistant/helpers/errors.py @@ -0,0 +1,5 @@ +"""Custom errors and exceptions.""" + + +class AuthenticationError(Exception): + """Custom Exception for all authentication errors.""" diff --git a/music_assistant/helpers/images.py b/music_assistant/helpers/images.py index ebdea7aa..2489c6db 100644 --- a/music_assistant/helpers/images.py +++ b/music_assistant/helpers/images.py @@ -64,14 +64,14 @@ async def get_image_url( and item.artist.metadata.get("image") ): return item.album.metadata["image"] - if media_type == MediaType.Track and item.album: + if media_type == MediaType.TRACK and item.album: # try album instead for tracks return await get_image_url( - mass, item.album.item_id, item.album.provider, MediaType.Album + mass, item.album.item_id, item.album.provider, MediaType.ALBUM ) - if media_type == MediaType.Album and item.artist: + if media_type == MediaType.ALBUM and item.artist: # try artist instead for albums return await get_image_url( - mass, item.artist.item_id, item.artist.provider, MediaType.Artist + mass, item.artist.item_id, item.artist.provider, MediaType.ARTIST ) return None diff --git a/music_assistant/helpers/muli_state_queue.py b/music_assistant/helpers/muli_state_queue.py new file mode 100644 index 00000000..7655704b --- /dev/null +++ b/music_assistant/helpers/muli_state_queue.py @@ -0,0 +1,66 @@ +"""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) diff --git a/music_assistant/helpers/process.py b/music_assistant/helpers/process.py index 27309da9..db211cd4 100644 --- a/music_assistant/helpers/process.py +++ b/music_assistant/helpers/process.py @@ -7,32 +7,46 @@ even when properly handling reading/writes from different tasks. import asyncio import logging -from typing import AsyncGenerator, List, Optional +from typing import AsyncGenerator, List, Optional, Union from async_timeout import timeout LOGGER = logging.getLogger("AsyncProcess") -DEFAULT_CHUNKSIZE = 512000 -DEFAULT_TIMEOUT = 60 +DEFAULT_CHUNKSIZE = 256000 +DEFAULT_TIMEOUT = 10 class AsyncProcess: """Implementation of a (truly) non blocking subprocess.""" - def __init__(self, process_args: List, enable_write: bool = False): + def __init__(self, args: Union[List, str], enable_write: bool = False): """Initialize.""" self._proc = None - self._process_args = process_args + self._args = args self._enable_write = enable_write async def __aenter__(self) -> "AsyncProcess": """Enter context manager.""" - self._proc = await asyncio.create_subprocess_exec( - *self._process_args, - stdin=asyncio.subprocess.PIPE if self._enable_write else None, - stdout=asyncio.subprocess.PIPE - ) + if "|" in self._args: + self._args = " ".join(self._args) + + if isinstance(self._args, str): + self._proc = await asyncio.create_subprocess_shell( + self._args, + stdin=asyncio.subprocess.PIPE if self._enable_write else None, + stdout=asyncio.subprocess.PIPE, + limit=8000000, + close_fds=True, + ) + else: + self._proc = await asyncio.create_subprocess_exec( + *self._args, + stdin=asyncio.subprocess.PIPE if self._enable_write else None, + stdout=asyncio.subprocess.PIPE, + limit=8000000, + close_fds=True, + ) return self async def __aexit__(self, exc_type, exc_value, traceback) -> bool: @@ -54,21 +68,25 @@ class AsyncProcess: """Yield chunks from the process stdout. Generator.""" while True: chunk = await self.read(chunk_size) + if not chunk: + break yield chunk - if len(chunk) < chunk_size: + if chunk_size is not None and len(chunk) < chunk_size: break - async def read( - self, chunk_size: int = DEFAULT_CHUNKSIZE, time_out: int = DEFAULT_TIMEOUT - ) -> bytes: + async def read(self, chunk_size: int = DEFAULT_CHUNKSIZE) -> bytes: """Read x bytes from the process stdout.""" + if self._proc.stdout.at_eof() or self._proc.returncode is not None: + return b"" try: - async with timeout(time_out): + async with timeout(DEFAULT_TIMEOUT): + if chunk_size is None: + return await self._proc.stdout.read(DEFAULT_CHUNKSIZE) return await self._proc.stdout.readexactly(chunk_size) except asyncio.IncompleteReadError as err: return err.partial - except AttributeError: - raise asyncio.CancelledError() + except AttributeError as exc: + raise asyncio.CancelledError() from exc async def write(self, data: bytes) -> None: """Write data to process stdin.""" diff --git a/music_assistant/helpers/repath.py b/music_assistant/helpers/repath.py deleted file mode 100644 index f5857e6d..00000000 --- a/music_assistant/helpers/repath.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -Helper functionalities for path detection in api routes. - -Based on the original work by nickcoutsos and synacor -https://github.com/nickcoutsos/python-repath -""" -import re -import urllib -import urllib.parse - -REGEXP_TYPE = type(re.compile("")) -PATH_REGEXP = re.compile( - "|".join( - [ - # Match escaped characters that would otherwise appear in future matches. - # This allows the user to escape special characters that won't transform. - "(\\\\.)", - # Match Express-style parameters and un-named parameters with a prefix - # and optional suffixes. Matches appear as: - # - # "/:test(\\d+)?" => ["/", "test", "\d+", undefined, "?", undefined] - # "/route(\\d+)" => [undefined, undefined, undefined, "\d+", undefined, undefined] - # "/*" => ["/", undefined, undefined, undefined, undefined, "*"] - "([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^()])+)\\))?|\\(((?:\\\\.|[^()])+)\\))([+*?])?|(\\*))", - ] - ) -) - - -def escape_string(string): - """Escape URL-acceptable regex special-characters.""" - return re.sub("([.+*?=^!:${}()[\\]|])", r"\\\1", string) - - -def escape_group(group): - """Escape group.""" - return re.sub("([=!:$()])", r"\\\1", group) - - -def parse(string): - """Parse a string for the raw tokens.""" - tokens = [] - key = 0 - index = 0 - path = "" - - for match in PATH_REGEXP.finditer(string): - matched = match.group(0) - escaped = match.group(1) - offset = match.start(0) - path += string[index:offset] - index = offset + len(matched) - - if escaped: - path += escaped[1] - continue - - if path: - tokens.append(path) - path = "" - - prefix, name, capture, group, suffix, asterisk = match.groups()[1:] - repeat = suffix in ("+", "*") - optional = suffix in ("?", "*") - delimiter = prefix or "/" - pattern = capture or group or (".*" if asterisk else "[^%s]+?" % delimiter) - - if not name: - name = key - key += 1 - - token = { - "name": str(name), - "prefix": prefix or "", - "delimiter": delimiter, - "optional": optional, - "repeat": repeat, - "pattern": escape_group(pattern), - } - - tokens.append(token) - - if index < len(string): - path += string[index:] - - if path: - tokens.append(path) - - return tokens - - -def tokens_to_function(tokens): - """Expose a method for transforming tokens into the path function.""" - - def transform(obj): - path = "" - obj = obj or {} - - for key in tokens: - if isinstance(key, str): - path += key - continue - - regexp = re.compile("^%s$" % key["pattern"]) - - value = obj.get(key["name"]) - if value is None: - if key["optional"]: - continue - raise KeyError('Expected "{name}" to be defined'.format(**key)) - - if isinstance(value, list): - if not key["repeat"]: - raise TypeError('Expected "{name}" to not repeat'.format(**key)) - - if not value: - if key["optional"]: - continue - raise ValueError('Expected "{name}" to not be empty'.format(**key)) - - for i, val in enumerate(value): - val = str(val) - if not regexp.search(val): - raise ValueError( - 'Expected all "{name}" to match "{pattern}"'.format(**key) - ) - - path += key["prefix"] if i == 0 else key["delimiter"] - path += urllib.parse.quote(val, "") - - continue - - value = str(value) - if not regexp.search(value): - raise ValueError('Expected "{name}" to match "{pattern}"'.format(**key)) - - path += key["prefix"] + urllib.parse.quote( - value.encode("utf8"), "-_.!~*'()" - ) - - return path - - return transform - - -def regexp_to_pattern(regexp, keys): - """ - Generate a pattern based on a compiled regular expression. - - This function exists for a semblance of compatibility with pathToRegexp - and serves basically no purpose beyond making sure the pre-existing tests - continue to pass. - - """ - _match = re.search(r"\((?!\?)", regexp.pattern) - - if _match: - keys.extend( - [ - { - "name": i, - "prefix": None, - "delimiter": None, - "optional": False, - "repeat": False, - "pattern": None, - } - for i in range(len(_match.groups())) - ] - ) - - return regexp.pattern - - -def tokens_to_pattern(tokens, options=None): - """Generate a pattern for the given list of tokens.""" - options = options or {} - - strict = options.get("strict") - end = options.get("end") is not False - route = "" - last_token = tokens[-1] - ends_with_slash = isinstance(last_token, str) and last_token.endswith("/") - - patterns = dict( - REPEAT="(?:{prefix}{capture})*", - OPTIONAL="(?:{prefix}({name}{capture}))?", - REQUIRED="{prefix}({name}{capture})", - ) - - for token in tokens: - if isinstance(token, str): - route += escape_string(token) - continue - - parts = { - "prefix": escape_string(token["prefix"]), - "capture": token["pattern"], - "name": "", - } - - if token["name"] and re.search("[a-zA-Z]", token["name"]): - parts["name"] = "?P<%s>" % re.escape(token["name"]) - - if token["repeat"]: - parts["capture"] += patterns["REPEAT"].format(**parts) - - template = patterns["OPTIONAL" if token["optional"] else "REQUIRED"] - route += template.format(**parts) - - if not strict: - route = route[:-1] if ends_with_slash else route - route += "(?:/(?=$))?" - - if end: - route += "$" - else: - route += "" if strict and ends_with_slash else "(?=/|$)" - - return "^%s" % route - - -def array_to_pattern(paths, keys, options): - """Generate a single pattern from an array of path pattern values.""" - parts = [path_to_pattern(path, keys, options) for path in paths] - - return "(?:%s)" % ("|".join(parts)) - - -def string_to_pattern(path, keys, options): - """ - Generate pattern for a string. - - Equivalent to `tokens_to_pattern(parse(string))`. - """ - tokens = parse(path) - pattern = tokens_to_pattern(tokens, options) - - tokens = filter(lambda t: not isinstance(t, str), tokens) - keys.extend(tokens) - - return pattern - - -def path_to_pattern(path, keys=None, options=None): - """ - Generate a pattern from any kind of path value. - - This function selects the appropriate function array/regex/string paths, - and calls it with the provided values. - """ - keys = keys if keys is not None else [] - options = options if options is not None else {} - - if isinstance(path, REGEXP_TYPE): - return regexp_to_pattern(path, keys) - if isinstance(path, list): - return array_to_pattern(path, keys, options) - return string_to_pattern(path, keys, options) - - -def match_pattern(pattrn, requested_url_path): - """Return shorthand to match function.""" - return re.match(pattrn, requested_url_path) diff --git a/music_assistant/helpers/typing.py b/music_assistant/helpers/typing.py index 2b567a7d..bb5b3b84 100644 --- a/music_assistant/helpers/typing.py +++ b/music_assistant/helpers/typing.py @@ -9,9 +9,10 @@ if TYPE_CHECKING: QueueItem, PlayerQueue, ) - from music_assistant.models.streamdetails import StreamDetails + 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" @@ -20,6 +21,8 @@ else: StreamDetails = "StreamDetails" Player = "Player" ConfigSubItem = "ConfigSubItem" + MediaType = "MediaType" + StreamType = "StreamType" QueueItems = Set[QueueItem] diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index 8a7cc408..682144f4 100755 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -1,18 +1,23 @@ """Helper and utility functions.""" import asyncio +import functools import logging import os import platform import socket import struct import tempfile +import threading import urllib.request +from asyncio.events import AbstractEventLoop from io import BytesIO -from typing import Any, Callable, Dict, Optional, Set, TypeVar +from typing import Any, Callable, Dict, Optional, Set, TypeVar, Union import memory_tempfile import ujson +from .typing import MediaType + # pylint: disable=invalid-name T = TypeVar("T") _UNDEF: dict = {} @@ -20,6 +25,8 @@ CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) CALLBACK_TYPE = Callable[[], None] # pylint: enable=invalid-name +DEFAULT_LOOP = None + def callback(func: CALLABLE_T) -> CALLABLE_T: """Annotation to mark method as safe to call from within the event loop.""" @@ -32,6 +39,58 @@ def is_callback(func: Callable[..., Any]) -> bool: return getattr(func, "_mass_callback", False) is True +def create_task( + target: Callable[..., Any], + *args: Any, + loop: AbstractEventLoop = None, + **kwargs: Any, +) -> Union[asyncio.Task, asyncio.Future]: + """Create Task on (main) event loop from Callable or awaitable. + + target: target to call. + loop: Running (main) event loop, defaults to loop in current thread + args/kwargs: parameters for method to call. + """ + try: + loop = loop or asyncio.get_running_loop() + except RuntimeError: + # try to fetch the default loop from global variable + loop = DEFAULT_LOOP + + # Check for partials to properly determine if coroutine function + check_target = target + 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) + + # called from other thread + if threading.current_thread() is not threading.main_thread(): + if asyncio.iscoroutine(check_target): + 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 + ) + + if asyncio.iscoroutine(check_target): + 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)) + + def run_periodic(period): """Run a coroutine at interval.""" @@ -61,25 +120,6 @@ def filename_from_string(string): return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip() -def run_background_task(corofn, *args, executor=None): - """Run non-async task in background.""" - return asyncio.get_event_loop().run_in_executor(executor, corofn, *args) - - -def run__background_task(executor, corofn, *args): - """Run async task in background.""" - - def run_task(corofn, *args): - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - coro = corofn(*args) - res = new_loop.run_until_complete(coro) - new_loop.close() - return res - - return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args) - - def try_parse_int(possible_int): """Try to parse an int.""" try: @@ -298,6 +338,11 @@ def get_changed_keys( 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}" + + def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=3600): """Generate a wave header from given params.""" file = BytesIO() diff --git a/music_assistant/helpers/web.py b/music_assistant/helpers/web.py index 98c35a4e..29704b93 100644 --- a/music_assistant/helpers/web.py +++ b/music_assistant/helpers/web.py @@ -1,19 +1,16 @@ """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, Union +from typing import Any, Callable, Dict, Optional, Tuple, Union, get_args, get_origin import ujson from aiohttp import web - -try: - # python 3.8+ - from typing import get_args, get_origin -except ImportError: - # python 3.7 - from typing_inspect import get_args, get_origin +from music_assistant.helpers.typing import MusicAssistant def require_local_subnet(func): @@ -43,23 +40,33 @@ def serialize_values(obj): """Recursively create serializable values for (custom) data types.""" def get_val(val): - if hasattr(val, "to_dict"): - return val.to_dict() - if isinstance(val, (list, set, filter, tuple)): - return [get_val(x) for x in val] - if val.__class__ == "dict_valueiterator": + if ( + isinstance(val, (list, set, filter, tuple)) + or val.__class__ == "dict_valueiterator" + ): return [get_val(x) for x in val] if isinstance(val, dict): return {key: get_val(value) for key, value in val.items()} - return val + try: + return val.to_dict() + except AttributeError: + return val return get_val(obj) -def json_serializer(obj): +def json_serializer(data): """Json serializer to recursively create serializable values for custom data types.""" - return ujson.dumps(serialize_values(obj)) - # return ujson.dumps(obj) + 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): @@ -69,39 +76,70 @@ def json_response(data: Any, status: int = 200): ) -def api_route(ws_cmd_path, ws_require_auth=True): - """Decorate a function as websocket command.""" +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.ws_cmd_path = ws_cmd_path - func.ws_require_auth = ws_require_auth + 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 vaildation and/or api spec generation.""" + """Parse signature of function to do type validation and/or api spec generation.""" signature = inspect.signature(call) - typed_params = [ - inspect.Parameter( - name=param.name, - kind=param.kind, - default=param.default, - annotation=param.annotation, - ) - for param in signature.parameters.values() - ] - typed_signature = inspect.Signature(typed_params) - return typed_signature + return signature -def parse_arguments(call: Callable, args: dict): +def parse_arguments(mass: MusicAssistant, func_sig: inspect.Signature, args: dict): """Parse (and convert) incoming arguments to correct types.""" final_args = {} - if isinstance(call, type({}.values)): - return args - func_sig = get_typed_signature(call) for key, value in args.items(): if key not in func_sig.parameters: raise KeyError("Invalid parameter: '%s'" % key) @@ -109,7 +147,9 @@ def parse_arguments(call: Callable, args: dict): final_args[key] = convert_value(key, value, arg_type) # check for missing args for key, value in func_sig.parameters.items(): - if value.default is inspect.Parameter.empty: + 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 @@ -138,3 +178,34 @@ def convert_value(arg_key, value, arg_type): 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 diff --git a/music_assistant/managers/config.py b/music_assistant/managers/config.py index e3a52a14..7eb266ef 100755 --- a/music_assistant/managers/config.py +++ b/music_assistant/managers/config.py @@ -1,7 +1,6 @@ """All classes and helpers for the Configuration.""" import copy -import datetime import json import logging import os @@ -31,8 +30,10 @@ from music_assistant.constants import ( 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.util import merge_dict, try_load_json_file +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.helpers.util import create_task, merge_dict, try_load_json_file from music_assistant.helpers.web import api_route from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType from music_assistant.models.player import PlayerControlType @@ -134,7 +135,7 @@ PROVIDER_TYPE_MAPPINGS = { class ConfigManager: """Class which holds our configuration.""" - def __init__(self, mass, data_path: str): + def __init__(self, mass: MusicAssistant, data_path: str): """Initialize class.""" self._data_path = data_path self._stored_config = {} @@ -149,19 +150,25 @@ class ConfigManager: """Async initialize of module.""" self._translations = await self._fetch_translations() - @api_route("config/:conf_base?/:conf_key?") - def all_items(self, conf_base: str = "", conf_key: str = "") -> dict: + @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.""" - if conf_base and conf_key: - obj = getattr(self, conf_base)[conf_key] - if isinstance(obj, dict): - return obj - return obj.all_items() - if conf_base: - obj = getattr(self, conf_base) - if isinstance(obj, dict): - return obj - return obj.all_items() return { key: getattr(self, key).all_items() for key in [ @@ -175,7 +182,7 @@ class ConfigManager: ] } - @api_route("config/:conf_base/:conf_key/:conf_val") + @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: @@ -185,7 +192,7 @@ class ConfigManager: self[conf_base][conf_key][conf_val] = new_value return self[conf_base][conf_key].all_items() - @api_route("config/delete/:conf_base/:conf_key") + @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) @@ -408,7 +415,7 @@ class SecuritySettings(ConfigBaseItem): return self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][ client_id - ]["last_login"] = datetime.datetime.utcnow().timestamp() + ]["last_login"] = utc_timestamp() self.conf_mgr.save() def revoke_app_token(self, client_id): @@ -417,7 +424,7 @@ class SecuritySettings(ConfigBaseItem): CONF_KEY_SECURITY_APP_TOKENS ].pop(client_id) self.conf_mgr.save() - self.conf_mgr.mass.signal_event( + self.conf_mgr.mass.eventbus.signal( EVENT_CONFIG_CHANGED, (CONF_KEY_SECURITY, CONF_KEY_SECURITY_APP_TOKENS) ) return return_info @@ -663,19 +670,17 @@ class ConfigSubItem: stored_conf[self.parent_conf_key][self.conf_key] = {} stored_conf[self.parent_conf_key][self.conf_key][key] = value - self.conf_mgr.mass.add_job(self.conf_mgr.save) + 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: - self.conf_mgr.mass.add_job( - self.conf_mgr.mass.reload_provider(self.conf_key) - ) + 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 - self.conf_mgr.mass.add_job( + create_task( self.conf_mgr.mass.players.trigger_player_update(self.conf_key) ) # signal config changed event - self.conf_mgr.mass.signal_event( + self.conf_mgr.mass.eventbus.signal( EVENT_CONFIG_CHANGED, (self.parent_conf_key, self.conf_key) ) return diff --git a/music_assistant/managers/database.py b/music_assistant/managers/database.py index a6a9aeb9..0b1f58bf 100755 --- a/music_assistant/managers/database.py +++ b/music_assistant/managers/database.py @@ -3,11 +3,11 @@ import logging import os import statistics -from datetime import datetime 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 ( @@ -49,15 +49,15 @@ class DatabaseManager: media_type: MediaType, ) -> Optional[MediaItem]: """Get the database item for the given prov_id.""" - if media_type == MediaType.Artist: + if media_type == MediaType.ARTIST: return await self.get_artist_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.Album: + if media_type == MediaType.ALBUM: return await self.get_album_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.Track: + if media_type == MediaType.TRACK: return await self.get_track_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.Playlist: + if media_type == MediaType.PLAYLIST: return await self.get_playlist_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.Radio: + if media_type == MediaType.RADIO: return await self.get_radio_by_prov_id(provider_id, prov_item_id) return None @@ -145,19 +145,19 @@ class DatabaseManager: """Search library for the given searchphrase.""" result = SearchResult([], [], [], [], []) searchquery = "%" + searchquery + "%" - if media_types is None or MediaType.Artist in media_types: + 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: + 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: + 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: + 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: + 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 @@ -195,7 +195,7 @@ class DatabaseManager: orderby: str = "name", ) -> List[Playlist]: """Get all playlists from database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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: @@ -224,7 +224,7 @@ class DatabaseManager: if filter_query: sql_query += " " + filter_query sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: db_conn.row_factory = aiosqlite.Row return [ Radio.from_db_row(db_row) @@ -241,7 +241,7 @@ class DatabaseManager: 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=120) as db_conn: + 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=?;", @@ -275,7 +275,7 @@ class DatabaseManager: db_conn, ) await self._add_prov_ids( - new_item[0], MediaType.Playlist, playlist.provider_ids, db_conn=db_conn + 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) @@ -284,7 +284,7 @@ class DatabaseManager: async def update_playlist(self, item_id: int, playlist: Playlist): """Update a playlist record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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( @@ -316,7 +316,7 @@ class DatabaseManager: ), ) await self._add_prov_ids( - item_id, MediaType.Playlist, playlist.provider_ids, db_conn=db_conn + 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() @@ -326,7 +326,7 @@ class DatabaseManager: 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=120) as db_conn: + 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 @@ -353,7 +353,7 @@ class DatabaseManager: db_conn, ) await self._add_prov_ids( - new_item[0], MediaType.Radio, radio.provider_ids, db_conn=db_conn + 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) @@ -362,7 +362,7 @@ class DatabaseManager: async def update_radio(self, item_id: int, radio: Radio): """Update a radio record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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( @@ -388,16 +388,16 @@ class DatabaseManager: ), ) await self._add_prov_ids( - item_id, MediaType.Radio, radio.provider_ids, db_conn=db_conn + 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, provider: str): + 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=120) as db_conn: + 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=?;" @@ -405,10 +405,12 @@ class DatabaseManager: await db_conn.commit() async def remove_from_library( - self, item_id: int, media_type: MediaType, provider: str + self, + item_id: int, + media_type: MediaType, ): """Remove item from the library.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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=?;" @@ -426,7 +428,7 @@ class DatabaseManager: if filter_query: sql_query += " " + filter_query sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: db_conn.row_factory = aiosqlite.Row return [ Artist.from_db_row(db_row) @@ -442,7 +444,7 @@ class DatabaseManager: async def add_artist(self, artist: Artist): """Add a new artist record to the database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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=?;", @@ -473,7 +475,7 @@ class DatabaseManager: db_conn, ) await self._add_prov_ids( - new_item[0], MediaType.Artist, artist.provider_ids, db_conn=db_conn + 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) @@ -482,7 +484,7 @@ class DatabaseManager: async def update_artist(self, item_id: int, artist: Artist): """Update a artist record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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 @@ -505,7 +507,7 @@ class DatabaseManager: ), ) await self._add_prov_ids( - item_id, MediaType.Artist, artist.provider_ids, db_conn=db_conn + 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() @@ -523,7 +525,7 @@ class DatabaseManager: if filter_query: sql_query += " " + filter_query sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: db_conn.row_factory = aiosqlite.Row return [ Album.from_db_row(db_row) @@ -560,7 +562,7 @@ class DatabaseManager: async def add_album(self, album: Album): """Add a new album record to the database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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 @@ -613,7 +615,7 @@ class DatabaseManager: db_conn, ) await self._add_prov_ids( - new_item[0], MediaType.Album, album.provider_ids, db_conn=db_conn + 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) @@ -622,7 +624,7 @@ class DatabaseManager: async def update_album(self, item_id: int, album: Album): """Update a album record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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( @@ -636,7 +638,7 @@ class DatabaseManager: ) 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: + if cur_item.album_type == AlbumType.UNKNOWN: album_type = album.album_type else: album_type = cur_item.album_type @@ -659,7 +661,7 @@ class DatabaseManager: ), ) await self._add_prov_ids( - item_id, MediaType.Album, album.provider_ids, db_conn=db_conn + 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() @@ -677,7 +679,7 @@ class DatabaseManager: if filter_query: sql_query += " " + filter_query sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: db_conn.row_factory = aiosqlite.Row return [ Track.from_db_row(db_row) @@ -728,7 +730,7 @@ class DatabaseManager: """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=120) as db_conn: + 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 @@ -777,7 +779,7 @@ class DatabaseManager: db_conn, ) await self._add_prov_ids( - new_item[0], MediaType.Track, track.provider_ids, db_conn=db_conn + 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) @@ -786,7 +788,7 @@ class DatabaseManager: async def update_track(self, item_id: int, track: Track): """Update a track record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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) @@ -815,7 +817,7 @@ class DatabaseManager: ), ) await self._add_prov_ids( - item_id, MediaType.Track, track.provider_ids, db_conn=db_conn + 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() @@ -824,7 +826,7 @@ class DatabaseManager: 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=120) as db_conn: + 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)) @@ -832,7 +834,7 @@ class DatabaseManager: 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=120) as db_conn: + 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( @@ -846,14 +848,14 @@ class DatabaseManager: 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=120) as db_conn: + 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=120) as db_conn: + 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: @@ -862,8 +864,8 @@ class DatabaseManager: async def mark_item_played(self, item_id: str, provider: str): """Mark item as played in playlog.""" - timestamp = datetime.utcnow().timestamp() - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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)) @@ -871,7 +873,7 @@ class DatabaseManager: async def get_thumbnail_id(self, url, size): """Get/create id for thumbnail.""" - async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: + 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: diff --git a/music_assistant/managers/events.py b/music_assistant/managers/events.py new file mode 100644 index 00000000..2c07da4b --- /dev/null +++ b/music_assistant/managers/events.py @@ -0,0 +1,57 @@ +"""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 LOGGER.isEnabledFor(logging.DEBUG): + log_details = getattr( + event_details, "name", getattr(event_details, "id", event_details) + ) + LOGGER.debug("%s: %s", event_msg, log_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 diff --git a/music_assistant/managers/library.py b/music_assistant/managers/library.py index ade46ab4..54925135 100755 --- a/music_assistant/managers/library.py +++ b/music_assistant/managers/library.py @@ -1,13 +1,13 @@ """LibraryManager: Orchestrates synchronisation of music providers into the library.""" -import asyncio -import functools + import logging import time -from typing import Any, List +from typing import Any, List, Optional -from music_assistant.constants import EVENT_MUSIC_SYNC_STATUS, EVENT_PROVIDER_REGISTERED -from music_assistant.helpers.util import callback, run_periodic +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, @@ -22,62 +22,34 @@ from music_assistant.models.provider import ProviderType LOGGER = logging.getLogger("music_manager") -def sync_task(desc): - """Return decorator to report a sync task.""" - - def wrapper(func): - @functools.wraps(func) - async def wrapped(*args): - method_class = args[0] - prov_id = args[1] - # check if this sync task is not already running - for sync_prov_id, sync_desc in method_class.running_sync_jobs: - if sync_prov_id == prov_id and sync_desc == desc: - LOGGER.debug( - "Syncjob %s for provider %s is already running!", desc, prov_id - ) - return - LOGGER.info("Start syncjob %s for provider %s.", desc, prov_id) - sync_job = (prov_id, desc) - method_class.running_sync_jobs.add(sync_job) - method_class.mass.signal_event( - EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs - ) - await func(*args) - LOGGER.info("Finished syncing %s for provider %s", desc, prov_id) - method_class.running_sync_jobs.remove(sync_job) - method_class.mass.signal_event( - EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs - ) - - return wrapped - - return wrapper - - class LibraryManager: """Manage sync of musicproviders to library.""" - def __init__(self, mass): + def __init__(self, mass: MusicAssistant): """Initialize class.""" self.running_sync_jobs = set() self.mass = mass self.cache = mass.cache - self.mass.add_event_listener(self.mass_event, EVENT_PROVIDER_REGISTERED) + 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 - self.mass.add_job(self._music_providers_sync()) + # 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) - @callback - def mass_event(self, msg: str, msg_details: Any): + async def mass_event(self, msg: str, msg_details: Any): """Handle message on eventbus.""" if msg == EVENT_PROVIDER_REGISTERED: - # schedule a sync task when a new provider registers + # schedule the sync task when a new provider registers provider = self.mass.get_provider(msg_details) if provider.type == ProviderType.MUSIC_PROVIDER: - self.mass.add_job(self.music_provider_sync(msg_details)) + 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 ################ @@ -120,99 +92,169 @@ class LibraryManager: return radio return None - @api_route("library/add") - async def library_add(self, items: List[MediaItem]): - """Add media item(s) to the library.""" - result = False + @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: - # add to provider's libraries - for prov in media_item.provider_ids: - provider = self.mass.get_provider(prov.provider) - if provider: - result = await provider.library_add( - prov.item_id, media_item.media_type - ) - # mark as library item in internal db - if media_item.provider == "database": - await self.mass.database.add_to_library( - media_item.item_id, media_item.media_type, media_item.provider - ) + 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 - @api_route("library/remove") - async def library_remove(self, items: List[MediaItem]): - """Remove media item(s) from the library.""" - result = False + 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: - # remove from provider's libraries - for prov in media_item.provider_ids: - provider = self.mass.get_provider(prov.provider) - if provider: - result = await provider.library_remove( - prov.item_id, media_item.media_type - ) - # mark as library item in internal db - if media_item.provider == "database": - await self.mass.database.remove_from_library( - media_item.item_id, media_item.media_type, media_item.provider + 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 - @api_route("library/playlists/:db_playlist_id/tracks/add") - async def add_playlist_tracks(self, db_playlist_id: int, tracks: List[Track]): - """Add tracks to playlist - make sure we dont add duplicates.""" + 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 or not playlist.is_editable: - return False - # playlist can only have one provider (for now) + 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.add(item.item_id) - cur_playlist_track_ids.update({i.item_id for i in item.provider_ids}) - track_ids_to_add = set() - for track in tracks: - # check for duplicates - already_exists = track.item_id in cur_playlist_track_ids - for track_prov in track.provider_ids: - if track_prov.item_id in cur_playlist_track_ids: - already_exists = True - if already_exists: - continue - # we can only add a track to a provider playlist if track is available on that provider - # this should all be handled in the frontend but these checks are here just to be safe - # 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) - for track_version in sorted( - track.provider_ids, key=lambda x: x.quality, reverse=True + 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 ): - if track_version.provider == playlist_prov.provider: - track_ids_to_add.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 - uri = f"{track_version.provider}://{track_version.item_id}" - track_ids_to_add.add(uri) - break + 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 - if track_ids_to_add: - # 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_ids_to_add + # 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 False + return result - @api_route("library/playlists/:db_playlist_id/tracks/remove") - async def remove_playlist_tracks(self, db_playlist_id, tracks: List[Track]): - """Remove tracks from playlist.""" + 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: @@ -220,11 +262,10 @@ class LibraryManager: # playlist can only have one provider (for now) prov_playlist = next(iter(playlist.provider_ids)) track_ids_to_remove = set() - for track in tracks: - # 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) + # 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 @@ -235,14 +276,7 @@ class LibraryManager: prov_playlist.item_id, track_ids_to_remove ) - @run_periodic(3600 * 3) - async def _music_providers_sync(self): - """Periodic sync of all music providers.""" - await asyncio.sleep(10) - for prov in self.mass.get_providers(ProviderType.MUSIC_PROVIDER): - await self.music_provider_sync(prov.id) - - async def music_provider_sync(self, prov_id: str): + async def music_provider_sync(self, prov_id: str, periodic: Optional[int] = None): """ Sync a music provider. @@ -251,18 +285,42 @@ class LibraryManager: provider = self.mass.get_provider(prov_id) if not provider: return - if MediaType.Album in provider.supported_mediatypes: - await self.library_albums_sync(prov_id) - if MediaType.Track in provider.supported_mediatypes: - await self.library_tracks_sync(prov_id) - if MediaType.Artist in provider.supported_mediatypes: - await self.library_artists_sync(prov_id) - if MediaType.Playlist in provider.supported_mediatypes: - await self.library_playlists_sync(prov_id) - if MediaType.Radio in provider.supported_mediatypes: - await self.library_radios_sync(prov_id) - - @sync_task("artists") + 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) @@ -276,18 +334,15 @@ class LibraryManager: 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, provider_id + 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, provider_id - ) + 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) - @sync_task("albums") async def library_albums_sync(self, provider_id: str): """Sync library albums for given provider.""" music_provider = self.mass.get_provider(provider_id) @@ -304,7 +359,7 @@ class LibraryManager: 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, provider_id + db_album.item_id, MediaType.ALBUM ) # precache album tracks await self.mass.music.get_album_tracks(item.item_id, provider_id) @@ -312,12 +367,11 @@ class LibraryManager: 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, provider_id + db_id, MediaType.ALBUM, provider_id ) # store ids in cache for next sync await self.mass.cache.set(cache_key, cur_db_ids) - @sync_task("tracks") async def library_tracks_sync(self, provider_id: str): """Sync library tracks for given provider.""" music_provider = self.mass.get_provider(provider_id) @@ -334,18 +388,15 @@ class LibraryManager: 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, provider_id + 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, provider_id - ) + 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) - @sync_task("playlists") async def library_playlists_sync(self, provider_id: str): """Sync library playlists for given provider.""" music_provider = self.mass.get_provider(provider_id) @@ -358,32 +409,16 @@ class LibraryManager: ) if db_item.checksum != playlist.checksum: db_item = await self.mass.database.add_playlist(playlist) - # precache playlist tracks - for playlist_track in await self.mass.music.get_playlist_tracks( - playlist.item_id, provider_id - ): - # try to find substitutes for unavailable tracks with matching technique - if not playlist_track.available: - await self.mass.music.get_track( - playlist_track.item_id, - playlist_track.provider, - playlist_track, - ) cur_db_ids.add(db_item.item_id) - await self.mass.database.add_to_library( - db_item.item_id, MediaType.Playlist, playlist.provider - ) + 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, provider_id - ) + 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) - @sync_task("radios") async def library_radios_sync(self, provider_id: str): """Sync library radios for given provider.""" music_provider = self.mass.get_provider(provider_id) @@ -395,14 +430,13 @@ class LibraryManager: 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, provider_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, provider_id + db_id, + MediaType.RADIO, ) # store ids in cache for next sync await self.mass.cache.set(cache_key, cur_db_ids) diff --git a/music_assistant/managers/metadata.py b/music_assistant/managers/metadata.py index 9533f24d..d60a05df 100755 --- a/music_assistant/managers/metadata.py +++ b/music_assistant/managers/metadata.py @@ -8,7 +8,7 @@ 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_manager") +LOGGER = logging.getLogger("metadata") class MetaDataManager: @@ -32,10 +32,21 @@ class MetaDataManager: 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 diff --git a/music_assistant/managers/music.py b/music_assistant/managers/music.py index 28c6cc52..e5245f66 100755 --- a/music_assistant/managers/music.py +++ b/music_assistant/managers/music.py @@ -19,7 +19,9 @@ from music_assistant.helpers.compare import ( 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, @@ -34,7 +36,6 @@ from music_assistant.models.media_types import ( Track, ) from music_assistant.models.provider import MusicProvider, ProviderType -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType LOGGER = logging.getLogger("music_manager") @@ -42,11 +43,12 @@ LOGGER = logging.getLogger("music_manager") class MusicManager: """Several helpers around the musicproviders.""" - def __init__(self, mass): + 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.""" @@ -58,39 +60,7 @@ class MusicManager: ################ GET MediaItem(s) by id and provider ################# - @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, - ): - """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("artists/:provider_id/:item_id") + @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: @@ -105,7 +75,9 @@ class MusicManager: artist = await self._get_provider_artist(item_id, provider_id) if not lazy: return await self.add_artist(artist) - self.mass.add_background_task(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: @@ -121,7 +93,7 @@ class MusicManager: ) return artist - @api_route("albums/:provider_id/:item_id") + @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: @@ -136,7 +108,7 @@ class MusicManager: album = await self._get_provider_album(item_id, provider_id) if not lazy: return await self.add_album(album) - self.mass.add_background_task(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: @@ -152,7 +124,7 @@ class MusicManager: ) return album - @api_route("tracks/:provider_id/:item_id") + @api_route("tracks/{provider_id}/{item_id}") async def get_track( self, item_id: str, @@ -176,7 +148,9 @@ class MusicManager: track_details.album = album_details if not lazy: return await self.add_track(track_details) - self.mass.add_background_task(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: @@ -192,7 +166,7 @@ class MusicManager: ) return track - @api_route("playlists/:provider_id/:item_id") + @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: @@ -206,7 +180,9 @@ class MusicManager: playlist = await self._get_provider_playlist(item_id, provider_id) if not lazy: return await self.add_playlist(playlist) - self.mass.add_background_task(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: @@ -228,7 +204,7 @@ class MusicManager: ) return playlist - @api_route("radios/:provider_id/:item_id") + @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: @@ -242,7 +218,9 @@ class MusicManager: radio = await self._get_provider_radio(item_id, provider_id) if not lazy: return await self.add_radio(radio) - self.mass.add_background_task(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: @@ -258,7 +236,7 @@ class MusicManager: ) return radio - @api_route("albums/:provider_id/:item_id/tracks") + @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 @@ -290,7 +268,7 @@ class MusicManager: for item in all_prov_tracks ] - @api_route("albums/:provider_id/:item_id/versions") + @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) @@ -302,7 +280,7 @@ class MusicManager: prov_item for prov_items in await asyncio.gather( *[ - self.search_provider(search_query, prov_id, [MediaType.Album], 25) + self.search_provider(search_query, prov_id, [MediaType.ALBUM], 25) for prov_id in provider_ids ] ) @@ -310,7 +288,7 @@ class MusicManager: if compare_strings(prov_item.artist.name, album.artist.name) } - @api_route("tracks/:provider_id/:item_id/versions") + @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) @@ -323,7 +301,7 @@ class MusicManager: prov_item for prov_items in await asyncio.gather( *[ - self.search_provider(search_query, prov_id, [MediaType.Track], 25) + self.search_provider(search_query, prov_id, [MediaType.TRACK], 25) for prov_id in provider_ids ] ) @@ -331,7 +309,7 @@ class MusicManager: if compare_artists(prov_item.artists, track.artists) } - @api_route("playlists/:provider_id/:item_id/tracks") + @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 @@ -347,21 +325,13 @@ class MusicManager: playlist = await provider.get_playlist(item_id) cache_checksum = playlist.checksum cache_key = f"{provider_id}.playlist_tracks.{item_id}" - playlist_tracks = await cached( + return await cached( self.cache, cache_key, provider.get_playlist_tracks, item_id, checksum=cache_checksum, ) - db_tracks = await self.mass.database.get_tracks_from_provider_ids( - provider_id, {x.item_id for x in playlist_tracks} - ) - # combine provider tracks with db tracks - return [ - await self.__process_item(item, db_tracks, index) - for index, item in enumerate(playlist_tracks) - ] async def __process_item( self, @@ -387,11 +357,14 @@ class MusicManager: item.track_number = track_number return item - @api_route("artists/:provider_id/:item_id/tracks") + @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) - # get results from all providers all_prov_tracks = { track for prov_tracks in await asyncio.gather( @@ -426,11 +399,13 @@ class MusicManager: item_id, ) - @api_route("artists/:provider_id/:item_id/albums") + @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) - # get results from all providers all_prov_albums = { album for prov_albums in await asyncio.gather( @@ -465,7 +440,7 @@ class MusicManager: item_id, ) - @api_route("search/:provider_id") + @api_route("search/{provider_id}") async def search_provider( self, search_query: str, @@ -523,70 +498,94 @@ class MusicManager: # TODO: sort by name and filter out duplicates ? return result - async def get_stream_details( - self, media_item: MediaItem, player_id: str = "" - ) -> StreamDetails: + @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]: """ - Get streamdetails for the given media_item. + Refresh MediaItems to force retrieval of full info and matches. - This is called just-in-time when a player/queue 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. + Creates background tasks to process the action. """ - if media_item.provider == "uri": - # special type: a plain uri was added to the queue - streamdetails = StreamDetails( - type=StreamType.URL, - provider="uri", - item_id=media_item.item_id, - path=media_item.item_id, - content_type=ContentType(media_item.item_id.split(".")[-1]), - sample_rate=44100, - bit_depth=16, - ) - else: - # always request the full db track as there might be other qualities available - # except for radio - if media_item.media_type == MediaType.Radio: - full_track = media_item - else: - full_track = ( - await self.get_track(media_item.item_id, media_item.provider) - or media_item - ) - # sort by quality and check track availability - for prov_media in sorted( - full_track.provider_ids, key=lambda x: x.quality, reverse=True - ): - if not prov_media.available: - continue - # get streamdetails from provider - music_prov = self.mass.get_provider(prov_media.provider) - if not music_prov or not music_prov.available: - continue # provider temporary unavailable ? + 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 - streamdetails: StreamDetails = await music_prov.get_stream_details( - prov_media.item_id - ) - if streamdetails: - try: - streamdetails.content_type = ContentType( - streamdetails.content_type - ) - except KeyError: - LOGGER.warning("Invalid content type!") - else: - break - - if streamdetails: - # set player_id on the streamdetails so we know what players stream - streamdetails.player_id = player_id - # set streamdetails as attribute on the media_item - # this way the app knows what content is playing - media_item.streamdetails = streamdetails - return streamdetails - return None + 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 ################ @@ -601,7 +600,8 @@ class MusicManager: db_item = await self.mass.database.add_artist(artist) # also fetch same artist on all providers await self.match_artist(db_item) - self.mass.signal_event(EVENT_ARTIST_ADDED, 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: @@ -611,7 +611,8 @@ class MusicManager: db_item = await self.mass.database.add_album(album) # also fetch same album on all providers await self.match_album(db_item) - self.mass.signal_event(EVENT_ALBUM_ADDED, 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: @@ -623,19 +624,20 @@ class MusicManager: 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) - self.mass.signal_event(EVENT_TRACK_ADDED, 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.signal_event(EVENT_PLAYLIST_ADDED, db_item) + 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.signal_event(EVENT_RADIO_ADDED, db_item) + self.mass.eventbus.signal(EVENT_RADIO_ADDED, db_item) return db_item async def _get_artist_musicbrainz_id(self, artist: Artist): @@ -685,7 +687,7 @@ class MusicManager: 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: + if MediaType.ARTIST not in provider.supported_mediatypes: continue if not await self._match_prov_artist(db_artist, provider): LOGGER.debug( @@ -708,7 +710,7 @@ class MusicManager: 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 + searchstr, provider.id, [MediaType.TRACK], limit=25 ) for search_result_item in search_results.tracks: if compare_track(search_result_item, ref_track): @@ -729,11 +731,11 @@ class MusicManager: db_artist.item_id, db_artist.provider ) for ref_album in artist_albums: - if ref_album.album_type == AlbumType.Compilation: + 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 + searchstr, provider.id, [MediaType.ALBUM], limit=25 ) for search_result_item in search_result.albums: # artist must match 100% @@ -774,7 +776,7 @@ class MusicManager: if db_album.version: searchstr += " " + db_album.version search_result = await self.search_provider( - searchstr, provider.id, [MediaType.Album], limit=25 + searchstr, provider.id, [MediaType.ALBUM], limit=25 ) for search_result_item in search_result.albums: if not search_result_item.available: @@ -809,7 +811,7 @@ class MusicManager: # 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: + if MediaType.ALBUM in provider.supported_mediatypes: await find_prov_match(provider) async def match_track(self, db_track: Track): @@ -825,7 +827,7 @@ class MusicManager: # 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: + if MediaType.TRACK not in provider.supported_mediatypes: continue LOGGER.debug( "Trying to match track %s on provider %s", db_track.name, provider.name @@ -838,7 +840,7 @@ class MusicManager: if db_track.version: searchstr += " " + db_track.version search_result = await self.search_provider( - searchstr, provider.id, [MediaType.Track], limit=25 + searchstr, provider.id, [MediaType.TRACK], limit=25 ) for search_result_item in search_result.tracks: if not search_result_item.available: diff --git a/music_assistant/managers/players.py b/music_assistant/managers/players.py index 71bd79dd..c7d9daff 100755 --- a/music_assistant/managers/players.py +++ b/music_assistant/managers/players.py @@ -2,6 +2,7 @@ import asyncio import logging +import pathlib from typing import Dict, List, Optional, Set, Tuple, Union from music_assistant.constants import ( @@ -11,17 +12,18 @@ from music_assistant.constants import ( EVENT_PLAYER_REMOVED, ) from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import callback, try_parse_int +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 ( - PlaybackState, Player, PlayerControl, PlayerControlType, + PlayerState, ) from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption from music_assistant.models.provider import PlayerProvider, ProviderType +from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType POLL_INTERVAL = 30 @@ -41,7 +43,7 @@ class PlayerManager: async def setup(self) -> None: """Async initialize of module.""" - self.mass.add_job(self.poll_task()) + asyncio.create_task(self.poll_task()) async def close(self) -> None: """Handle stop/shutdown.""" @@ -55,11 +57,11 @@ class PlayerManager: count = 0 while True: for player in self: - if not player.player_state.available: + if not player.calculated_state.available: continue if not player.should_poll: continue - if player.state == PlaybackState.Playing or count == POLL_INTERVAL: + if player.state == PlayerState.PLAYING or count == POLL_INTERVAL: await player.on_poll() if count == POLL_INTERVAL: count = 0 @@ -93,12 +95,13 @@ class PlayerManager: return tuple(self._players.values()) @callback - @api_route("players/queues") + @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) -> Player: """Return Player by player_id or None if player does not exist.""" return self._players.get(player_id) @@ -111,7 +114,7 @@ class PlayerManager: for player in self: if provider_id is not None and player.provider_id != provider_id: continue - if player.name == name or player.player_state.name == name: + if player.name == name or player.calculated_state.name == name: return player return None @@ -122,24 +125,24 @@ class PlayerManager: return self.mass.get_provider(player.provider_id) if player else None @callback - @api_route("players/:player_id/queue") - def get_player_queue(self, player_id: str) -> PlayerQueue: + @api_route("queues/{queue_id}") + def get_player_queue(self, queue_id: str) -> PlayerQueue: """Return player's queue by player_id or None if player does not exist.""" - player = self.get_player(player_id) + player = self.get_player(queue_id) if not player: - LOGGER.warning("Player(queue) %s is not available!", player_id) + LOGGER.warning("Player(queue) %s is not available!", queue_id) return None return self._player_queues.get(player.active_queue) @callback - @api_route("players/:queue_id/queue/items") + @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") + @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: @@ -181,7 +184,7 @@ class PlayerManager: player.mass = self.mass # make sure that the player state is created/updated - player.player_state.update(player.create_state()) + player.calculated_state.update(player.create_calculated_state()) # Fully initialize only if player is enabled if not player.enabled: @@ -202,7 +205,7 @@ class PlayerManager: player.provider_id, player.name, ) - self.mass.signal_event(EVENT_PLAYER_ADDED, player) + self.mass.eventbus.signal(EVENT_PLAYER_ADDED, player) async def remove_player(self, player_id: str): """Remove a player from the registry.""" @@ -212,7 +215,7 @@ class PlayerManager: await player.on_remove() player_name = player.name if player else player_id LOGGER.info("Player removed: %s", player_name) - self.mass.signal_event(EVENT_PLAYER_REMOVED, {"player_id": player_id}) + 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..""" @@ -220,7 +223,7 @@ class PlayerManager: if player: await player.on_poll() - @api_route("players/controls/:control_id/register") + @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 @@ -238,9 +241,9 @@ class PlayerManager: conf.get(CONF_POWER_CONTROL), conf.get(CONF_VOLUME_CONTROL), ]: - self.mass.add_job(self.trigger_player_update(player.player_id)) + create_task(self.trigger_player_update(player.player_id)) - @api_route("players/controls/:control_id/update") + @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: @@ -262,16 +265,16 @@ class PlayerManager: conf.get(CONF_POWER_CONTROL), conf.get(CONF_VOLUME_CONTROL), ]: - self.mass.add_job(self.trigger_player_update(player.player_id)) + create_task(self.trigger_player_update(player.player_id)) # SERVICE CALLS / PLAYER COMMANDS - @api_route("players/:player_id/play_media") + @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, + queue_opt: QueueOption = QueueOption.PLAY, ): """ Play media item(s) on the given player. @@ -279,10 +282,10 @@ class PlayerManager: :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 + 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(items, list): @@ -290,19 +293,19 @@ class PlayerManager: queue_items = [] for media_item in items: # collect tracks to play - if media_item.media_type == MediaType.Artist: + 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: + 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: + 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: + elif media_item.media_type == MediaType.RADIO: # single radio tracks = [ await self.mass.music.get_radio( @@ -320,30 +323,36 @@ class PlayerManager: if not track.available: continue queue_item = QueueItem.from_track(track) - # generate uri for this queue item - queue_item.uri = "%s/queue/%s/%s" % ( + # generate url for this queue item + queue_item.stream_url = "%s/queue/%s/%s" % ( self.mass.web.stream_url, player_id, queue_item.queue_item_id, ) queue_items.append(queue_item) # turn on player - await self.cmd_power_on(player_id) + player = self.get_player(player_id) + if not player: + raise FileNotFoundError("Player not found %s" % player_id) + if not player.calculated_state.powered: + await self.cmd_power_on(player_id) # load items into the queue player_queue = self.get_player_queue(player_id) - if queue_opt == QueueOption.Replace: + 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: + 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: + if queue_opt == QueueOption.NEXT: return await player_queue.insert(queue_items, 1) - if queue_opt == QueueOption.Play: + if queue_opt == QueueOption.PLAY: return await player_queue.insert(queue_items, 0) - if queue_opt == QueueOption.Add: + if queue_opt == QueueOption.ADD: return await player_queue.append(queue_items) - @api_route("players/:player_id/play_uri") - async def cmd_play_uri(self, player_id: str, uri: str): + @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. @@ -352,20 +361,143 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. :param uri: Url/Uri that can be played by a player. """ - queue_item = QueueItem(item_id=uri, provider="uri", name=uri) - # generate uri for this queue item - queue_item.uri = "%s/%s/%s" % ( + # 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) + # fallback to regular url + queue_item = QueueItem(item_id=uri, provider="url", name=uri, uri=uri) + # generate url for this queue item + queue_item.stream_url = "%s/queue/%s/%s" % ( self.mass.web.stream_url, player_id, queue_item.queue_item_id, ) # turn on player - await self.cmd_power_on(player_id) - # load item into the queue + player = self.get_player(player_id) + if not player: + raise FileNotFoundError("Player not found %s" % player_id) + if not player.calculated_state.powered: + await self.cmd_power_on(player_id) + # load items into the queue + player_queue = self.get_player_queue(player_id) + 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, + gain_adjust: int = 0, + 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 gain_adjust: Adjust volume/gain of audio. + :param force: Play alert even if player is currently powered off. + :param announce: Prepend alert sound. + """ + player = self.get_player(player_id) player_queue = self.get_player_queue(player_id) - return await player_queue.insert([queue_item], 0) + prev_state = player.calculated_state.state + prev_power = player.calculated_state.powered + if not player.calculated_state.powered: + if not force: + LOGGER.debug( + "Ignore alert playback: Player %s is powered off.", + player.calculated_state.name, + ) + return + await self.cmd_power_on(player_id) - @api_route("players/:player_id/cmd/stop") + queue_items = [] + if announce: + alert_announce = ( + pathlib.Path(__file__) + .parent.resolve() + .parent.resolve() + .joinpath("helpers", "alert.mp3") + ) + queue_item = QueueItem( + item_id="alert_announce", + provider="url", + name="alert", + duration=2, + streamdetails=StreamDetails( + type=StreamType.URL, + provider="url", + item_id="alert_announce", + path=str(alert_announce), + content_type=ContentType(url.split(".")[-1]), + gain_correct=10, + ), + ) + queue_item.stream_url = "%s/queue/%s/%s" % ( + self.mass.web.stream_url, + player_id, + queue_item.queue_item_id, + ) + queue_items.append(queue_item) + + queue_item = QueueItem( + item_id="alert_sound", + provider="url", + name="alert", + duration=10, + streamdetails=StreamDetails( + type=StreamType.URL, + provider="url", + item_id="alert_sound", + path=url, + content_type=ContentType(url.split(".")[-1]), + gain_correct=gain_adjust, + ), + ) + queue_item.stream_url = "%s/queue/%s/%s?alert=true" % ( + self.mass.web.stream_url, + player_id, + queue_item.queue_item_id, + ) + queue_items.append(queue_item) + + await player_queue.insert(queue_items, 0) + + if prev_power and prev_state in [PlayerState.PLAYING, PlayerState.PAUSED]: + return + + # wait until playback completed + playback_started = False + count = 0 + while True: + if not playback_started and player_queue.state == PlayerState.PLAYING: + playback_started = True + elif playback_started and ( + player_queue.state != PlayerState.PLAYING + or (player_queue.cur_item and player_queue.cur_item.name != "alert") + ): + break + if count == 20: + break + count += 0.2 + await asyncio.sleep(0.2) + await self.cmd_power_off(player_id) + + @api_route("players/{player_id}/cmd/stop", method="PUT") async def cmd_stop(self, player_id: str) -> None: """ Send STOP command to given player. @@ -379,7 +511,7 @@ class PlayerManager: queue_player = self.get_player(queue_id) return await queue_player.cmd_stop() - @api_route("players/:player_id/cmd/play") + @api_route("players/{player_id}/cmd/play", method="PUT") async def cmd_play(self, player_id: str) -> None: """ Send PLAY command to given player. @@ -392,13 +524,13 @@ class PlayerManager: queue_id = player.active_queue queue_player = self.get_player(queue_id) # unpause if paused else resume queue - if queue_player.state == PlaybackState.Paused: + if queue_player.state == PlayerState.PAUSED: return await queue_player.cmd_play() # power on at play request await self.cmd_power_on(player_id) return await self._player_queues[queue_id].resume() - @api_route("players/:player_id/cmd/pause") + @api_route("players/{player_id}/cmd/pause", method="PUT") async def cmd_pause(self, player_id: str): """ Send PAUSE command to given player. @@ -412,7 +544,7 @@ class PlayerManager: queue_player = self.get_player(queue_id) return await queue_player.cmd_pause() - @api_route("players/:player_id/cmd/play_pause") + @api_route("players/{player_id}/cmd/play_pause", method="PUT") async def cmd_play_pause(self, player_id: str): """ Toggle play/pause on given player. @@ -422,11 +554,11 @@ class PlayerManager: player = self.get_player(player_id) if not player: return - if player.state == PlaybackState.Playing: + if player.state == PlayerState.PLAYING: return await self.cmd_pause(player_id) return await self.cmd_play(player_id) - @api_route("players/:player_id/cmd/next") + @api_route("players/{player_id}/cmd/next", method="PUT") async def cmd_next(self, player_id: str): """ Send NEXT TRACK command to given player. @@ -439,7 +571,7 @@ class PlayerManager: queue_id = player.active_queue return await self.get_player_queue(queue_id).next() - @api_route("players/:player_id/cmd/previous") + @api_route("players/{player_id}/cmd/previous", method="PUT") async def cmd_previous(self, player_id: str): """ Send PREVIOUS TRACK command to given player. @@ -452,7 +584,7 @@ class PlayerManager: queue_id = player.active_queue return await self.get_player_queue(queue_id).previous() - @api_route("players/:player_id/cmd/power_on") + @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. @@ -471,7 +603,7 @@ class PlayerManager: if control: await control.set_state(True) - @api_route("players/:player_id/cmd/power_off") + @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. @@ -483,8 +615,8 @@ class PlayerManager: return # send stop if player is playing if player.active_queue == player_id and player.state in [ - PlaybackState.Playing, - PlaybackState.Paused, + PlayerState.PLAYING, + PlayerState.PAUSED, ]: await self.cmd_stop(player_id) player_config = self.mass.config.player_settings[player.player_id] @@ -500,25 +632,25 @@ class PlayerManager: # 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.player_state.powered: - self.mass.add_job(self.cmd_power_off(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.player_state.powered: + 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.player_state.powered: + if child_player and child_player.calculated_state.powered: has_powered_players = True if not has_powered_players: - self.mass.add_job(self.cmd_power_off(parent_player_id)) + create_task(self.cmd_power_off(parent_player_id)) - @api_route("players/:player_id/cmd/power_toggle") + @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. @@ -528,11 +660,11 @@ class PlayerManager: player = self.get_player(player_id) if not player: return - if player.player_state.powered: + 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/:volume_level?") + @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. @@ -572,7 +704,7 @@ class PlayerManager: if ( child_player and child_player.available - and child_player.player_state.powered + and child_player.calculated_state.powered ): cur_child_volume = child_player.volume_level new_child_volume = cur_child_volume + ( @@ -583,7 +715,7 @@ class PlayerManager: else: await player.cmd_volume_set(volume_level) - @api_route("players/:player_id/cmd/volume_up") + @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. @@ -602,7 +734,7 @@ class PlayerManager: new_level = 100 return await self.cmd_volume_set(player_id, new_level) - @api_route("players/:player_id/cmd/volume_down") + @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. @@ -621,7 +753,7 @@ class PlayerManager: new_level = 0 return await self.cmd_volume_set(player_id, new_level) - @api_route("players/:player_id/cmd/volume_mute/:is_muted") + @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. @@ -635,37 +767,23 @@ class PlayerManager: # TODO: handle mute on volumecontrol? return await player.cmd_volume_mute(is_muted) - @api_route("players/:queue_id/queue/cmd/shuffle_enabled/:enable_shuffle?") - async def player_queue_cmd_set_shuffle( - self, queue_id: str, enable_shuffle: bool = False - ): - """ - Send enable/disable shuffle command to given playerqueue. - - :param queue_id: player_id of the playerqueue to handle the command. - :param enable_shuffle: bool with the new ahuffle state. - """ - player_queue = self.get_player_queue(queue_id) - if not player_queue: - return - return await player_queue.set_shuffle_enabled(enable_shuffle) - - @api_route("players/:queue_id/queue/cmd/repeat_enabled/:enable_repeat?") - async def player_queue_cmd_set_repeat( - self, queue_id: str, enable_repeat: bool = False - ): - """ - Send enable/disable repeat command to given playerqueue. - - :param queue_id: player_id of the playerqueue to handle the command. - :param enable_repeat: bool with the new ahuffle state. - """ + @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: - return - return await player_queue.set_repeat_enabled(enable_repeat) + 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("players/:queue_id/queue/cmd/next") + @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. @@ -677,7 +795,7 @@ class PlayerManager: return return await player_queue.next() - @api_route("players/:queue_id/queue/cmd/previous") + @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. @@ -689,7 +807,7 @@ class PlayerManager: return return await player_queue.previous() - @api_route("players/:queue_id/queue/cmd/move/:queue_item_id?/:pos_shift?") + @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 ): @@ -705,7 +823,7 @@ class PlayerManager: return return await player_queue.move_item(queue_item_id, pos_shift) - @api_route("players/:queue_id/queue/cmd/play_index/:index?") + @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) @@ -713,8 +831,8 @@ class PlayerManager: return return await player_queue.play_index(index) - @api_route("players/:queue_id/queue/cmd/clear") - async def player_queue_cmd_clear(self, queue_id: str, enable_repeat: bool = False): + @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. @@ -724,24 +842,3 @@ class PlayerManager: if not player_queue: return return await player_queue.clear() - - # OTHER/HELPER FUNCTIONS - - async def get_gain_correct(self, player_id: str, item_id: str, provider_id: str): - """Get gain correction for given player / track combination.""" - player_conf = self.mass.config.get_player_config(player_id) - if not player_conf["volume_normalisation"]: - return 0 - target_gain = int(player_conf["target_volume"]) - track_loudness = await self.mass.database.get_track_loudness( - item_id, provider_id - ) - if track_loudness is None: - # fallback to provider average - track_loudness = await self.mass.database.get_provider_loudness(provider_id) - if track_loudness is None: - # fallback to some (hopefully sane) average value for now - track_loudness = -8.5 - gain_correct = target_gain - track_loudness - gain_correct = round(gain_correct, 2) - return gain_correct diff --git a/music_assistant/managers/streams.py b/music_assistant/managers/streams.py deleted file mode 100755 index f24affa5..00000000 --- a/music_assistant/managers/streams.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -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 the SoX executable, using various subprocess streams. -""" -import asyncio -import logging -import shlex -import subprocess -from enum import Enum -from typing import AsyncGenerator, List, Optional, Tuple - -import aiofiles -from music_assistant.constants import ( - CONF_MAX_SAMPLE_RATE, - EVENT_STREAM_ENDED, - EVENT_STREAM_STARTED, -) -from music_assistant.helpers.process import AsyncProcess -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import create_tempfile, get_ip -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType - -LOGGER = logging.getLogger("stream_manager") - - -class SoxOutputFormat(Enum): - """Enum representing the various output formats.""" - - MP3 = "mp3" # Lossy mp3 - OGG = "ogg" # Lossy Ogg Vorbis - FLAC = "flac" # Flac (with default compression) - S24 = "s24" # Raw PCM 24bits signed - S32 = "s32" # Raw PCM 32bits signed - S64 = "s64" # Raw PCM 64bits signed - - -class StreamManager: - """Built-in streamer utilizing SoX.""" - - def __init__(self, mass: MusicAssistant) -> None: - """Initialize class.""" - self.mass = mass - self.local_ip = get_ip() - self.analyze_jobs = {} - - async def get_sox_stream( - self, - streamdetails: StreamDetails, - output_format: SoxOutputFormat = SoxOutputFormat.FLAC, - resample: Optional[int] = None, - gain_db_adjust: Optional[float] = None, - chunk_size: int = 512000, - ) -> AsyncGenerator[Tuple[bool, bytes], None]: - """Get the sox manipulated audio data for the given streamdetails.""" - # collect all args for sox - if output_format in [ - SoxOutputFormat.S24, - SoxOutputFormat.S32, - SoxOutputFormat.S64, - ]: - output_format = [output_format.value, "-c", "2"] - else: - output_format = [output_format.value] - if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]: - input_format = "flac" - else: - input_format = streamdetails.content_type.value - - args = ["sox", "-t", input_format, "-", "-t"] + output_format + ["-"] - if gain_db_adjust: - args += ["vol", str(gain_db_adjust), "dB"] - if resample: - args += ["rate", "-v", str(resample)] - - LOGGER.debug( - "start sox stream for: %s/%s", streamdetails.provider, streamdetails.item_id - ) - - async with AsyncProcess(args, enable_write=True) as sox_proc: - - async def fill_buffer(): - """Forward audio chunks to sox stdin.""" - # feed audio data into sox stdin for processing - async for chunk in self.get_media_stream(streamdetails): - await sox_proc.write(chunk) - await sox_proc.write_eof() - - fill_buffer_task = self.mass.loop.create_task(fill_buffer()) - # 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( - "get_sox_stream aborted for: %s/%s", - streamdetails.provider, - streamdetails.item_id, - ) - fill_buffer_task.cancel() - raise err - else: - LOGGER.debug( - "finished sox stream for: %s/%s", - streamdetails.provider, - streamdetails.item_id, - ) - - async def queue_stream_flac(self, player_id) -> AsyncGenerator[bytes, None]: - """Stream the PlayerQueue's tracks as constant feed in flac format.""" - player_conf = self.mass.config.get_player_config(player_id) - sample_rate = player_conf.get(CONF_MAX_SAMPLE_RATE, 96000) - - args = [ - "sox", - "-t", - "s32", - "-c", - "2", - "-r", - str(sample_rate), - "-", - "-t", - "flac", - "-", - ] - async with AsyncProcess(args, enable_write=True) as sox_proc: - - # feed stdin with pcm samples - async def fill_buffer(): - """Feed audio data into sox stdin for processing.""" - async for chunk in self.queue_stream_pcm(player_id, sample_rate, 32): - await sox_proc.write(chunk) - - fill_buffer_task = self.mass.loop.create_task(fill_buffer()) - - # start yielding audio chunks - try: - async for chunk in sox_proc.iterate_chunks(): - yield chunk - except (asyncio.CancelledError, GeneratorExit) as err: - LOGGER.debug( - "queue_stream_flac aborted for: %s", - player_id, - ) - fill_buffer_task.cancel() - raise err - else: - LOGGER.debug( - "finished queue_stream_flac for: %s", - player_id, - ) - - async def queue_stream_pcm( - self, player_id, sample_rate=96000, bit_depth=32 - ) -> AsyncGenerator[bytes, None]: - """Stream the PlayerQueue's tracks as constant feed in PCM raw audio.""" - player_queue = self.mass.players.get_player_queue(player_id) - - LOGGER.info("Start Queue Stream for player %s ", player_id) - - last_fadeout_data = b"" - queue_index = None - 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.info("no (more) tracks left in queue") - break - - # get crossfade details - fade_length = player_queue.crossfade_duration - pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)] - sample_size = int(sample_rate * (bit_depth / 8) * 2) # 1 second - buffer_size = sample_size * fade_length if fade_length else sample_size * 10 - - # get streamdetails - streamdetails = await self.mass.music.get_stream_details( - queue_track, player_id - ) - # get gain correct / replaygain - gain_correct = await self.mass.players.get_gain_correct( - player_id, streamdetails.item_id, streamdetails.provider - ) - streamdetails.gain_correct = gain_correct - - LOGGER.debug( - "Start Streaming queue track: %s (%s) for player %s", - queue_track.item_id, - queue_track.name, - player_id, - ) - fade_in_part = b"" - cur_chunk = 0 - prev_chunk = None - bytes_written = 0 - # handle incoming audio chunks - async for is_last_chunk, chunk in self.mass.streams.get_sox_stream( - streamdetails, - SoxOutputFormat.S32, - resample=sample_rate, - gain_db_adjust=gain_correct, - 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_id, - ) - # 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 - LOGGER.info("streaming of queue for player %s completed", player_id) - - async def stream_queue_item( - self, player_id: str, queue_item_id: str - ) -> AsyncGenerator[bytes, None]: - """Stream a single Queue item.""" - # collect streamdetails - player_queue = self.mass.players.get_player_queue(player_id) - if not player_queue: - raise FileNotFoundError("invalid player_id") - queue_item = player_queue.by_item_id(queue_item_id) - if not queue_item: - raise FileNotFoundError("invalid queue_item_id") - streamdetails = await self.mass.music.get_stream_details(queue_item, player_id) - - # get gain correct / replaygain - gain_correct = await self.mass.players.get_gain_correct( - player_id, streamdetails.item_id, streamdetails.provider - ) - streamdetails.gain_correct = gain_correct - - # start streaming - LOGGER.debug("Start streaming %s (%s)", queue_item_id, queue_item.name) - async for _, audio_chunk in self.get_sox_stream( - streamdetails, gain_db_adjust=gain_correct, chunk_size=4000000 - ): - yield audio_chunk - LOGGER.debug("Finished streaming %s (%s)", queue_item_id, queue_item.name) - - async def get_media_stream( - self, streamdetails: StreamDetails - ) -> AsyncGenerator[bytes, None]: - """Get the (original/untouched) audio data for the given streamdetails. Generator.""" - stream_path = streamdetails.path - stream_type = StreamType(streamdetails.type) - audio_data = b"" - track_loudness = await self.mass.database.get_track_loudness( - streamdetails.item_id, streamdetails.provider - ) - needs_analyze = track_loudness is None - - # support for AAC/MPEG created with ffmpeg in between - if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]: - stream_type = StreamType.EXECUTABLE - stream_path = f'ffmpeg -v quiet -i "{stream_path}" -f flac -' - - # signal start of stream event - self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails) - LOGGER.debug( - "start media stream for: %s/%s (%s)", - streamdetails.provider, - streamdetails.item_id, - streamdetails.type, - ) - # stream from URL - if stream_type == StreamType.URL: - async with self.mass.http_session.get(stream_path) as response: - async for chunk, _ in response.content.iter_chunks(): - yield chunk - if needs_analyze and len(audio_data) < 100000000: - audio_data += chunk - # stream from file - elif stream_type == StreamType.FILE: - async with aiofiles.open(stream_path) as afp: - async for chunk in afp: - yield chunk - if needs_analyze and len(audio_data) < 100000000: - audio_data += chunk - # stream from executable's stdout - elif stream_type == StreamType.EXECUTABLE: - args = shlex.split(stream_path) - async with AsyncProcess(args) as process: - async for chunk in process.iterate_chunks(): - yield chunk - if needs_analyze and len(audio_data) < 100000000: - audio_data += chunk - - # signal end of stream event - self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails) - LOGGER.debug( - "finished media stream for: %s/%s", - streamdetails.provider, - streamdetails.item_id, - ) - await self.mass.database.mark_item_played( - streamdetails.item_id, streamdetails.provider - ) - - # send analyze job to background worker - # TODO: feed audio chunks to analyzer while streaming - # so we don't have to load this large chunk in memory - if needs_analyze and audio_data: - self.mass.add_job(self.__analyze_audio, streamdetails, audio_data) - - def __analyze_audio(self, streamdetails, audio_data) -> None: - """Analyze track audio, for now we only calculate EBU R128 loudness.""" - item_key = "%s%s" % (streamdetails.item_id, streamdetails.provider) - if item_key in self.analyze_jobs: - return # prevent multiple analyze jobs for same track - self.analyze_jobs[item_key] = True - - # get track loudness - track_loudness = self.mass.add_job( - self.mass.database.get_track_loudness( - streamdetails.item_id, streamdetails.provider - ) - ).result() - if track_loudness is None: - # only when needed we do the analyze stuff - LOGGER.debug("Start analyzing track %s", item_key) - # calculate BS.1770 R128 integrated loudness with ffmpeg - # we used pyloudnorm here before but the numpy/scipy requirements were too heavy, - # considered the same feature is also included in ffmpeg - value = subprocess.check_output( - "ffmpeg -i pipe: -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'", - shell=True, - input=audio_data, - ) - loudness = float(value.decode().strip()) - self.mass.add_job( - self.mass.database.set_track_loudness( - streamdetails.item_id, streamdetails.provider, loudness - ) - ) - LOGGER.debug("Integrated loudness of track %s is: %s", item_key, loudness) - del audio_data - self.analyze_jobs.pop(item_key, None) - - -async def crossfade_pcm_parts( - fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int -) -> bytes: - """Crossfade two chunks of pcm/raw audio using sox.""" - # 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)] - 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 += [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 + ["-"] - async with AsyncProcess(args, enable_write=False) as sox_proc: - crossfade_part, _ = await sox_proc.communicate() - fadeinfile.close() - fadeoutfile.close() - del fadeinfile - del fadeoutfile - return crossfade_part - - -async def strip_silence(audio_data: bytes, pcm_args: List[str], reverse=False) -> bytes: - """Strip silence from (a chunk of) pcm audio.""" - args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"] - if reverse: - args.append("reverse") - args += ["silence", "1", "0.1", "1%"] - if reverse: - args.append("reverse") - async with AsyncProcess(args, enable_write=True) as sox_proc: - stripped_data, _ = await sox_proc.communicate(audio_data) - return stripped_data diff --git a/music_assistant/managers/tasks.py b/music_assistant/managers/tasks.py new file mode 100644 index 00000000..85b12788 --- /dev/null +++ b/music_assistant/managers/tasks.py @@ -0,0 +1,182 @@ +"""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 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) diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 49c50e36..4b3ddaa3 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -1,14 +1,13 @@ """Main Music Assistant class.""" import asyncio -import functools import importlib import logging import os -import threading -from typing import Any, Awaitable, Callable, Coroutine, Dict, Optional, Tuple, Union +from typing import Dict, Optional, Tuple import aiohttp +import music_assistant.helpers.util as util from music_assistant.constants import ( CONF_ENABLED, EVENT_PROVIDER_REGISTERED, @@ -17,16 +16,17 @@ from music_assistant.constants import ( ) from music_assistant.helpers.cache import Cache from music_assistant.helpers.migration import check_migrations -from music_assistant.helpers.util import callback, get_ip_pton, is_callback +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.streams import StreamManager +from music_assistant.managers.tasks import TaskManager from music_assistant.models.provider import Provider, ProviderType -from music_assistant.web.server import WebServer +from music_assistant.web import WebServer from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf LOGGER = logging.getLogger("mass") @@ -55,12 +55,13 @@ class MusicAssistant: self._loop = None self._debug = debug self._http_session = None - self._event_listeners = [] + self._providers = {} - self._background_tasks = None # 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) @@ -68,7 +69,6 @@ class MusicAssistant: self._music = MusicManager(self) self._library = LibraryManager(self) self._players = PlayerManager(self) - self._streams = StreamManager(self) # shared zeroconf instance self.zeroconf = Zeroconf(interfaces=InterfaceChoice.All) @@ -76,6 +76,7 @@ class MusicAssistant: """Start running the music assistant server.""" # 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) # create shared aiohttp ClientSession @@ -85,6 +86,7 @@ class MusicAssistant: ) # run migrations if needed await check_migrations(self) + await self._tasks.setup() await self._config.setup() await self._cache.setup() await self._music.setup() @@ -93,13 +95,13 @@ class MusicAssistant: await self.setup_discovery() await self._web.setup() await self._library.setup() - self.loop.create_task(self.__process_background_tasks()) + self.tasks.add("Save config", self.config.save) async def stop(self) -> None: """Stop running the music assistant server.""" self._exit = True LOGGER.info("Application shutdown") - self.signal_event(EVENT_SHUTDOWN) + self._eventbus.signal(EVENT_SHUTDOWN) await self.config.close() await self._web.stop() for prov in self._providers.values(): @@ -143,11 +145,6 @@ class MusicAssistant: """Return the Cache instance.""" return self._cache - @property - def streams(self) -> StreamManager: - """Return the Streams controller/manager.""" - return self._streams - @property def database(self) -> DatabaseManager: """Return the Database controller/manager.""" @@ -158,6 +155,16 @@ class MusicAssistant: """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.""" @@ -181,7 +188,7 @@ class MusicAssistant: if await provider.on_start() is not False: provider.available = True LOGGER.debug("Provider registered: %s", provider.name) - self.signal_event(EVENT_PROVIDER_REGISTERED, provider.id) + self.eventbus.signal(EVENT_PROVIDER_REGISTERED, provider.id) else: LOGGER.debug( "Provider registered but loading failed: %s", provider.name @@ -195,7 +202,7 @@ class MusicAssistant: # unload it if it's loaded await self._providers[provider_id].on_stop() LOGGER.debug("Provider unregistered: %s", provider_id) - self.signal_event(EVENT_PROVIDER_UNREGISTERED, 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: @@ -206,7 +213,7 @@ class MusicAssistant: await self.register_provider(provider) else: # try preloading all providers - self.add_job(self._preload_providers()) + self.tasks.add("Reload providers", self._preload_providers) @callback def get_provider(self, provider_id: str) -> Provider: @@ -230,100 +237,6 @@ class MusicAssistant: and (include_unavailable or item.available) ) - @callback - def signal_event(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. - """ - for cb_func, event_filter in self._event_listeners: - if not event_filter or event_msg in event_filter: - self.add_job(cb_func, event_msg, event_details) - - @callback - def add_event_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._event_listeners.append(listener) - - def remove_listener(): - self._event_listeners.remove(listener) - - return remove_listener - - @callback - def add_background_task(self, task: Coroutine): - """Add a coroutine/task to the end of the job queue.""" - if self._background_tasks is None: - self._background_tasks = asyncio.Queue() - self._background_tasks.put_nowait(task) - - @callback - def add_job( - self, target: Callable[..., Any], *args: Any, **kwargs: Any - ) -> Optional[asyncio.Task]: - """Add a job/task to the event loop. - - target: target to call. - args: parameters for method to call. - """ - task = None - - # Check for partials to properly determine if coroutine function - check_target = target - while isinstance(check_target, functools.partial): - check_target = check_target.func - - if threading.current_thread() is not threading.main_thread(): - # called from other thread - if asyncio.iscoroutine(check_target): - task = asyncio.run_coroutine_threadsafe(target, self.loop) # type: ignore - elif asyncio.iscoroutinefunction(check_target): - task = asyncio.run_coroutine_threadsafe( - target(*args, **kwargs), self.loop - ) - elif is_callback(check_target): - task = self.loop.call_soon_threadsafe(target, *args, **kwargs) - else: - task = self.loop.run_in_executor(None, target, *args, **kwargs) # type: ignore - else: - # called from mainthread - if asyncio.iscoroutine(check_target): - task = self.loop.create_task(target) # type: ignore - elif asyncio.iscoroutinefunction(check_target): - task = self.loop.create_task(target(*args, **kwargs)) - elif is_callback(check_target): - task = self.loop.call_soon(target, *args, *kwargs) - else: - task = self.loop.run_in_executor(None, target, *args, *kwargs) # type: ignore - return task - - async def __process_background_tasks(self): - """Background tasks that takes care of slowly handling jobs in the queue.""" - if self._background_tasks is None: - self._background_tasks = asyncio.Queue() - while not self.exit: - task = await self._background_tasks.get() - await task - if self._background_tasks.qsize() > 200: - await asyncio.sleep(0.5) - elif self._background_tasks.qsize() == 0: - await asyncio.sleep(10) - else: - await asyncio.sleep(1) - async def setup_discovery(self) -> None: """Make this Music Assistant instance discoverable on the network.""" @@ -351,7 +264,7 @@ class MusicAssistant: "Music Assistant instance with identical name present in the local network!" ) - self.add_job(_setup_discovery) + create_task(_setup_discovery) async def _preload_providers(self) -> None: """Dynamically load all providermodules.""" diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py index 7d9151e4..2acea7bf 100755 --- a/music_assistant/models/media_types.py +++ b/music_assistant/models/media_types.py @@ -2,37 +2,39 @@ from dataclasses import dataclass, field from enum import Enum, IntEnum -from typing import Any, Dict, List, Mapping, Set +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" + 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" + ARTIST = "artist" + WRITER = "writer" + PRODUCER = "producer" class AlbumType(Enum): """Enum for Album type.""" - Album = "album" - Single = "single" - Compilation = "compilation" - Unknown = "unknown" + ALBUM = "album" + SINGLE = "single" + COMPILATION = "compilation" + UNKNOWN = "unknown" class TrackQuality(IntEnum): @@ -66,7 +68,7 @@ class MediaItemProviderId(DataClassDictMixin): @dataclass class MediaItem(DataClassDictMixin): - """Representation of a media item.""" + """Base representation of a media item.""" item_id: str provider: str @@ -74,10 +76,16 @@ class MediaItem(DataClassDictMixin): metadata: Dict[str, Any] = field(default_factory=dict) provider_ids: Set[MediaItemProviderId] = field(default_factory=set) in_library: bool = False - media_type: MediaType = MediaType.Track + 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): + def from_dict(cls, dict_obj: dict): # pylint: disable=arguments-differ """Parse MediaItem from dict.""" if dict_obj["media_type"] == "artist": @@ -130,7 +138,7 @@ class MediaItem(DataClassDictMixin): class Artist(MediaItem): """Model for an artist.""" - media_type: MediaType = MediaType.Artist + media_type: MediaType = MediaType.ARTIST musicbrainz_id: str = "" def __hash__(self): @@ -145,7 +153,13 @@ class ItemMapping(DataClassDictMixin): item_id: str provider: str name: str = "" - media_type: MediaType = MediaType.Artist + 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): @@ -161,11 +175,11 @@ class ItemMapping(DataClassDictMixin): class Album(MediaItem): """Model for an album.""" - media_type: MediaType = MediaType.Album + media_type: MediaType = MediaType.ALBUM version: str = "" year: int = 0 - artist: ItemMapping = None - album_type: AlbumType = AlbumType.Unknown + artist: Optional[ItemMapping] = None + album_type: AlbumType = AlbumType.UNKNOWN upc: str = "" def __hash__(self): @@ -177,7 +191,7 @@ class Album(MediaItem): class FullAlbum(Album): """Model for an album with full details.""" - artist: Artist = None + artist: Optional[Artist] = None def __hash__(self): """Return custom hash.""" @@ -188,14 +202,14 @@ class FullAlbum(Album): class Track(MediaItem): """Model for a track.""" - media_type: MediaType = MediaType.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: ItemMapping = None + album: Optional[ItemMapping] = None disc_number: int = 0 track_number: int = 0 # playlist track only @@ -212,7 +226,7 @@ class FullTrack(Track): artists: Set[Artist] = field(default_factory=set) albums: Set[Album] = field(default_factory=set) - album: Album = None + album: Optional[Album] = None def __hash__(self): """Return custom hash.""" @@ -223,7 +237,7 @@ class FullTrack(Track): class Playlist(MediaItem): """Model for a playlist.""" - media_type: MediaType = MediaType.Playlist + media_type: MediaType = MediaType.PLAYLIST owner: str = "" checksum: str = "" # some value to detect playlist track changes is_editable: bool = False @@ -237,7 +251,7 @@ class Playlist(MediaItem): class Radio(MediaItem): """Model for a radio station.""" - media_type: MediaType = MediaType.Radio + media_type: MediaType = MediaType.RADIO duration: int = 86400 def __hash__(self): diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 8968e086..24f89e73 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -2,7 +2,6 @@ from abc import abstractmethod from dataclasses import dataclass, field -from datetime import datetime from enum import Enum, IntEnum from typing import Any, Optional, Set @@ -15,17 +14,17 @@ from music_assistant.constants import ( EVENT_PLAYER_CHANGED, ) from music_assistant.helpers.typing import ConfigSubItem, MusicAssistant, QueueItems -from music_assistant.helpers.util import callback +from music_assistant.helpers.util import callback, create_task from music_assistant.models.config_entry import ConfigEntry -class PlaybackState(Enum): - """Enum for the playstate of a player.""" +class PlayerState(Enum): + """Enum for the (playback)state of a player.""" - Stopped = "stopped" - Paused = "paused" - Playing = "playing" - Off = "off" + IDLE = "idle" + PAUSED = "paused" + PLAYING = "playing" + OFF = "off" @dataclass(frozen=True) @@ -78,26 +77,26 @@ class PlayerControl(DataClassDictMixin): # pickup this event (e.g. from the websocket api) # or override this method with your own implementation. # pylint: disable=no-member - self.mass.signal_event(f"players/controls/{self.control_id}/state", new_state) + self.mass.eventbus.signal( + f"players/controls/{self.control_id}/state", new_state + ) @dataclass -class PlayerState(DataClassDictMixin): +class CalculatedPlayerState(DataClassDictMixin): """Model for a (calculated) player state.""" player_id: str = None provider_id: str = None name: str = None powered: bool = False - state: PlaybackState = PlaybackState.Off + state: PlayerState = PlayerState.IDLE available: bool = False volume_level: int = 0 - elapsed_time: 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) - updated_at: datetime = datetime.now() group_parents: Set[str] = field(default_factory=set) features: Set[PlayerFeature] = field(default_factory=set) active_queue: str = None @@ -114,10 +113,7 @@ class PlayerState(DataClassDictMixin): new_val = getattr(new_obj, key) if getattr(self, key) != new_val: setattr(self, key, new_val) - if key != "updated_at": - changed_keys.add(key) - if changed_keys: - self.updated_at = datetime.now() + changed_keys.add(key) return changed_keys @@ -168,9 +164,9 @@ class Player: @property @abstractmethod - def state(self) -> PlaybackState: - """Return current PlaybackState of player.""" - return PlaybackState.Stopped + def state(self) -> PlayerState: + """Return current PlayerState of player.""" + return PlayerState.IDLE @property def available(self) -> bool: @@ -352,12 +348,12 @@ class Player: @property def active_queue(self) -> str: """Return the active parent player/queue for a player.""" - return self._cur_state.active_queue or self.player_id + return self._calculated_state.active_queue or self.player_id @property def group_parents(self) -> Set[str]: """Return all groups this player belongs to.""" - return self._cur_state.group_parents + return self._calculated_state.group_parents @property def config(self) -> ConfigSubItem: @@ -386,9 +382,9 @@ class Player: return None @property - def player_state(self) -> PlayerState: + def calculated_state(self) -> CalculatedPlayerState: """Return calculated/final state for this player.""" - return self._cur_state + return self._calculated_state @callback def update_state(self) -> None: @@ -398,35 +394,25 @@ class Player: if not self.added_to_mass: if self.enabled: # player is now enabled and can be added - self.mass.add_job(self.mass.players.add_player(self)) + create_task(self.mass.players.add_player(self)) return - new_state = self.create_state() - changed_keys = self._cur_state.update(new_state) - # basic throttle: do not send state changed events if player did not change - if not changed_keys: - return - self._cur_state = new_state + 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: - self.mass.add_job(player_queue.update_state) - if len(changed_keys) == 1 and "elapsed_time" in changed_keys: - # no need to send player update if only the elapsed time changes - # this is already handled by the queue manager + 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.mass.signal_event(EVENT_PLAYER_CHANGED, new_state) + 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: - self.mass.add_job(self.mass.players.trigger_player_update(child_player_id)) + create_task(self.mass.players.trigger_player_update(child_player_id)) # update group player when child updates - for group_player_id in self._cur_state.group_parents: - self.mass.add_job(self.mass.players.trigger_player_update(group_player_id)) - - @callback - def _get_name(self) -> str: - """Return final/calculated player name.""" - conf_name = self.config.get(CONF_NAME) - return conf_name if conf_name else self.name + 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: @@ -439,14 +425,12 @@ class Player: return self.powered @callback - def _get_state(self) -> PlaybackState: - """Return final/calculated player's playback state.""" - if self.powered and self.active_queue != self.player_id: + 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(self.active_queue).state - if self.state == PlaybackState.Stopped and not self.powered: - return PlaybackState.Off - return self.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: @@ -469,7 +453,7 @@ class Player: 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.player_state.volume_level + group_volume += child_player.calculated_state.volume_level active_players += 1 if active_players: group_volume = group_volume / active_players @@ -495,39 +479,41 @@ class Player: 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 [ - PlaybackState.Playing, - PlaybackState.Paused, + PlayerState.PLAYING, + PlayerState.PAUSED, ]: return group_player_id return self.player_id @callback - def create_state(self) -> PlayerState: - """Create PlayerState.""" - return PlayerState( + 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=self._get_name(), - powered=self._get_powered(), - state=self._get_state(), + 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(), - elapsed_time=self.elapsed_time, 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=self._get_active_queue(), + active_queue=active_queue, ) def to_dict(self) -> dict: """Return playerstate for compatability with json serializer.""" - return self._cur_state.to_dict() + return self._calculated_state.to_dict() def __init__(self, *args, **kwargs) -> None: """Initialize a Player instance.""" self.mass: Optional[MusicAssistant] = None self.added_to_mass = False - self._cur_state = PlayerState() + self._calculated_state = CalculatedPlayerState() diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index f538cc57..9afcdec3 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -4,25 +4,25 @@ import logging import random import time import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Union from music_assistant.constants import ( CONF_CROSSFADE_DURATION, EVENT_QUEUE_ITEMS_UPDATED, - EVENT_QUEUE_TIME_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 -from music_assistant.models.media_types import Radio, Track -from music_assistant.models.player import PlaybackState, PlayerFeature +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 @@ -35,28 +35,31 @@ LOGGER = logging.getLogger("player_queue") class QueueOption(Enum): """Enum representation of the queue (play) options.""" - Play = "play" - Replace = "replace" - Next = "next" - Add = "add" + PLAY = "play" + REPLACE = "replace" + NEXT = "next" + ADD = "add" @dataclass -class QueueItem(Track): - """Representation of a queue item, extended version of track.""" +class QueueItem(ItemMapping): + """Representation of a queue item, simplified version of track.""" - streamdetails: StreamDetails = None - uri: str = "" queue_item_id: str = "" + streamdetails: StreamDetails = None + stream_url: str = "" + duration: int = 0 + artists: Set[ItemMapping] = field(default_factory=set) def __post_init__(self): """Generate unique id for the QueueItem.""" + super().__post_init__() self.queue_item_id = str(uuid.uuid4()) @classmethod - def from_track(cls, track: Union[Track, Radio]): + def from_track(cls, base_item: Union[Track, Radio]): """Construct QueueItem from track/radio item.""" - return cls.from_dict(track.to_dict()) + return cls.from_dict(base_item.to_dict()) class PlayerQueue: @@ -74,9 +77,14 @@ class PlayerQueue: self._last_item = None self._queue_stream_start_index = 0 self._queue_stream_next_index = 0 - self._last_player = PlaybackState.Stopped + self._queue_stream_active = False + self._last_playback_state = PlayerState.IDLE # load previous queue settings from disk - self.mass.add_job(self._restore_saved_state()) + 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.""" @@ -88,6 +96,11 @@ class PlayerQueue: """Return handle to (master) player of this queue.""" return self.mass.players.get_player(self._queue_id) + @property + def state(self) -> PlayerState: + """Return playbackstate of this (player) Queue.""" + return self.player.state + @property def queue_id(self) -> str: """Return the Queue's id.""" @@ -95,10 +108,10 @@ class PlayerQueue: def get_stream_url(self) -> str: """Return the full stream url for the player's Queue Stream.""" - uri = f"{self.mass.web.stream_url}/queue/{self.queue_id}" + url = f"{self.mass.web.stream_url}/queue/{self.queue_id}" # we set the checksum just to invalidate cache stuf - uri += f"?checksum={time.time()}" - return uri + url += f"?checksum={time.time()}" + return url @property def shuffle_enabled(self) -> bool: @@ -114,7 +127,7 @@ class PlayerQueue: 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 - self.mass.add_job(self.update(items)) + await self.update(items) elif self._shuffle_enabled and not enable_shuffle: # unshuffle self._shuffle_enabled = False @@ -123,9 +136,9 @@ class PlayerQueue: 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 - self.mass.add_job(self.update(items)) + await self.update(items) self.update_state() - self.mass.signal_event(EVENT_QUEUE_UPDATED, self) + self.signal_update() @property def repeat_enabled(self) -> bool: @@ -137,8 +150,8 @@ class PlayerQueue: if self._repeat_enabled != enable_repeat: self._repeat_enabled = enable_repeat self.update_state() - self.mass.add_job(self._save_state()) - self.mass.signal_event(EVENT_QUEUE_UPDATED, self) + create_task(self._save_state()) + self.signal_update() @property def cur_index(self) -> OptionalInt: @@ -290,9 +303,10 @@ class PlayerQueue: 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 or not self.supports_queue: + 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 @@ -308,13 +322,15 @@ class PlayerQueue: """Play item at index (or item_id) X in queue.""" if not isinstance(index, int): index = self.__index_by_id(index) + if index is None: + raise FileNotFoundError("Unknown index/id: %s" % 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_uri = self.get_stream_url() - return await self.player.cmd_play_uri(queue_stream_uri) + 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) @@ -326,7 +342,7 @@ class PlayerQueue: 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].uri) + return await self.player.cmd_play_uri(self._items[index].stream_url) async def move_item(self, queue_item_id: str, pos_shift: int = 1) -> None: """ @@ -338,7 +354,7 @@ class PlayerQueue: """ items = self.items.copy() item_index = self.__index_by_id(queue_item_id) - if pos_shift == 0 and self.player.state == PlaybackState.Playing: + if pos_shift == 0 and self.player.state == PlayerState.PLAYING: new_index = self.cur_index + 1 elif pos_shift == 0: new_index = self.cur_index @@ -357,12 +373,12 @@ class PlayerQueue: if self._shuffle_enabled: queue_items = self.__shuffle_items(queue_items) self._items = queue_items - if self.use_queue_stream or not self.supports_queue: + if self.use_queue_stream: await self.play_index(0) else: await self.player.cmd_queue_load(queue_items) - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self) - self.mass.add_job(self._save_state()) + self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) + create_task(self._save_state()) async def insert(self, queue_items: List[QueueItem], offset: int = 0) -> None: """ @@ -379,7 +395,7 @@ class PlayerQueue: insert_at_index = self.cur_index + offset for index, item in enumerate(queue_items): item.sort_index = insert_at_index + index - if self.shuffle_enabled: + if self.shuffle_enabled and len(queue_items) > 10: queue_items = self.__shuffle_items(queue_items) if offset == 0: # replace current item with new @@ -408,8 +424,8 @@ class PlayerQueue: ) self._items = self._items[self.cur_index + offset :] return await self.player.cmd_queue_load(self._items) - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self) - self.mass.add_job(self._save_state()) + self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) + create_task(self._save_state()) async def append(self, queue_items: List[QueueItem]) -> None: """Append new items at the end of the queue.""" @@ -422,7 +438,7 @@ class PlayerQueue: items = played_items + [self.cur_item] + next_items return await self.update(items) self._items = self._items + queue_items - if self.supports_queue and not self.use_queue_stream: + if not self.use_queue_stream: # send queue to player's own implementation try: await self.player.cmd_queue_append(queue_items) @@ -433,13 +449,13 @@ class PlayerQueue: ) self._items = self._items[self.cur_index :] return await self.player.cmd_queue_load(self._items) - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self) - self.mass.add_job(self._save_state()) + self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) + create_task(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 self.supports_queue and not self.use_queue_stream: + if not self.use_queue_stream: # send queue to player's own implementation try: await self.player.cmd_queue_update(queue_items) @@ -450,8 +466,8 @@ class PlayerQueue: ) self._items = self._items[self.cur_index :] await self.player.cmd_queue_load(self._items) - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self) - self.mass.add_job(self._save_state()) + self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) + create_task(self._save_state()) async def clear(self) -> None: """Clear all items in the queue.""" @@ -468,17 +484,18 @@ class PlayerQueue: except NotImplementedError: # not supported by player, ignore pass - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self) + self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) @callback def update_state(self) -> None: """Update queue details, called when player updates.""" new_index = self._cur_index track_time = self._cur_item_time + new_item_loaded = False # handle queue stream if ( self.use_queue_stream - and self.player.state == PlaybackState.Playing + and self.player.state == PlayerState.PLAYING and self.player.elapsed_time > 1 ): new_index, track_time = self.__get_queue_stream_index() @@ -486,7 +503,7 @@ class PlayerQueue: elif not self.use_queue_stream: track_time = self.player.elapsed_time for index, queue_item in enumerate(self.items): - if queue_item.uri == self.player.current_uri: + if queue_item.stream_url == self.player.current_uri: new_index = index break # process new index @@ -500,18 +517,22 @@ class PlayerQueue: and self.cur_item.streamdetails ): # new active item in queue - self.mass.signal_event(EVENT_QUEUE_UPDATED, self) + new_item_loaded = True # invalidate previous streamdetails if self._last_item: self._last_item.streamdetails = None self._last_item = self.cur_item - # update vars - if self._cur_item_time != track_time: - self._cur_item_time = track_time - self.mass.signal_event( - EVENT_QUEUE_TIME_UPDATED, - {"queue_id": self.queue_id, "cur_item_time": track_time}, - ) + # 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: + self._last_playback_state = self.state + self.signal_update() + 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() async def queue_stream_start(self) -> None: """Call when queue_streamer starts playing the queue stream.""" @@ -536,7 +557,7 @@ class PlayerQueue: """Instance attributes as dict so it can be serialized to json.""" return { "queue_id": self.player.player_id, - "queue_name": self.player.player_state.name, + "queue_name": self.player.calculated_state.name, "shuffle_enabled": self.shuffle_enabled, "repeat_enabled": self.repeat_enabled, "crossfade_enabled": self.crossfade_enabled, @@ -545,11 +566,19 @@ class PlayerQueue: "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": self.cur_item_time, + "cur_item_time": int(self.cur_item_time), "next_item": self.next_item.to_dict() if self.next_item else None, - "queue_stream_enabled": self.use_queue_stream, + "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, + ) + @callback def __get_queue_stream_index(self) -> Tuple[int, int]: """Get index of queue stream.""" diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py index 77d731c3..66ce26b2 100644 --- a/music_assistant/models/provider.py +++ b/music_assistant/models/provider.py @@ -136,11 +136,11 @@ class MusicProvider(Provider): def supported_mediatypes(self) -> List[MediaType]: """Return MediaTypes the provider supports.""" return [ - MediaType.Album, - MediaType.Artist, - MediaType.Playlist, - MediaType.Radio, - MediaType.Track, + MediaType.ALBUM, + MediaType.ARTIST, + MediaType.PLAYLIST, + MediaType.RADIO, + MediaType.TRACK, ] async def search( @@ -157,72 +157,72 @@ class MusicProvider(Provider): async def get_library_artists(self) -> List[Artist]: """Retrieve library artists from the provider.""" - if MediaType.Artist in self.supported_mediatypes: + if MediaType.ARTIST in self.supported_mediatypes: raise NotImplementedError async def get_library_albums(self) -> List[Album]: """Retrieve library albums from the provider.""" - if MediaType.Album in self.supported_mediatypes: + if MediaType.ALBUM in self.supported_mediatypes: raise NotImplementedError async def get_library_tracks(self) -> List[Track]: """Retrieve library tracks from the provider.""" - if MediaType.Track in self.supported_mediatypes: + if MediaType.TRACK in self.supported_mediatypes: raise NotImplementedError async def get_library_playlists(self) -> List[Playlist]: """Retrieve library/subscribed playlists from the provider.""" - if MediaType.Playlist in self.supported_mediatypes: + if MediaType.PLAYLIST in self.supported_mediatypes: raise NotImplementedError async def get_radios(self) -> List[Radio]: """Retrieve library/subscribed radio stations from the provider.""" - if MediaType.Radio in self.supported_mediatypes: + if MediaType.RADIO in self.supported_mediatypes: raise NotImplementedError async def get_artist(self, prov_artist_id: str) -> Artist: """Get full artist details by id.""" - if MediaType.Artist in self.supported_mediatypes: + if MediaType.ARTIST in self.supported_mediatypes: raise NotImplementedError async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: """Get a list of all albums for the given artist.""" - if MediaType.Album in self.supported_mediatypes: + if MediaType.ALBUM in self.supported_mediatypes: raise NotImplementedError async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: """Get a list of most popular tracks for the given artist.""" - if MediaType.Track in self.supported_mediatypes: + if MediaType.TRACK in self.supported_mediatypes: raise NotImplementedError async def get_album(self, prov_album_id: str) -> Album: """Get full album details by id.""" - if MediaType.Album in self.supported_mediatypes: + if MediaType.ALBUM in self.supported_mediatypes: raise NotImplementedError async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" - if MediaType.Track in self.supported_mediatypes: + if MediaType.TRACK in self.supported_mediatypes: raise NotImplementedError async def get_playlist(self, prov_playlist_id: str) -> Playlist: """Get full playlist details by id.""" - if MediaType.Playlist in self.supported_mediatypes: + if MediaType.PLAYLIST in self.supported_mediatypes: raise NotImplementedError async def get_radio(self, prov_radio_id: str) -> Radio: """Get full radio details by id.""" - if MediaType.Radio in self.supported_mediatypes: + if MediaType.RADIO in self.supported_mediatypes: raise NotImplementedError async def get_album_tracks(self, prov_album_id: str) -> List[Track]: """Get album tracks for given album id.""" - if MediaType.Album in self.supported_mediatypes: + if MediaType.ALBUM in self.supported_mediatypes: raise NotImplementedError async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: """Get all playlist tracks for given playlist id.""" - if MediaType.Playlist in self.supported_mediatypes: + if MediaType.PLAYLIST in self.supported_mediatypes: raise NotImplementedError async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool: @@ -237,14 +237,14 @@ class MusicProvider(Provider): self, prov_playlist_id: str, prov_track_ids: List[str] ) -> bool: """Add track(s) to playlist. Return true on succes.""" - if MediaType.Playlist in self.supported_mediatypes: + if MediaType.PLAYLIST in self.supported_mediatypes: raise NotImplementedError async def remove_playlist_tracks( self, prov_playlist_id: str, prov_track_ids: List[str] ) -> bool: """Remove track(s) from playlist. Return true on succes.""" - if MediaType.Playlist in self.supported_mediatypes: + if MediaType.PLAYLIST in self.supported_mediatypes: raise NotImplementedError async def get_stream_details(self, item_id: str) -> StreamDetails: diff --git a/music_assistant/models/streamdetails.py b/music_assistant/models/streamdetails.py index 61da129e..f7452cca 100644 --- a/music_assistant/models/streamdetails.py +++ b/music_assistant/models/streamdetails.py @@ -2,9 +2,10 @@ from dataclasses import dataclass from enum import Enum -from typing import Any +from typing import Any, Optional from mashumaro.serializer.base.dict import DataClassDictMixin +from music_assistant.models.media_types import MediaType class StreamType(Enum): @@ -24,6 +25,9 @@ class ContentType(Enum): MP3 = "mp3" AAC = "aac" MPEG = "mpeg" + S24 = "s24" + S32 = "s32" + S64 = "s64" @dataclass @@ -35,27 +39,34 @@ class StreamDetails(DataClassDictMixin): item_id: str path: str content_type: ContentType - sample_rate: int - bit_depth: int player_id: str = "" details: Any = None seconds_played: int = 0 gain_correct: float = 0 + loudness: Optional[float] = None + sample_rate: int = 44100 + bit_depth: int = 16 + media_type: MediaType = MediaType.TRACK def to_dict( self, use_bytes: bool = False, use_enum: bool = False, use_datetime: bool = False, - **kwargs + **kwargs, ): """Handle conversion to dict.""" return { "provider": self.provider, "item_id": self.item_id, "content_type": self.content_type.value, + "media_type": self.media_type.value, "sample_rate": self.sample_rate, "bit_depth": self.bit_depth, "gain_correct": self.gain_correct, "seconds_played": self.seconds_played, } + + def __str__(self): + """Return pretty printable string of object.""" + return f"{self.type.value}/{self.content_type.value} - {self.provider}/{self.item_id}" diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py index f78b33fd..b083cdbd 100644 --- a/music_assistant/providers/builtin_player/__init__.py +++ b/music_assistant/providers/builtin_player/__init__.py @@ -3,15 +3,9 @@ import logging import time from typing import List -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import run_periodic +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, - PlaybackState, - Player, - PlayerFeature, -) +from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState from music_assistant.models.provider import PlayerProvider PROV_ID = "builtin_player" @@ -23,7 +17,7 @@ PLAYER_CONFIG_ENTRIES = [] PLAYER_FEATURES = [] WS_COMMAND_WSPLAYER_CMD = "wsplayer command" -WS_COMMAND_WSplayer = "wsplayer state" +WS_COMMAND_WSPLAYER_STATE = "wsplayer state" WS_COMMAND_WSPLAYER_REGISTER = "wsplayer register" @@ -58,11 +52,11 @@ class MassPlayerProvider(PlayerProvider): async def on_start(self) -> bool: """Handle initialization of the provider based on config.""" # listen for websockets commands to dynamically create players - self.mass.add_job(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, self.handle_ws_player) + 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): @@ -75,7 +69,7 @@ class MassPlayerProvider(PlayerProvider): player = self.mass.players.get_player(player_id) if not player: # register new player - player = WebsocketsPlayer(self.mass, player_id, details["name"]) + player = WebsocketsPlayer(player_id, details["name"]) await self.mass.players.add_player(player) await player.handle_player(details) @@ -102,18 +96,19 @@ class WebsocketsPlayer(Player): and our internal event bus. """ - def __init__(self, mass: MusicAssistant, player_id: str, player_name: str): + 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 = PlaybackState.Stopped + 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.""" @@ -122,7 +117,7 @@ class WebsocketsPlayer(Player): if "muted" in data: self._muted = data["muted"] if "state" in data: - self._state = PlaybackState(data["state"]) + self._state = PlayerState(data["state"]) if "elapsed_time" in data: self._elapsed_time = data["elapsed_time"] if "current_uri" in data: @@ -161,8 +156,8 @@ class WebsocketsPlayer(Player): return self._elapsed_time @property - def state(self) -> PlaybackState: - """Return current PlaybackState of player.""" + def state(self) -> PlayerState: + """Return current PlayerState of player.""" return self._state @property @@ -207,22 +202,22 @@ class WebsocketsPlayer(Player): :param uri: uri/url to send to the player. """ data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri} - self.mass.signal_event(WS_COMMAND_WSPLAYER_CMD, data) + 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.signal_event(WS_COMMAND_WSPLAYER_CMD, data) + 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.signal_event(WS_COMMAND_WSPLAYER_CMD, data) + 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.signal_event(WS_COMMAND_WSPLAYER_CMD, data) + self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) async def cmd_power_on(self) -> None: """Send POWER ON command to player.""" @@ -245,7 +240,7 @@ class WebsocketsPlayer(Player): "cmd": "volume_set", "volume_level": volume_level, } - self.mass.signal_event(WS_COMMAND_WSPLAYER_CMD, data) + self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) async def cmd_volume_mute(self, is_muted: bool = False) -> None: """ @@ -254,4 +249,4 @@ class WebsocketsPlayer(Player): :param is_muted: bool with new mute state. """ data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted} - self.mass.signal_event(WS_COMMAND_WSPLAYER_CMD, data) + self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index 155b503e..e6b0dc26 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -4,6 +4,7 @@ import logging from typing import List 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 @@ -60,7 +61,7 @@ class ChromecastProvider(PlayerProvider): self._listener, self.mass.zeroconf ) - self.mass.add_job(start_discovery) + create_task(start_discovery) return True async def on_stop(self): @@ -68,7 +69,7 @@ class ChromecastProvider(PlayerProvider): if not self._browser: return # stop discovery - self.mass.add_job(pychromecast.stop_discovery, self._browser) + create_task(pychromecast.stop_discovery, self._browser) def __chromecast_add_update_callback(self, cast_uuid, cast_service_name): """Handle zeroconf discovery of a new or updated chromecast.""" @@ -99,7 +100,7 @@ class ChromecastProvider(PlayerProvider): player = ChromecastPlayer(self.mass, cast_info) # if player was already added, the player will take care of reconnects itself. player.set_cast_info(cast_info) - self.mass.add_job(self.mass.players.add_player(player)) + create_task(self.mass.players.add_player(player)) @staticmethod def __chromecast_remove_callback(cast_uuid, cast_service_name, cast_service): diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 4d0db6d1..874e4e4e 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -7,14 +7,9 @@ import pychromecast from asyncio_throttle import Throttler from music_assistant.helpers.compare import compare_strings from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import yield_chunks +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, - PlaybackState, - Player, - PlayerFeature, -) +from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState from music_assistant.models.player_queue import QueueItem from pychromecast.controllers.multizone import MultizoneController from pychromecast.socket_client import ( @@ -54,7 +49,7 @@ class ChromecastPlayer(Player): self._available = False self._status_listener: Optional[CastStatusListener] = None self._is_speaker_group = False - self._throttler = Throttler(rate_limit=1, period=0.1) + self._throttler = Throttler(rate_limit=1, period=0.2) @property def player_id(self) -> str: @@ -94,19 +89,17 @@ class ChromecastPlayer(Player): return self.media_status and self.media_status.player_is_playing @property - def state(self) -> PlaybackState: + def state(self) -> PlayerState: """Return the state of the player.""" - if not self.powered: - return PlaybackState.Off if self.media_status is None: - return PlaybackState.Stopped + return PlayerState.IDLE if self.media_status.player_is_playing: - return PlaybackState.Playing + return PlayerState.PLAYING if self.media_status.player_is_paused: - return PlaybackState.Paused + return PlayerState.PAUSED if self.media_status.player_is_idle: - return PlaybackState.Stopped - return PlaybackState.Stopped + return PlayerState.IDLE + return PlayerState.IDLE @property def elapsed_time(self) -> int: @@ -286,7 +279,7 @@ class ChromecastPlayer(Player): self._available = new_available self.update_state() if self._cast_info.is_audio_group and new_available: - self.mass.add_job(self._chromecast.mz_controller.update_members) + create_task(self._chromecast.mz_controller.update_members) # ========== Service Calls ========== @@ -338,7 +331,7 @@ class ChromecastPlayer(Player): if player_queue.use_queue_stream: # create (fake) CC queue so that skip and previous will work queue_item = QueueItem( - item_id=uri, provider="mass", name="Music Assistant", uri=uri + item_id=uri, provider="mass", name="Music Assistant", stream_url=uri ) return await self.cmd_queue_load([queue_item, queue_item]) await self.chromecast_command(self._chromecast.play_media, uri, "audio/flac") @@ -346,7 +339,7 @@ class ChromecastPlayer(Player): async def cmd_queue_load(self, queue_items: List[QueueItem]) -> None: """Load (overwrite) queue with new items.""" player_queue = self.mass.players.get_player_queue(self.player_id) - cc_queue_items = self.__create_queue_items(queue_items[:50]) + cc_queue_items = self.__create_queue_items(queue_items[:25]) repeat_enabled = player_queue.use_queue_stream or player_queue.repeat_enabled queuedata = { "type": "QUEUE_LOAD", @@ -354,16 +347,16 @@ class ChromecastPlayer(Player): "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 50 tracks at once or the socket will crash + "items": cc_queue_items, # only load 25 tracks at once or the socket will crash } await self.chromecast_command(self.__send_player_queue, queuedata) if len(queue_items) > 50: - await self.cmd_queue_append(queue_items[51:]) + 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, 50): + async for chunk in yield_chunks(cc_queue_items, 25): queuedata = { "type": "QUEUE_INSERT", "insertBefore": None, @@ -375,30 +368,32 @@ class ChromecastPlayer(Player): """Create list of CC queue items from tracks.""" return [self.__create_queue_item(track) for track in tracks] - def __create_queue_item(self, track): + def __create_queue_item(self, queue_item: QueueItem): """Create CC queue item from track info.""" player_queue = self.mass.players.get_player_queue(self.player_id) return { - "opt_itemId": track.queue_item_id, + "opt_itemId": queue_item.queue_item_id, "autoplay": True, "preloadTime": 10, - "playbackDuration": int(track.duration), + "playbackDuration": int(queue_item.duration), "startTime": 0, "activeTrackIds": [], "media": { - "contentId": track.uri, + "contentId": queue_item.stream_url, "customData": { - "provider": track.provider, - "uri": track.uri, - "item_id": track.queue_item_id, + "provider": queue_item.provider, + "uri": queue_item.stream_url, + "item_id": queue_item.queue_item_id, }, "contentType": "audio/flac", "streamType": "LIVE" if player_queue.use_queue_stream else "BUFFERED", "metadata": { - "title": track.name, - "artist": next(iter(track.artists)).name if track.artists else "", + "title": queue_item.name, + "artist": next(iter(queue_item.artists)).name + if queue_item.artists + else "", }, - "duration": int(track.duration), + "duration": int(queue_item.duration), }, } @@ -431,4 +426,4 @@ class ChromecastPlayer(Player): ) return async with self._throttler: - self.mass.add_job(func, *args, **kwargs) + create_task(func, *args, **kwargs) diff --git a/music_assistant/providers/file/__init__.py b/music_assistant/providers/file/__init__.py index 68a11b12..9a264dcb 100644 --- a/music_assistant/providers/file/__init__.py +++ b/music_assistant/providers/file/__init__.py @@ -83,7 +83,7 @@ class FileProvider(MusicProvider): @property def supported_mediatypes(self) -> List[MediaType]: """Return MediaTypes the provider supports.""" - return [MediaType.Album, MediaType.Artist, MediaType.Playlist, MediaType.Track] + return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK] async def on_start(self) -> bool: """Handle initialization of the provider based on config.""" diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py index 361ed38b..6ddf0e86 100644 --- a/music_assistant/providers/qobuz/__init__.py +++ b/music_assistant/providers/qobuz/__init__.py @@ -79,7 +79,7 @@ class QobuzProvider(MusicProvider): @property def supported_mediatypes(self) -> List[MediaType]: """Return MediaTypes the provider supports.""" - return [MediaType.Album, MediaType.Artist, MediaType.Playlist, MediaType.Track] + return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK] async def on_start(self) -> bool: """Handle initialization of the provider based on config.""" @@ -94,7 +94,7 @@ class QobuzProvider(MusicProvider): self.__user_auth_info = None self.__logged_in = False self._throttler = Throttler(rate_limit=4, period=1) - self.mass.add_event_listener( + self.mass.eventbus.add_listener( self.mass_event, (EVENT_STREAM_STARTED, EVENT_STREAM_ENDED) ) return True @@ -113,13 +113,13 @@ class QobuzProvider(MusicProvider): 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: + if media_types[0] == MediaType.ARTIST: params["type"] = "artists" - if media_types[0] == MediaType.Album: + if media_types[0] == MediaType.ALBUM: params["type"] = "albums" - if media_types[0] == MediaType.Track: + if media_types[0] == MediaType.TRACK: params["type"] = "tracks" - if media_types[0] == MediaType.Playlist: + if media_types[0] == MediaType.PLAYLIST: params["type"] = "playlists" searchresult = await self._get_data("catalog/search", params) if searchresult: @@ -298,19 +298,19 @@ class QobuzProvider(MusicProvider): async def library_add(self, prov_item_id, media_type: MediaType): """Add item to library.""" result = None - if media_type == MediaType.Artist: + if media_type == MediaType.ARTIST: result = await self._get_data( "favorite/create", {"artist_ids": prov_item_id} ) - elif media_type == MediaType.Album: + elif media_type == MediaType.ALBUM: result = await self._get_data( "favorite/create", {"album_ids": prov_item_id} ) - elif media_type == MediaType.Track: + elif media_type == MediaType.TRACK: result = await self._get_data( "favorite/create", {"track_ids": prov_item_id} ) - elif media_type == MediaType.Playlist: + elif media_type == MediaType.PLAYLIST: result = await self._get_data( "playlist/subscribe", {"playlist_id": prov_item_id} ) @@ -319,19 +319,19 @@ class QobuzProvider(MusicProvider): async def library_remove(self, prov_item_id, media_type: MediaType): """Remove item from library.""" result = None - if media_type == MediaType.Artist: + if media_type == MediaType.ARTIST: result = await self._get_data( "favorite/delete", {"artist_ids": prov_item_id} ) - elif media_type == MediaType.Album: + elif media_type == MediaType.ALBUM: result = await self._get_data( "favorite/delete", {"album_ids": prov_item_id} ) - elif media_type == MediaType.Track: + elif media_type == MediaType.TRACK: result = await self._get_data( "favorite/delete", {"track_ids": prov_item_id} ) - elif media_type == MediaType.Playlist: + elif media_type == MediaType.PLAYLIST: playlist = await self.get_playlist(prov_item_id) if playlist.is_editable: result = await self._get_data( @@ -460,6 +460,9 @@ class QobuzProvider(MusicProvider): 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 @@ -493,17 +496,17 @@ class QobuzProvider(MusicProvider): album_obj.get("product_type", "") == "single" or album_obj.get("release_type", "") == "single" ): - album.album_type = AlbumType.Single + album.album_type = AlbumType.SINGLE elif ( album_obj.get("product_type", "") == "compilation" or "Various" in album.artist.name ): - album.album_type = AlbumType.Compilation + album.album_type = AlbumType.COMPILATION elif ( album_obj.get("product_type", "") == "album" or album_obj.get("release_type", "") == "album" ): - album.album_type = AlbumType.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) diff --git a/music_assistant/providers/sonos/sonos.py b/music_assistant/providers/sonos/sonos.py index aae18158..affc3afa 100644 --- a/music_assistant/providers/sonos/sonos.py +++ b/music_assistant/providers/sonos/sonos.py @@ -6,14 +6,9 @@ import time from typing import List import soco -from music_assistant.helpers.util import run_periodic +from music_assistant.helpers.util import create_task from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import ( - DeviceInfo, - PlaybackState, - Player, - PlayerFeature, -) +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 @@ -53,7 +48,7 @@ class SonosProvider(PlayerProvider): async def on_start(self) -> bool: """Handle initialization of the provider.""" - self._tasks.append(self.mass.add_job(self._periodic_discovery())) + 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.""" @@ -68,7 +63,7 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.play_uri, uri) + create_task(player.soco.play_uri, uri) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -80,7 +75,7 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.stop) + create_task(player.soco.stop) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -92,7 +87,7 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.play) + create_task(player.soco.play) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -104,7 +99,7 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.pause) + create_task(player.soco.pause) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -116,7 +111,7 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.next) + create_task(player.soco.next) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -128,7 +123,7 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.previous) + create_task(player.soco.previous) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -195,7 +190,7 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.play_from_queue, index) + create_task(player.soco.play_from_queue, index) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -208,9 +203,9 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.clear_queue) + create_task(player.soco.clear_queue) for pos, item in enumerate(queue_items): - self.mass.add_job(player.soco.add_uri_to_queue, item.uri, pos) + create_task(player.soco.add_uri_to_queue, item.stream_url, pos) else: LOGGER.warning("Received command for unavailable player: %s", player_id) @@ -228,8 +223,8 @@ class SonosProvider(PlayerProvider): player = self._players.get(player_id) if player: for pos, item in enumerate(queue_items): - self.mass.add_job( - player.soco.add_uri_to_queue, item.uri, insert_at_index + pos + 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) @@ -256,15 +251,10 @@ class SonosProvider(PlayerProvider): """ player = self._players.get(player_id) if player: - self.mass.add_job(player.soco.clear_queue) + create_task(player.soco.clear_queue) else: LOGGER.warning("Received command for unavailable player: %s", player_id) - @run_periodic(1800) - async def _periodic_discovery(self): - """Run Sonos discovery at interval.""" - self._tasks.append(self.mass.add_job(None, self.__run_discovery)) - def __run_discovery(self): """Background Sonos discovery and handler, runs in executor thread.""" if self._discovery_running: @@ -279,7 +269,7 @@ class SonosProvider(PlayerProvider): # 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: - self.mass.add_job(self.mass.players.remove_player(player.player_id)) + create_task(self.mass.players.remove_player(player.player_id)) for sub in player.subscriptions: sub.unsubscribe() self._players.pop(player, None) @@ -322,7 +312,7 @@ class SonosProvider(PlayerProvider): subscribe(soco_device.avTransport, self.__player_event) subscribe(soco_device.renderingControl, self.__player_event) subscribe(soco_device.zoneGroupTopology, self.__topology_changed) - self.mass.run_task(self.mass.players.add_player(player)) + create_task(self.mass.players.add_player(player)) return player def __player_event(self, player_id: str, event): @@ -353,8 +343,8 @@ class SonosProvider(PlayerProvider): ) rel_time = __timespan_secs(position_info.get("RelTime")) player.elapsed_time = rel_time - if player.state == PlaybackState.Playing: - self.mass.add_job(self._report_progress(player_id)) + if player.state == PlayerState.PLAYING: + create_task(self._report_progress(player_id)) player.update_state() def __process_groups(self, sonos_groups): @@ -371,13 +361,13 @@ class SonosProvider(PlayerProvider): group_player.is_group_player = True group_player.name = group.label group_player.group_childs = [item.uid for item in group.members] - self.mass.run_task(self.mass.players.update_player(group_player)) + 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. - self.mass.add_job(self.__run_discovery) + create_task(self.__run_discovery) async def _report_progress(self, player_id: str): """Report current progress while playing.""" @@ -387,7 +377,7 @@ class SonosProvider(PlayerProvider): # so we need to send it in periodically player = self._players[player_id] player.should_poll = True - while player and player.state == PlaybackState.Playing: + 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 @@ -396,15 +386,15 @@ class SonosProvider(PlayerProvider): self._report_progress_tasks.pop(player_id, None) -def __convert_state(sonos_state: str) -> PlaybackState: - """Convert Sonos state to PlaybackState.""" +def __convert_state(sonos_state: str) -> PlayerState: + """Convert Sonos state to PlayerState.""" if sonos_state == "PLAYING": - return PlaybackState.Playing + return PlayerState.PLAYING if sonos_state == "TRANSITIONING": - return PlaybackState.Playing + return PlayerState.PLAYING if sonos_state == "PAUSED_PLAYBACK": - return PlaybackState.Paused - return PlaybackState.Stopped + return PlayerState.PAUSED + return PlayerState.IDLE def __timespan_secs(timespan): diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index 3e835b72..e7cd92a9 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -84,11 +84,11 @@ class SpotifyProvider(MusicProvider): def supported_mediatypes(self) -> List[MediaType]: """Return MediaTypes the provider supports.""" return [ - MediaType.Album, - MediaType.Artist, - MediaType.Playlist, - # MediaType.Radio, # TODO! - MediaType.Track, + MediaType.ALBUM, + MediaType.ARTIST, + MediaType.PLAYLIST, + # MediaType.RADIO, # TODO! + MediaType.TRACK, ] async def on_start(self) -> bool: @@ -120,13 +120,13 @@ class SpotifyProvider(MusicProvider): """ result = SearchResult() searchtypes = [] - if MediaType.Artist in media_types: + if MediaType.ARTIST in media_types: searchtypes.append("artist") - if MediaType.Album in media_types: + if MediaType.ALBUM in media_types: searchtypes.append("album") - if MediaType.Track in media_types: + if MediaType.TRACK in media_types: searchtypes.append("track") - if MediaType.Playlist in media_types: + if MediaType.PLAYLIST in media_types: searchtypes.append("playlist") searchtype = ",".join(searchtypes) params = {"q": search_query, "type": searchtype, "limit": limit} @@ -257,15 +257,15 @@ class SpotifyProvider(MusicProvider): async def library_add(self, prov_item_id, media_type: MediaType): """Add item to library.""" result = False - if media_type == MediaType.Artist: + if media_type == MediaType.ARTIST: result = await self._put_data( "me/following", {"ids": prov_item_id, "type": "artist"} ) - elif media_type == MediaType.Album: + elif media_type == MediaType.ALBUM: result = await self._put_data("me/albums", {"ids": prov_item_id}) - elif media_type == MediaType.Track: + elif media_type == MediaType.TRACK: result = await self._put_data("me/tracks", {"ids": prov_item_id}) - elif media_type == MediaType.Playlist: + elif media_type == MediaType.PLAYLIST: result = await self._put_data( f"playlists/{prov_item_id}/followers", data={"public": False} ) @@ -274,15 +274,15 @@ class SpotifyProvider(MusicProvider): async def library_remove(self, prov_item_id, media_type: MediaType): """Remove item from library.""" result = False - if media_type == MediaType.Artist: + if media_type == MediaType.ARTIST: result = await self._delete_data( "me/following", {"ids": prov_item_id, "type": "artist"} ) - elif media_type == MediaType.Album: + elif media_type == MediaType.ALBUM: result = await self._delete_data("me/albums", {"ids": prov_item_id}) - elif media_type == MediaType.Track: + elif media_type == MediaType.TRACK: result = await self._delete_data("me/tracks", {"ids": prov_item_id}) - elif media_type == MediaType.Playlist: + elif media_type == MediaType.PLAYLIST: result = await self._delete_data(f"playlists/{prov_item_id}/followers") return result @@ -360,11 +360,11 @@ class SpotifyProvider(MusicProvider): if album.artist: break if album_obj["album_type"] == "single": - album.album_type = AlbumType.Single + album.album_type = AlbumType.SINGLE elif album_obj["album_type"] == "compilation": - album.album_type = AlbumType.Compilation + album.album_type = AlbumType.COMPILATION elif album_obj["album_type"] == "album": - album.album_type = AlbumType.Album + album.album_type = AlbumType.ALBUM if "genres" in album_obj: album.metadata["genres"] = album_obj["genres"] if album_obj.get("images"): diff --git a/music_assistant/providers/squeezebox/__init__.py b/music_assistant/providers/squeezebox/__init__.py index 00144cb4..758125d6 100644 --- a/music_assistant/providers/squeezebox/__init__.py +++ b/music_assistant/providers/squeezebox/__init__.py @@ -6,14 +6,9 @@ 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 +from music_assistant.helpers.util import callback, create_task from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import ( - DeviceInfo, - PlaybackState, - Player, - PlayerFeature, -) +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 @@ -60,18 +55,10 @@ class PySqueezeProvider(PlayerProvider): async def on_start(self) -> bool: """Handle initialization of the provider. Called on startup.""" # start slimproto server - self._tasks.append( - self.mass.add_job( - asyncio.start_server(self._client_connected, "0.0.0.0", 3483) - ) - ) - # setup discovery - self._tasks.append(self.mass.add_job(self.start_discovery())) + create_task(asyncio.start_server(self._client_connected, "0.0.0.0", 3483)) - async def on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - for task in self._tasks: - task.cancel() + # setup discovery + create_task(self.start_discovery()) async def start_discovery(self): """Start discovery for players.""" @@ -171,7 +158,7 @@ class SqueezePlayer(Player): @property def state(self): """Return current state of player.""" - return PlaybackState(self.socket_client.state) + return PlayerState(self.socket_client.state) @property def elapsed_time(self): @@ -253,7 +240,7 @@ class SqueezePlayer(Player): if queue: new_track = queue.get_item(queue.cur_index + 1) if new_track: - return await self.cmd_play_uri(new_track.uri) + return await self.cmd_play_uri(new_track.stream_url) async def cmd_previous(self): """Send PREVIOUS TRACK command to player.""" @@ -261,7 +248,7 @@ class SqueezePlayer(Player): if queue: new_track = queue.get_item(queue.cur_index - 1) if new_track: - return await self.cmd_play_uri(new_track.uri) + return await self.cmd_play_uri(new_track.stream_url) async def cmd_queue_play_index(self, index: int): """ @@ -273,7 +260,7 @@ class SqueezePlayer(Player): if queue: new_track = queue.get_item(index) if new_track: - return await self.cmd_play_uri(new_track.uri) + return await self.cmd_play_uri(new_track.stream_url) async def cmd_queue_load(self, queue_items: List[QueueItem]): """ @@ -282,8 +269,8 @@ class SqueezePlayer(Player): :param queue_items: a list of QueueItems """ if queue_items: - await self.cmd_play_uri(queue_items[0].uri) - return await self.cmd_play_uri(queue_items[0].uri) + 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 @@ -335,7 +322,7 @@ class SqueezePlayer(Player): """Process incoming event from the socket client.""" if event == SqueezeEvent.CONNECTED: # restore previous power/volume - self.mass.add_job(self.restore_states()) + 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) @@ -345,9 +332,9 @@ class SqueezePlayer(Player): crossfade = self.mass.config.player_settings[self.player_id][ CONF_CROSSFADE_DURATION ] - self.mass.add_job( + create_task( self.socket_client.play_uri( - next_item.uri, + next_item.stream_url, send_flush=False, crossfade_duration=crossfade, ) diff --git a/music_assistant/providers/squeezebox/socket_client.py b/music_assistant/providers/squeezebox/socket_client.py index 5fe3b99a..f5be8ac4 100644 --- a/music_assistant/providers/squeezebox/socket_client.py +++ b/music_assistant/providers/squeezebox/socket_client.py @@ -9,7 +9,7 @@ from enum import Enum from typing import Callable from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import run_periodic +from music_assistant.helpers.util import create_task, run_periodic from .constants import PROV_ID @@ -32,7 +32,7 @@ DEVICE_TYPE = { } STATE_PLAYING = "playing" -STATE_STOPPED = "stopped" +STATE_IDLE = "idle" STATE_PAUSED = "paused" @@ -67,15 +67,15 @@ class SqueezeSocketClient: self._volume_control = PySqueezeVolume() self._powered = False self._muted = False - self._state = STATE_STOPPED + self._state = STATE_IDLE self._elapsed_seconds = 0 self._elapsed_milliseconds = 0 self._current_uri = "" self._connected = True self._event_callbacks = [] self._tasks = [ - asyncio.create_task(self._socket_reader()), - asyncio.create_task(self._send_heartbeat()), + create_task(self._socket_reader()), + create_task(self._send_heartbeat()), ] def disconnect(self) -> None: @@ -172,18 +172,15 @@ class SqueezeSocketClient: async def cmd_stop(self): """Send stop command to player.""" - data = self.__pack_stream(b"q", autostart=b"0", flags=0) - await self._send_frame(b"strm", data) + await self.send_strm(b"q") async def cmd_play(self): """Send play (unpause) command to player.""" - data = self.__pack_stream(b"u", autostart=b"0", flags=0) - await self._send_frame(b"strm", data) + await self.send_strm(b"u") async def cmd_pause(self): """Send pause command to player.""" - data = self.__pack_stream(b"p", autostart=b"0", flags=0) - await self._send_frame(b"strm", data) + await self.send_strm(b"p") async def cmd_power(self, powered: bool = True): """Send power command to player.""" @@ -215,25 +212,15 @@ class SqueezeSocketClient: ): """Request player to start playing a single uri.""" if send_flush: - data = self.__pack_stream(b"f", autostart=b"0", flags=0) - await self._send_frame(b"strm", data) + 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" + autostart = b"0" trans_type = b"1" if enable_crossfade else b"0" - formatbyte = b"f" # fixed to flac uri = "/stream" + uri.split("/stream")[1] - data = self.__pack_stream( - command, - autostart=autostart, - flags=0x00, - formatbyte=formatbyte, - trans_type=trans_type, - trans_duration=crossfade_duration, - ) # extract host and port from uri regex = "(?:http.*://)?(?P[^:/ ]+).?(?P[0-9]*).*" regex_result = re.search(regex, uri) @@ -244,9 +231,14 @@ class SqueezeSocketClient: elif not port: port = 80 headers = f"Connection: close\r\nAccept: */*\r\nHost: {host}:{port}\r\n" - request = "GET %s HTTP/1.1\r\n%s\r\n" % (uri, headers) - data = data + request.encode("utf-8") - await self._send_frame(b"strm", data) + 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): @@ -254,8 +246,7 @@ class SqueezeSocketClient: if not self._connected: return timestamp = int(time.time()) - data = self.__pack_stream(b"t", replay_gain=timestamp, flags=0) - await self._send_frame(b"strm", data) + await self.send_strm(b"t", replay_gain=timestamp, flags=0) async def _send_frame(self, command, data): """Send command to Squeeze player.""" @@ -297,29 +288,36 @@ class SqueezeSocketClient: self._connected = False self.signal_event(SqueezeEvent.DISCONNECTED) - @staticmethod - def __pack_stream( - command, - autostart=b"1", - formatbyte=b"o", - pcmargs=(b"?", b"?", b"?", b"?"), - threshold=200, + 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, + flags=0x00, output_threshold=0, replay_gain=0, server_port=8095, server_ip=0, + httpreq=b"", ): """Create stream request message based on given arguments.""" - return struct.pack( + data = struct.pack( "!cccccccBcBcBBBLHL", command, autostart, formatbyte, - *pcmargs, + samplesize, + samplerate, + channels, + endian, threshold, spdif, trans_duration, @@ -331,6 +329,7 @@ class SqueezeSocketClient: 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).""" @@ -341,7 +340,7 @@ class SqueezeSocketClient: self._player_id = str(device_mac).lower() self._device_type = DEVICE_TYPE.get(dev_id, "unknown device") LOGGER.debug("Player connected: %s", self.name) - asyncio.create_task(self._initialize_player()) + create_task(self._initialize_player()) self.signal_event(SqueezeEvent.CONNECTED) def _process_stat(self, data): @@ -355,7 +354,7 @@ class SqueezeSocketClient: if event_handler is None: LOGGER.debug("Unhandled event: %s - event_data: %s", event, event_data) else: - self.mass.add_job(event_handler, data[4:]) + create_task(event_handler, data[4:]) def _process_stat_aude(self, data): """Process incoming stat AUDe message (power level and mute).""" @@ -374,14 +373,14 @@ class SqueezeSocketClient: def _process_stat_stmd(self, data): """Process incoming stat STMd message (decoder ready).""" # pylint: disable=unused-argument - LOGGER.debug("STMu received - Decoder Ready for next track.") + 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_STOPPED + self._state = STATE_IDLE self._elapsed_milliseconds = 0 self._elapsed_seconds = 0 self.signal_event(SqueezeEvent.STATE_UPDATED) @@ -394,7 +393,7 @@ class SqueezeSocketClient: No more decoded (uncompressed) data to play; triggers rebuffering. """ # pylint: disable=unused-argument - LOGGER.debug("STMo received - output underrun.") + LOGGER.warning("STMo received - output underrun.") def _process_stat_stmp(self, data): """Process incoming stat STMp message: Pause confirmed.""" @@ -436,7 +435,7 @@ class SqueezeSocketClient: elapsed_seconds, voltage, elapsed_milliseconds, - server_timestamp, + timestamp, error_code, ) = struct.unpack("!BBBLLLLHLLLLHLLH", data) if self.state == STATE_PLAYING: @@ -451,14 +450,26 @@ class SqueezeSocketClient: """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_STOPPED + 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.") + # 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.""" - # pylint: disable=unused-argument - # send continue - asyncio.create_task(self._send_frame(b"cont", b"0")) + LOGGER.debug("RESP received - Response received at player.") def _process_setd(self, data): """Process incoming SETD message: Get/set player firmware settings.""" diff --git a/music_assistant/providers/tunein/__init__.py b/music_assistant/providers/tunein/__init__.py index dd330731..821d4b3e 100644 --- a/music_assistant/providers/tunein/__init__.py +++ b/music_assistant/providers/tunein/__init__.py @@ -66,7 +66,7 @@ class TuneInProvider(MusicProvider): @property def supported_mediatypes(self) -> List[MediaType]: """Return MediaTypes the provider supports.""" - return [MediaType.Radio] + return [MediaType.RADIO] async def on_start(self) -> bool: """Handle initialization of the provider based on config.""" @@ -176,6 +176,7 @@ class TuneInProvider(MusicProvider): content_type=ContentType(stream["media_type"]), sample_rate=44100, bit_depth=16, + media_type=MediaType.RADIO, details=stream, ) return None diff --git a/music_assistant/providers/universal_group/__init__.py b/music_assistant/providers/universal_group/__init__.py index c8ce97ff..5c5f2dfb 100644 --- a/music_assistant/providers/universal_group/__init__.py +++ b/music_assistant/providers/universal_group/__init__.py @@ -5,8 +5,9 @@ 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 -from music_assistant.models.player import DeviceInfo, PlaybackState, Player +from music_assistant.models.player import DeviceInfo, Player, PlayerState from music_assistant.models.provider import PlayerProvider PROV_ID = "universal_group" @@ -57,7 +58,7 @@ class GroupPlayerProvider(PlayerProvider): conf = self.mass.config.player_providers[PROV_ID] for index in range(conf[CONF_PLAYER_COUNT]): player = GroupPlayer(self.mass, index) - self.mass.add_job(self.mass.players.add_player(player)) + await self.mass.players.add_player(player) return True async def on_stop(self): @@ -78,7 +79,7 @@ class GroupPlayer(Player): self._provider_id = PROV_ID self._name = f"{PROV_NAME} {player_index}" self._powered = False - self._state = PlaybackState.Stopped + self._state = PlayerState.IDLE self._available = True self._current_uri = "" self._volume_level = 0 @@ -110,8 +111,8 @@ class GroupPlayer(Player): return self._powered @property - def state(self) -> PlaybackState: - """Return current PlaybackState of player.""" + def state(self) -> PlayerState: + """Return current PlayerState of player.""" return self._state @property @@ -137,7 +138,7 @@ class GroupPlayer(Player): @property def elapsed_time(self): """Return elapsed time for first child player.""" - if self.state in [PlaybackState.Playing, PlaybackState.Paused]: + 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: @@ -234,7 +235,7 @@ class GroupPlayer(Player): """Play the specified uri/url on the player.""" await self.cmd_stop() self._current_uri = uri - self._state = PlaybackState.Playing + self._state = PlayerState.PLAYING self._powered = True # forward this command to each child player # TODO: Only start playing on powered players ? @@ -245,11 +246,11 @@ class GroupPlayer(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 = self.mass.add_job(self.queue_stream_task()) + self.stream_task = create_task(self.queue_stream_task()) async def cmd_stop(self) -> None: """Send STOP command to player.""" - self._state = PlaybackState.Stopped + self._state = PlayerState.IDLE if self.stream_task: # cancel existing stream task if any self.stream_task.cancel() @@ -267,14 +268,14 @@ class GroupPlayer(Player): async def cmd_play(self) -> None: """Send PLAY command to player.""" - if not self.state == PlaybackState.Paused: + 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 = PlaybackState.Playing + self._state = PlayerState.PLAYING self.update_state() async def cmd_pause(self): @@ -284,7 +285,7 @@ class GroupPlayer(Player): child_player = self.mass.players.get_player(child_player_id) if child_player: await child_player.cmd_pause() - self._state = PlaybackState.Paused + self._state = PlayerState.PAUSED self.update_state() async def cmd_power_on(self) -> None: @@ -361,7 +362,7 @@ class GroupPlayer(Player): LOGGER.debug( "start queue stream with %s connected clients", len(self.connected_clients) ) - self.sync_task = asyncio.create_task(self.__synchronize_players()) + self.sync_task = create_task(self.__synchronize_players()) async for audio_chunk in self.mass.streams.queue_stream_flac(self.player_id): @@ -373,7 +374,7 @@ class GroupPlayer(Player): # send the audio chunk to all connected players tasks = [] for _queue in self.connected_clients.values(): - tasks.append(self.mass.add_job(_queue.put(audio_chunk))) + tasks.append(create_task(_queue.put(audio_chunk))) # wait for clients to consume the data await asyncio.wait(tasks) @@ -397,7 +398,7 @@ class GroupPlayer(Player): ) # wait until master is playing - while master_player.state != PlaybackState.Playing: + while master_player.state != PlayerState.PLAYING: await asyncio.sleep(0.1) await asyncio.sleep(0.5) @@ -417,7 +418,7 @@ class GroupPlayer(Player): if ( not child_player - or child_player.state != PlaybackState.Playing + or child_player.state != PlayerState.PLAYING or child_player.elapsed_milliseconds is None ): continue @@ -451,7 +452,7 @@ class GroupPlayer(Player): if avg_lag > 20: sleep_time = avg_lag - 20 await asyncio.sleep(sleep_time / 1000) - asyncio.create_task(master_player.cmd_play()) + 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) diff --git a/music_assistant/web/__init__.py b/music_assistant/web/__init__.py old mode 100644 new mode 100755 index e6e244e6..006bdb55 --- a/music_assistant/web/__init__.py +++ b/music_assistant/web/__init__.py @@ -1 +1,269 @@ -"""Webserver and API handlers/logic.""" +""" +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 API connect address for this Music Assistant instance.""" + return f"ws://{self.hostname}:{self.port}/ws" + + @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) diff --git a/music_assistant/web/api.py b/music_assistant/web/api.py new file mode 100644 index 00000000..92196a7c --- /dev/null +++ b/music_assistant/web/api.py @@ -0,0 +1,240 @@ +"""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.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("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.""" + 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() diff --git a/music_assistant/web/server.py b/music_assistant/web/server.py deleted file mode 100755 index ef02fa3b..00000000 --- a/music_assistant/web/server.py +++ /dev/null @@ -1,398 +0,0 @@ -""" -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 asyncio -import datetime -import logging -import os -import uuid -from base64 import b64encode -from typing import Any, Awaitable, Optional, Union - -import aiohttp_cors -import jwt -import ujson -from aiohttp import WSMsgType, web -from aiohttp.web import WebSocketResponse -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 import repath -from music_assistant.helpers.encryption import decrypt_string -from music_assistant.helpers.images import get_image_url, 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 api_route, json_serializer, parse_arguments -from music_assistant.models.media_types import ItemMapping, MediaItem - -from .json_rpc import json_rpc_endpoint -from .streams import routes as stream_routes - -LOGGER = logging.getLogger("webserver") - - -class WebServer: - """Webserver and json/websocket api.""" - - def __init__(self, mass: MusicAssistant, port: int): - """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 = {} - - async def setup(self): - """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["clients"] = [] - # add all routes - self.app.add_routes(stream_routes) - self.app.router.add_route("*", "/jsonrpc.js", json_rpc_endpoint) - self.app.router.add_get("/ws", self._websocket_handler) - - # register all methods decorated as api_route - for cls in [ - self, - self.mass.music, - self.mass.players, - self.mass.config, - self.mass.library, - ]: - self.register_api_routes(cls) - - # 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)) - # 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) - else: - self.app.router.add_get("/", self.info) - - 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() - LOGGER.info("Started Music Assistant server on port %s", self.port) - self.mass.add_event_listener(self._handle_mass_events) - - async def stop(self): - """Stop the webserver.""" - for ws_client in self.app["clients"]: - await ws_client.close(message=b"server shutdown") - - def register_api_route(self, cmd: str, func: Awaitable): - """Register a command(handler) to the websocket api.""" - pattern = repath.path_to_pattern(cmd) - self.api_routes[pattern] = func - - def register_api_routes(self, cls: Any): - """Register all methods of a class (instance) that are decorated with api_route.""" - for item in dir(cls): - func = getattr(cls, item) - if not hasattr(func, "ws_cmd_path"): - continue - # method is decorated with our api decorator - self.register_api_route(func.ws_cmd_path, func) - - @property - def hostname(self): - """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): - """Return the local IP(v4) address for this Music Assistant instance.""" - return self._ip_address - - @property - def port(self): - """Return the port for this Music Assistant instance.""" - return self._port - - @property - def stream_url(self): - """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): - """Return the API connect address for this Music Assistant instance.""" - return f"ws://{self.hostname}:{self.port}/ws" - - @property - def server_id(self): - """Return the device ID for this Music Assistant Server.""" - return self.mass.config.stored_config["server_id"] - - @property - def discovery_info(self): - """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): - """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) - - @api_route("info", False) - async def info(self, request: web.Request = None): - """Return discovery info on index page.""" - if request: - return web.json_response(self.discovery_info) - return self.discovery_info - - @api_route("revoke_token") - async def revoke_token(self, client_id: str): - """Revoke token for client.""" - return self.mass.config.security.revoke_app_token(client_id) - - @api_route("get_token", False) - async def get_token(self, username: str, password: str, app_id: str = "") -> dict: - """ - 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. - """ - 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"] = ( - datetime.datetime.utcnow() + datetime.timedelta(days=365 * 10) - ).timestamp() - else: - token_info["exp"] = ( - datetime.datetime.utcnow() + datetime.timedelta(hours=8) - ).timestamp() - 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 token_info - raise AuthenticationError("Invalid credentials") - - @api_route("setup", False) - async def create_user_setup(self, username: str, password: str): - """Handle first-time server setup through onboarding wizard.""" - 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 True - - @api_route("images/thumb") - async def get_image_thumb( - self, - size: int, - url: Optional[str] = "", - item: Union[None, ItemMapping, MediaItem] = None, - ): - """Get (resized) thumb image for given URL or media item as base64 encoded string.""" - if not url and item: - url = await get_image_url( - self.mass, item.item_id, item.provider, item.media_type - ) - if url: - img_file = await get_thumb_file(self.mass, url, size) - if img_file: - with open(img_file, "rb") as _file: - icon_data = _file.read() - icon_data = b64encode(icon_data) - return "data:image/png;base64," + icon_data.decode() - raise KeyError("Invalid item or url") - - @api_route("images/provider-icons/:provider_id?") - async def get_provider_icon(self, provider_id: Optional[str]): - """Get Provider icon as base64 encoded string.""" - if not provider_id: - return { - prov.id: await self.get_provider_icon(prov.id) - for prov in self.mass.get_providers(include_unavailable=True) - } - 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): - with open(icon_path, "rb") as _file: - icon_data = _file.read() - icon_data = b64encode(icon_data) - return "data:image/png;base64," + icon_data.decode() - raise KeyError("Invalid provider: %s" % provider_id) - - async def _websocket_handler(self, request: web.Request): - """Handle websocket client.""" - - ws_client = WebSocketResponse() - ws_client.authenticated = False - await ws_client.prepare(request) - request.app["clients"].append(ws_client) - - # handle incoming messages - async for msg in ws_client: - try: - if msg.type == WSMsgType.error: - LOGGER.warning( - "ws connection closed with exception %s", ws_client.exception() - ) - if msg.type != WSMsgType.text: - continue - if msg.data == "close": - await ws_client.close() - break - # regular message - json_msg = msg.json(loads=ujson.loads) - if "command" in json_msg and "data" in json_msg: - # handle command - await self._handle_command( - ws_client, - json_msg["command"], - json_msg["data"], - json_msg.get("id"), - ) - elif "event" in json_msg: - # handle event - await self._handle_event( - ws_client, json_msg["event"], json_msg.get("data") - ) - except AuthenticationError as exc: # pylint:disable=broad-except - # disconnect client on auth errors - await self._send_json(ws_client, error=str(exc), **json_msg) - await ws_client.close(message=str(exc).encode()) - except Exception as exc: # pylint:disable=broad-except - # log the error only - await self._send_json(ws_client, error=str(exc), **json_msg) - LOGGER.error("Error with WS client", exc_info=exc) - - # websocket disconnected - request.app["clients"].remove(ws_client) - LOGGER.debug("websocket connection closed: %s", request.remote) - - return ws_client - - async def _handle_command( - self, - ws_client: WebSocketResponse, - command: str, - data: Optional[dict], - msg_id: Any = None, - ): - """Handle websocket command.""" - res = None - if command == "auth": - return await self._handle_auth(ws_client, data) - # work out handler for the given path/command - for key in self.api_routes: - match = repath.match_pattern(key, command) - if match: - params = match.groupdict() - handler = self.api_routes[key] - # check authentication - if ( - getattr(handler, "ws_require_auth", True) - and not ws_client.authenticated - ): - raise AuthenticationError("Not authenticated") - if not data: - data = {} - params = parse_arguments(handler, {**params, **data}) - res = handler(**params) - if asyncio.iscoroutine(res): - res = await res - # return result of command to client - return await self._send_json( - ws_client, id=msg_id, result=command, data=res - ) - raise KeyError("Unknown command") - - async def _handle_event(self, ws_client: WebSocketResponse, event: str, data: Any): - """Handle event message from ws client.""" - LOGGER.info("received event %s", event) - if ws_client.authenticated: - self.mass.signal_event(event, data) - - async def _handle_auth(self, ws_client: WebSocketResponse, token: str): - """Handle authentication with JWT token.""" - token_info = jwt.decode(token, self.mass.web.jwt_key, algorithms=["HS256"]) - if self.mass.config.security.is_token_revoked(token_info): - raise AuthenticationError("Token is revoked") - ws_client.authenticated = True - self.mass.config.security.set_last_login(token_info["client_id"]) - # TODO: store token/app_id on ws_client obj and periodiclaly check if token is expired or revoked - await self._send_json(ws_client, result="auth", data=token_info) - - async def _send_json(self, ws_client: WebSocketResponse, **kwargs): - """Send message (back) to websocket client.""" - await ws_client.send_str(json_serializer(kwargs)) - - async def _handle_mass_events(self, event: str, event_data: Any): - """Broadcast events to connected clients.""" - for ws_client in self.app["clients"]: - if not ws_client.authenticated: - continue - try: - await self._send_json(ws_client, event=event, data=event_data) - except ConnectionResetError: - # client is already disconnected - self.app["clients"].remove(ws_client) - except Exception as exc: # pylint: disable=broad-except - # log errors and continue sending to all other clients - LOGGER.debug("Error while sending message to api client", exc_info=exc) - - -class AuthenticationError(Exception): - """Custom Exception for all authentication errors.""" diff --git a/music_assistant/web/stream.py b/music_assistant/web/stream.py new file mode 100644 index 00000000..963eeac5 --- /dev/null +++ b/music_assistant/web/stream.py @@ -0,0 +1,433 @@ +""" +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_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, StreamType + +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") + LOGGER.info("Start Queue Stream for player %s ", player_queue.player.name) + + # prepare request + resp = StreamResponse( + status=200, reason="OK", headers={"Content-Type": "audio/flac"} + ) + await resp.prepare(request) + + player_conf = player_queue.player.config + sample_rate = min(player_conf.get(CONF_MAX_SAMPLE_RATE, 96000), 96000) + + args = [ + "sox", + "-t", + "s32", + "-c", + "2", + "-r", + str(sample_rate), + "-", + "-t", + "flac", + "-", + ] + async with AsyncProcess(args, enable_write=True) as sox_proc: + + # feed stdin with pcm samples + async def fill_buffer(): + """Feed audio data into sox stdin for processing.""" + async for audio_chunk in get_queue_stream( + mass, player_queue, sample_rate, 32 + ): + 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 and not request.query.get("alert"): + # 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): + 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.""" + input_format = streamdetails.content_type.value + stream_path = streamdetails.path + stream_type = StreamType(streamdetails.type) + if output_format is None: + output_format = ContentType.FLAC + + # collect all args for sox/ffmpeg + if output_format in [ + ContentType.S24, + ContentType.S32, + ContentType.S64, + ]: + output_args = ["-t", output_format.value, "-c", "2", "-"] + elif output_format == ContentType.FLAC: + output_args = ["-t", output_format.value] + ["-C", "0", "-"] + else: + output_args = ["-t", output_format.value, "-"] + + # stream from URL or file + if stream_type in [StreamType.URL, StreamType.FILE]: + # input_args = ["sox", "-t", input_format, stream_path] + input_args = ["sox", stream_path] + # stream from executable + else: + input_args = [stream_path, "|", "sox", "-t", input_format, "-"] + + filter_args = [] + if streamdetails.gain_correct: + filter_args += ["vol", str(streamdetails.gain_correct), "dB"] + if resample: + filter_args += ["rate", "-v", str(resample)] + + if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]: + # use ffmpeg for processing radio streams + args = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-i", + stream_path, + "-filter:a", + "volume=%sdB" % streamdetails.gain_correct, + "-f", + "flac", + "-", + ] + else: + # regular sox processing + args = input_args + output_args + filter_args + + # signal start of stream event + mass.eventbus.signal(EVENT_STREAM_STARTED, streamdetails) + LOGGER.debug( + "start media stream for: %s/%s (%s)", + streamdetails.provider, + streamdetails.item_id, + streamdetails.type, + ) + + async with AsyncProcess(args) as sox_proc: + + # 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_queue_stream( + mass: MusicAssistant, player_queue: PlayerQueue, sample_rate=96000, bit_depth=32 +) -> 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 + pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)] + sample_size = int(sample_rate * (bit_depth / 8) * 2) # 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, + ContentType.S32, + 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 diff --git a/music_assistant/web/streams.py b/music_assistant/web/streams.py deleted file mode 100644 index 76e6cb26..00000000 --- a/music_assistant/web/streams.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Audio streaming endpoints.""" - -from aiohttp.web import Request, Response, RouteTableDef, StreamResponse -from music_assistant.helpers.web import require_local_subnet -from music_assistant.models.media_types import MediaType - -routes = RouteTableDef() - - -@routes.get("/stream/media/{media_type}/{item_id}") -async def stream_media(request: Request): - """Stream a single audio track.""" - media_type = MediaType(request.match_info["media_type"]) - if media_type not in [MediaType.Track, MediaType.Radio]: - return Response(status=404, reason="Media item is not playable!") - item_id = request.match_info["item_id"] - provider = request.rel_url.query.get("provider", "database") - media_item = await request.app["mass"].music.get_item(item_id, provider, media_type) - streamdetails = await request.app["mass"].music.get_stream_details(media_item) - - # prepare request - content_type = streamdetails.content_type.value - resp = StreamResponse( - status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"} - ) - await resp.prepare(request) - - # stream track - async for audio_chunk in request.app["mass"].streams.get_media_stream( - streamdetails - ): - await resp.write(audio_chunk) - return resp - - -@routes.get("/stream/queue/{player_id}") -@require_local_subnet -async def stream_queue(request: Request): - """Stream a player's queue.""" - player_id = request.match_info["player_id"] - if not request.app["mass"].players.get_player_queue(player_id): - return Response(text="invalid queue", status=404) - - # prepare request - resp = StreamResponse( - status=200, reason="OK", headers={"Content-Type": "audio/flac"} - ) - await resp.prepare(request) - - # stream queue - async for audio_chunk in request.app["mass"].streams.queue_stream_flac(player_id): - await resp.write(audio_chunk) - return resp - - -@routes.get("/stream/queue/{player_id}/{queue_item_id}") -@require_local_subnet -async def stream_queue_item(request: Request): - """Stream a single queue item.""" - player_id = request.match_info["player_id"] - queue_item_id = request.match_info["queue_item_id"] - - # prepare request - resp = StreamResponse( - status=200, reason="OK", headers={"Content-Type": "audio/flac"} - ) - await resp.prepare(request) - - async for audio_chunk in request.app["mass"].streams.stream_queue_item( - player_id, queue_item_id - ): - await resp.write(audio_chunk) - 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