"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,
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"
EVENT_TRACK_ADDED = "track added"
EVENT_PLAYLIST_ADDED = "playlist added"
EVENT_RADIO_ADDED = "radio added"
+EVENT_TASK_UPDATED = "task updated"
# player attributes
ATTR_PLAYER_ID = "player_id"
--- /dev/null
+"""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)
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")
_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(
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):
"""
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
await db_conn.execute(sql_query, (cache_id,))
# compact db
await db_conn.commit()
- LOGGER.debug("Auto cleanup done")
@staticmethod
def _get_checksum(stringinput):
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
if cachedata is not None:
return cachedata
result = await func(*args, **kwargs)
- asyncio.create_task(
+ create_task(
method_class.cache.set(
cache_str,
result,
--- /dev/null
+"""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()
--- /dev/null
+"""Custom errors and exceptions."""
+
+
+class AuthenticationError(Exception):
+ """Custom Exception for all authentication errors."""
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
--- /dev/null
+"""Special queue-like to process items in different states."""
+import asyncio
+from collections import deque
+from typing import Any, List, Type
+
+
+class MultiStateQueue:
+ """Special queue-like to process items in different states."""
+
+ QUEUE_ITEM_TYPE: Type = Any
+
+ def __init__(self, max_finished_items: int = 50) -> None:
+ """Initialize class."""
+ self._pending_items = asyncio.Queue()
+ self._progress_items = deque()
+ self._finished_items = deque(maxlen=max_finished_items)
+
+ @property
+ def pending_items(self) -> List[QUEUE_ITEM_TYPE]:
+ """Return all pending items."""
+ # pylint: disable=protected-access
+ return list(self._pending_items._queue)
+
+ @property
+ def progress_items(self) -> List[QUEUE_ITEM_TYPE]:
+ """Return all in-progress items."""
+ return list(self._progress_items)
+
+ @property
+ def finished_items(self) -> List[QUEUE_ITEM_TYPE]:
+ """Return all finished items."""
+ return list(self._finished_items)
+
+ @property
+ def all_items(self) -> List[QUEUE_ITEM_TYPE]:
+ """Return all items."""
+ return list(self.pending_items + self.progress_items + self.finished_items)
+
+ def put_nowait(self, item: QUEUE_ITEM_TYPE) -> None:
+ """Put item in the queue to progress."""
+ if item in self._finished_items:
+ self._finished_items.remove(item)
+ return self._pending_items.put_nowait(item)
+
+ async def put(self, item: QUEUE_ITEM_TYPE) -> None:
+ """Put item on the queue to progress."""
+ if item in self._finished_items:
+ self._finished_items.remove(item)
+ return await self._pending_items.put(item)
+
+ async def get_nowait(self) -> QUEUE_ITEM_TYPE:
+ """Get next item in Queue, raises QueueEmpty if no items in Queue."""
+ next_item = self._pending_items.get_nowait()
+ self._progress_items.append(next_item)
+ return next_item
+
+ async def get(self) -> QUEUE_ITEM_TYPE:
+ """Get next item in Queue, waits until item is available."""
+ next_item = await self._pending_items.get()
+ self._progress_items.append(next_item)
+ return next_item
+
+ def mark_finished(self, item: QUEUE_ITEM_TYPE) -> None:
+ """Mark item as finished."""
+ self._progress_items.remove(item)
+ self._finished_items.append(item)
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:
"""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."""
+++ /dev/null
-"""
-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)
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"
StreamDetails = "StreamDetails"
Player = "Player"
ConfigSubItem = "ConfigSubItem"
+ MediaType = "MediaType"
+ StreamType = "StreamType"
QueueItems = Set[QueueItem]
"""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 = {}
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."""
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."""
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:
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()
"""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):
"""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):
)
-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)
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
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
"""All classes and helpers for the Configuration."""
import copy
-import datetime
import json
import logging
import os
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
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 = {}
"""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 [
]
}
- @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:
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)
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):
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
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
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 (
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
"""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
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:
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)
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=?;",
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)
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(
),
)
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()
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
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)
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(
),
)
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=?;"
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=?;"
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)
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=?;",
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)
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
),
)
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()
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)
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
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)
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(
)
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
),
)
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()
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)
"""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
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)
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)
),
)
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()
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))
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(
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:
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))
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:
--- /dev/null
+"""Logic to process events throughout the application."""
+
+
+import logging
+from typing import Any, Awaitable, Callable, Tuple, Union
+
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import callback, create_task
+
+LOGGER = logging.getLogger("eventbus")
+
+
+class EventBus:
+ """Global EventBus handling listening for and forwarding of events."""
+
+ def __init__(self, mass: MusicAssistant):
+ """Initialize EventBus instance."""
+ self.mass = mass
+ self._listeners = []
+
+ @callback
+ def signal(self, event_msg: str, event_details: Any = None) -> None:
+ """
+ Signal (systemwide) event.
+
+ :param event_msg: the eventmessage to signal
+ :param event_details: optional details to send with the event.
+ """
+ if 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
"""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,
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 ################
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:
# 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
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.
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)
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)
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)
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)
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)
)
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)
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)
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:
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
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,
Track,
)
from music_assistant.models.provider import MusicProvider, ProviderType
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
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."""
################ 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:
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:
)
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:
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:
)
return album
- @api_route("tracks/:provider_id/:item_id")
+ @api_route("tracks/{provider_id}/{item_id}")
async def get_track(
self,
item_id: str,
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:
)
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:
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:
)
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:
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:
)
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
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)
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
]
)
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)
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
]
)
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
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,
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(
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(
item_id,
)
- @api_route("search/:provider_id")
+ @api_route("search/{provider_id}")
async def search_provider(
self,
search_query: str,
# 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 ################
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:
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:
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):
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(
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):
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%
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:
# 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):
# 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
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:
import asyncio
import logging
+import pathlib
from typing import Dict, List, Optional, Set, Tuple, Union
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
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."""
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
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)
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
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:
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:
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."""
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.."""
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
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:
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.
: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):
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(
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.
: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.
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.
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.
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.
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.
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.
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.
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.
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]
# 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.
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.
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 + (
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.
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.
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.
# 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.
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.
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
):
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)
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.
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
+++ /dev/null
-"""
-StreamManager: handles all audio streaming to players.
-
-Either by sending tracks one by one or send one continuous stream
-of music with crossfade/gapless support (queue stream).
-
-All audio is processed by 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
--- /dev/null
+"""Logic to process tasks on the event loop."""
+
+import asyncio
+import logging
+from asyncio.futures import Future
+from enum import IntEnum
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
+from uuid import uuid4
+
+from music_assistant.constants import EVENT_TASK_UPDATED
+from music_assistant.helpers.datetime import now
+from music_assistant.helpers.muli_state_queue import MultiStateQueue
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import create_task
+from music_assistant.helpers.web import api_route
+
+LOGGER = logging.getLogger("task_manager")
+
+MAX_SIMULTANEOUS_TASKS = 2
+
+
+class TaskStatus(IntEnum):
+ """Enum for Task status."""
+
+ PENDING = 0
+ PROGRESS = 1
+ FINISHED = 2
+ ERROR = 3
+ CANCELLED = 4
+
+
+class TaskInfo:
+ """Model for a background task."""
+
+ def __init__(
+ self,
+ name: str,
+ target: Union[Callable, Awaitable],
+ args: Any,
+ kwargs: Any,
+ periodic: Optional[int] = None,
+ ) -> None:
+ """Initialize instance."""
+ self.name = name
+ self.target = target
+ self.args = args
+ self.kwargs = kwargs
+ self.periodic = periodic
+ self.status = TaskStatus.PENDING
+ self.error_details = ""
+ self.updated_at = now()
+ self.execution_time = 0 # time in seconds it took to process
+ self.id = str(uuid4())
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Return serializable dict."""
+ return {
+ "id": self.id,
+ "name": self.name,
+ "status": self.status,
+ "error_details": self.error_details,
+ "updated_at": self.updated_at.isoformat(),
+ "execution_time": self.execution_time,
+ }
+
+ @property
+ def dupe_hash(self):
+ """Return simple hash to identify duplicate tasks."""
+ return f"{self.name}.{self.target.__qualname__}.{self.args}"
+
+
+class TaskManager:
+ """Task manager that executes tasks from a queue in the background."""
+
+ def __init__(self, mass: MusicAssistant):
+ """Initialize TaskManager instance."""
+ self.mass = mass
+ self._queue = None
+
+ async def setup(self):
+ """Async initialize of module."""
+ # queue can only be initialized when the loop is running
+ MultiStateQueue.QUEUE_ITEM_TYPE = TaskInfo
+ self._queue = MultiStateQueue()
+ create_task(self.__process_tasks())
+
+ def add(
+ self,
+ name: str,
+ target: Union[Callable, Awaitable],
+ *args: Any,
+ periodic: Optional[int] = None,
+ prevent_duplicate: bool = True,
+ **kwargs: Any,
+ ) -> TaskInfo:
+ """Add a job/task to the task manager.
+
+ name: A name to identify this task in the task queue.
+ target: target to call (coroutine function or callable).
+ periodic: [optional] run this task every X seconds.
+ prevent_duplicate: [default true] prevent same task running at same time
+ args: [optional] parameters for method to call.
+ kwargs: [optional] parameters for method to call.
+ """
+
+ if self.mass.exit:
+ return
+ if self._queue is None:
+ raise RuntimeError("Not yet initialized")
+
+ if periodic and asyncio.iscoroutine(target):
+ raise RuntimeError(
+ "Provide a coroutine function and not a coroutine itself"
+ )
+
+ task_info = TaskInfo(
+ name, periodic=periodic, target=target, args=args, kwargs=kwargs
+ )
+ if prevent_duplicate:
+ for task in self._queue.progress_items + self._queue.pending_items:
+ if task.dupe_hash == task_info.dupe_hash:
+ LOGGER.debug(
+ "Ignoring task %s as it is already running....", task_info.name
+ )
+ return task
+ self._add_task(task_info)
+ return task_info
+
+ @api_route("tasks")
+ def get_all_tasks(self) -> List[TaskInfo]:
+ """Return all tasks in the TaskManager."""
+ return self._queue.all_items
+
+ def _add_task(self, task_info: TaskInfo) -> None:
+ """Handle adding a task to the task queue."""
+ LOGGER.debug("Adding task [%s] to Task Queue...", task_info.name)
+ self._queue.put_nowait(task_info)
+ self.mass.eventbus.signal(EVENT_TASK_UPDATED, task_info)
+
+ def __task_done_callback(self, future: Future):
+ task_info: TaskInfo = future.task_info
+ self._queue.mark_finished(task_info)
+ prev_timestamp = task_info.updated_at.timestamp()
+ task_info.updated_at = now()
+ task_info.execution_time = round(
+ task_info.updated_at.timestamp() - prev_timestamp, 2
+ )
+ if future.cancelled():
+ future.task_info.status = TaskStatus.CANCELLED
+ elif future.exception():
+ exc = future.exception()
+ task_info.status = TaskStatus.ERROR
+ task_info.error_details = repr(exc)
+ LOGGER.debug(
+ "Error while running task [%s]",
+ task_info.name,
+ exc_info=exc,
+ )
+ else:
+ task_info.status = TaskStatus.FINISHED
+ LOGGER.debug(
+ "Task finished: [%s] in %s seconds",
+ task_info.name,
+ task_info.execution_time,
+ )
+ self.mass.eventbus.signal(EVENT_TASK_UPDATED, task_info)
+ # reschedule if the task is periodic
+ if task_info.periodic:
+ self.mass.loop.call_later(task_info.periodic, self._add_task, task_info)
+
+ async def __process_tasks(self):
+ """Process handling of tasks in the queue."""
+ while not self.mass.exit:
+ while len(self._queue.progress_items) >= MAX_SIMULTANEOUS_TASKS:
+ await asyncio.sleep(1)
+ next_task = await self._queue.get()
+ next_task.status = TaskStatus.PROGRESS
+ next_task.updated_at = now()
+ task = create_task(next_task.target, *next_task.args, **next_task.kwargs)
+ setattr(task, "task_info", next_task)
+ task.add_done_callback(self.__task_done_callback)
+ self.mass.eventbus.signal(EVENT_TASK_UPDATED, next_task)
"""Main Music Assistant class."""
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,
)
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")
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)
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)
"""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
)
# run migrations if needed
await check_migrations(self)
+ await self._tasks.setup()
await self._config.setup()
await self._cache.setup()
await self._music.setup()
await self.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():
"""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."""
"""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."""
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
# 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:
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:
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."""
"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."""
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):
@dataclass
class MediaItem(DataClassDictMixin):
- """Representation of a media item."""
+ """Base representation of a media item."""
item_id: str
provider: str
metadata: Dict[str, Any] = field(default_factory=dict)
provider_ids: Set[MediaItemProviderId] = field(default_factory=set)
in_library: bool = False
- media_type: MediaType = MediaType.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":
class Artist(MediaItem):
"""Model for an artist."""
- media_type: MediaType = MediaType.Artist
+ media_type: MediaType = MediaType.ARTIST
musicbrainz_id: str = ""
def __hash__(self):
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):
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):
class FullAlbum(Album):
"""Model for an album with full details."""
- artist: Artist = None
+ artist: Optional[Artist] = None
def __hash__(self):
"""Return custom hash."""
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
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."""
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
class Radio(MediaItem):
"""Model for a radio station."""
- media_type: MediaType = MediaType.Radio
+ media_type: MediaType = MediaType.RADIO
duration: int = 86400
def __hash__(self):
from abc import abstractmethod
from dataclasses import dataclass, field
-from datetime import datetime
from enum import Enum, IntEnum
from typing import Any, Optional, Set
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)
# 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
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
@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:
@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:
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:
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:
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:
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
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()
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
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:
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."""
"""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."""
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:
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
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:
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:
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
"""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)
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:
"""
"""
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
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:
"""
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
)
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."""
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)
)
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)
)
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."""
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()
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
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."""
"""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,
"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."""
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(
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:
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:
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):
MP3 = "mp3"
AAC = "aac"
MPEG = "mpeg"
+ S24 = "s24"
+ S32 = "s32"
+ S64 = "s64"
@dataclass
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}"
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"
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"
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):
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)
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."""
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:
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
: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."""
"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:
"""
: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)
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
self._listener, self.mass.zeroconf
)
- self.mass.add_job(start_discovery)
+ create_task(start_discovery)
return True
async def on_stop(self):
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."""
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):
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 (
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:
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:
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 ==========
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")
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",
"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,
"""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),
},
}
)
return
async with self._throttler:
- self.mass.add_job(func, *args, **kwargs)
+ create_task(func, *args, **kwargs)
@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."""
@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."""
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
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:
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}
)
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(
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
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)
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
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."""
"""
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)
"""
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)
"""
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)
"""
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)
"""
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)
"""
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)
"""
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)
"""
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)
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)
"""
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:
# 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)
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):
)
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):
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."""
# 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
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):
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:
"""
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}
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}
)
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
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"):
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
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."""
@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):
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."""
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):
"""
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]):
"""
: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
"""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)
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,
)
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
}
STATE_PLAYING = "playing"
-STATE_STOPPED = "stopped"
+STATE_IDLE = "idle"
STATE_PAUSED = "paused"
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:
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."""
):
"""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<host>[^:/ ]+).?(?P<port>[0-9]*).*"
regex_result = re.search(regex, uri)
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):
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."""
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,
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)."""
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):
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)."""
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)
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."""
elapsed_seconds,
voltage,
elapsed_milliseconds,
- server_timestamp,
+ timestamp,
error_code,
) = struct.unpack("!BBBLLLLHLLLLHLLH", data)
if self.state == STATE_PLAYING:
"""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."""
@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."""
content_type=ContentType(stream["media_type"]),
sample_rate=44100,
bit_depth=16,
+ media_type=MediaType.RADIO,
details=stream,
)
return None
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"
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):
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
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
@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:
"""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 ?
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()
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):
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:
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):
# 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)
)
# 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)
if (
not child_player
- or child_player.state != PlaybackState.Playing
+ or child_player.state != PlayerState.PLAYING
or child_player.elapsed_milliseconds is None
):
continue
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)
-"""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)
--- /dev/null
+"""Custom API implementation using websockets."""
+
+import asyncio
+import logging
+import os
+from base64 import b64encode
+from typing import Any, Dict, Optional, Union
+
+import aiofiles
+import jwt
+import ujson
+from aiohttp import WSMsgType, web
+from aiohttp.http_websocket import WSMessage
+from music_assistant.helpers.errors import AuthenticationError
+from music_assistant.helpers.images import get_image_url, get_thumb_file
+from music_assistant.helpers.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()
+++ /dev/null
-"""
-The web module handles serving the custom websocket api on a custom port (default is 8095).
-
-All MusicAssistant clients communicate locally with this websockets api.
-The server is intended to be used locally only and not exposed outside,
-so it is HTTP only. Secure remote connections will be offered by a remote connect broker.
-"""
-
-import 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."""
--- /dev/null
+"""
+StreamManager: handles all audio streaming to players.
+
+Either by sending tracks one by one or send one continuous stream
+of music with crossfade/gapless support (queue stream).
+
+All audio is processed by SoX and/or ffmpeg, using various subprocess streams.
+"""
+
+import asyncio
+import logging
+from typing import AsyncGenerator, Optional, Tuple
+
+from aiohttp.web import Request, Response, RouteTableDef, StreamResponse
+from aiohttp.web_exceptions import HTTPNotFound
+from music_assistant.constants import (
+ CONF_MAX_SAMPLE_RATE,
+ EVENT_STREAM_ENDED,
+ EVENT_STREAM_STARTED,
+)
+from music_assistant.helpers.audio import (
+ analyze_audio,
+ crossfade_pcm_parts,
+ get_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
+++ /dev/null
-"""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