From 6eb7a6bfdcd1e774cedb7df28afc4cd26f515651 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 5 Jul 2023 00:34:05 +0200 Subject: [PATCH 1/8] refactor core controllers and stream engine part 1 --- music_assistant/common/helpers/util.py | 44 +- .../common/models/config_entries.py | 11 + music_assistant/common/models/media_items.py | 22 +- music_assistant/constants.py | 4 + music_assistant/server/controllers/cache.py | 3 +- music_assistant/server/controllers/config.py | 89 +- .../server/controllers/metadata.py | 7 +- music_assistant/server/controllers/music.py | 3 +- .../server/controllers/player_queues.py | 57 +- music_assistant/server/controllers/players.py | 5 +- music_assistant/server/controllers/streams.py | 902 ++++++++++++------ .../server/controllers/webserver.py | 405 ++++++-- music_assistant/server/helpers/audio.py | 33 +- music_assistant/server/helpers/auth.py | 6 +- music_assistant/server/helpers/didl_lite.py | 14 +- music_assistant/server/helpers/webserver.py | 118 +++ .../server/models/core_controller.py | 48 + .../server/models/player_provider.py | 22 +- .../server/providers/airplay/__init__.py | 25 +- .../server/providers/chromecast/__init__.py | 81 +- .../server/providers/deezer/__init__.py | 5 +- .../server/providers/dlna/__init__.py | 73 +- .../server/providers/dlna/helpers.py | 4 +- .../server/providers/filesystem_local/base.py | 9 +- .../server/providers/plex/__init__.py | 19 +- .../server/providers/qobuz/__init__.py | 9 +- .../server/providers/radiobrowser/__init__.py | 5 +- .../server/providers/slimproto/__init__.py | 93 +- .../server/providers/slimproto/cli.py | 12 +- .../server/providers/sonos/__init__.py | 81 +- .../server/providers/soundcloud/__init__.py | 4 + .../server/providers/spotify/__init__.py | 5 +- .../server/providers/tidal/__init__.py | 9 +- .../server/providers/tunein/__init__.py | 8 +- .../server/providers/ugp/__init__.py | 52 +- .../server/providers/url/__init__.py | 9 +- .../providers/websocket_api/__init__.py | 282 ------ .../providers/websocket_api/manifest.json | 14 - .../server/providers/ytmusic/__init__.py | 9 +- music_assistant/server/server.py | 39 +- 40 files changed, 1588 insertions(+), 1052 deletions(-) create mode 100644 music_assistant/server/helpers/webserver.py create mode 100644 music_assistant/server/models/core_controller.py delete mode 100644 music_assistant/server/providers/websocket_api/__init__.py delete mode 100644 music_assistant/server/providers/websocket_api/manifest.json diff --git a/music_assistant/common/helpers/util.py b/music_assistant/common/helpers/util.py index de082794b..e7ca291c9 100755 --- a/music_assistant/common/helpers/util.py +++ b/music_assistant/common/helpers/util.py @@ -129,33 +129,37 @@ def get_version_substitute(version_str: str): return version_str.strip() -def get_ip(): +async def get_ip(): """Get primary IP-address for this host.""" - # pylint: disable=broad-except,no-member - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - # doesn't even have to be reachable - sock.connect(("10.255.255.255", 1)) - _ip = sock.getsockname()[0] - except Exception: - _ip = "127.0.0.1" - finally: - sock.close() - return _ip - - -def is_port_in_use(port: int) -> bool: - """Check if port is in use.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock: + + def _get_ip(): + """Get primary IP-address for this host.""" + # pylint: disable=broad-except,no-member + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: - _sock.bind(("127.0.0.1", port)) - except OSError: - return True + # doesn't even have to be reachable + sock.connect(("10.255.255.255", 1)) + _ip = sock.getsockname()[0] + except Exception: + _ip = "127.0.0.1" + finally: + sock.close() + return _ip + + return await asyncio.to_thread(_get_ip) async def select_free_port(range_start: int, range_end: int) -> int: """Automatically find available port within range.""" + def is_port_in_use(port: int) -> bool: + """Check if port is in use.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock: + try: + _sock.bind(("127.0.0.1", port)) + except OSError: + return True + def _select_free_port(): for port in range(range_start, range_end): if not is_port_in_use(port): diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 5ce07c4e2..f53a8b436 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -265,6 +265,15 @@ class PlayerConfig(Config): default_name: str | None = None +@dataclass +class CoreConfig(Config): + """CoreController Configuration.""" + + module: str # name of the core module + friendly_name: str # friendly name of the core module + last_error: str | None = None + + CONF_ENTRY_LOG_LEVEL = ConfigEntry( key=CONF_LOG_LEVEL, type=ConfigEntryType.STRING, @@ -282,6 +291,7 @@ class PlayerConfig(Config): ) DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,) +DEFAULT_CORE_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,) # some reusable player config entries @@ -406,6 +416,7 @@ class PlayerConfig(Config): advanced=True, ) + CONF_ENTRY_GROUPED_POWER_ON = ConfigEntry( key=CONF_GROUPED_POWER_ON, type=ConfigEntryType.BOOLEAN, diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py index 55f1b1933..36446f553 100755 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -511,6 +511,22 @@ def media_from_dict(media_item: dict) -> MediaItemType: return MediaItem.from_dict(media_item) +@dataclass +class AudioFormat(DataClassDictMixin): + """Model for AudioFormat details.""" + + content_type: ContentType + sample_rate: int = 44100 + bit_depth: int = 16 + channels: int = 2 + output_format_str: str = "" + + def __post_init__(self): + """Execute actions after init.""" + if not self.output_format_str: + self.output_format_str = self.content_type.value + + @dataclass class StreamDetails(DataClassDictMixin): """Model for streamdetails.""" @@ -523,11 +539,9 @@ class StreamDetails(DataClassDictMixin): # mandatory fields provider: str item_id: str - content_type: ContentType + audio_format: AudioFormat media_type: MediaType = MediaType.TRACK - sample_rate: int = 44100 - bit_depth: int = 16 - channels: int = 2 + # stream_title: radio streams can optionally set this field stream_title: str | None = None # duration of the item to stream, copied from media_item if omitted diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 70ecb0d1b..eb74fa762 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -32,6 +32,7 @@ CONF_PORT: Final[str] = "port" CONF_PROVIDERS: Final[str] = "providers" CONF_PLAYERS: Final[str] = "players" +CONF_CORE: Final[str] = "core" CONF_PATH: Final[str] = "path" CONF_USERNAME: Final[str] = "username" CONF_PASSWORD: Final[str] = "password" @@ -48,6 +49,9 @@ CONF_OUTPUT_CODEC: Final[str] = "output_codec" CONF_GROUPED_POWER_ON: Final[str] = "grouped_power_on" CONF_CROSSFADE_DURATION: Final[str] = "crossfade_duration" +CONF_BIND_IP: Final[str] = "bind_ip" +CONF_BIND_PORT: Final[str] = "bind_port" +CONF_PUBLISH_IP: Final[str] = "publish_ip" # config default values DEFAULT_HOST: Final[str] = "0.0.0.0" diff --git a/music_assistant/server/controllers/cache.py b/music_assistant/server/controllers/cache.py index 14afdff6b..a3d885102 100644 --- a/music_assistant/server/controllers/cache.py +++ b/music_assistant/server/controllers/cache.py @@ -18,6 +18,7 @@ SCHEMA_VERSION, ) from music_assistant.server.helpers.database import DatabaseConnection +from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: from music_assistant.server import MusicAssistant @@ -25,7 +26,7 @@ LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache") -class CacheController: +class CacheController(CoreController): """Basic cache controller using both memory and database.""" database: DatabaseConnection | None = None diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index 34411034a..e2d47c381 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -16,21 +16,30 @@ from music_assistant.common.helpers.json import JSON_DECODE_EXCEPTIONS, json_dumps, json_loads from music_assistant.common.models import config_entries from music_assistant.common.models.config_entries import ( + DEFAULT_CORE_CONFIG_ENTRIES, DEFAULT_PLAYER_CONFIG_ENTRIES, DEFAULT_PROVIDER_CONFIG_ENTRIES, ConfigEntry, ConfigValueType, + CoreConfig, PlayerConfig, ProviderConfig, ) from music_assistant.common.models.enums import EventType, ProviderType from music_assistant.common.models.errors import InvalidDataError, PlayerUnavailableError -from music_assistant.constants import CONF_PLAYERS, CONF_PROVIDERS, CONF_SERVER_ID, ENCRYPT_SUFFIX +from music_assistant.constants import ( + CONF_CORE, + CONF_PLAYERS, + CONF_PROVIDERS, + CONF_SERVER_ID, + ENCRYPT_SUFFIX, +) from music_assistant.server.helpers.api import api_command from music_assistant.server.helpers.util import get_provider_module from music_assistant.server.models.player_provider import PlayerProvider if TYPE_CHECKING: + from music_assistant.server.models.core_controller import CoreController from music_assistant.server.server import MusicAssistant LOGGER = logging.getLogger(__name__) @@ -174,12 +183,12 @@ async def get_provider_config(self, instance_id: str) -> ProviderConfig: raise KeyError(f"No config found for provider id {instance_id}") @api_command("config/providers/get_value") - def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType: + async def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType: """Return single configentry value for a provider.""" cache_key = f"prov_conf_value_{instance_id}.{key}" if cached_value := self._value_cache.get(cache_key) is not None: return cached_value - conf = self.get_provider_config(instance_id) + conf = await self.get_provider_config(instance_id) val = ( conf.values[key].value if conf.values[key].value is not None @@ -234,7 +243,6 @@ async def save_provider_config( provider_domain: (mandatory) domain of the provider. values: the raw values for config entries that need to be stored/updated. instance_id: id of an existing provider instance (None for new instance setup). - action: [optional] action key called from config entries UI. """ if instance_id is not None: config = await self._update_provider_config(instance_id, values) @@ -440,6 +448,79 @@ async def create_default_provider_config(self, provider_domain: str) -> None: conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}" self.set(conf_key, default_config.to_raw()) + @api_command("config/core") + async def get_core_configs( + self, + ) -> list[CoreConfig]: + """Return all core controllers config options.""" + return [ + await self.get_core_config(core_controller) + for core_controller in ("streams", "webserver") + ] + + @api_command("config/core/get") + async def get_core_config(self, core_controller: str) -> CoreConfig: + """Return configuration for a single core controller.""" + raw_conf = self.get(f"{CONF_CORE}/{core_controller}", {}) + config_entries = await self.get_core_config_entries(core_controller) + return CoreConfig.parse(config_entries, raw_conf) + + @api_command("config/core/get_entries") + async def get_core_config_entries( + self, + core_controller: str, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, + ) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to configure a core controller. + + core_controller: name of the core controller + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + if values is None: + values = self.get(f"{CONF_CORE}/{core_controller}/values", {}) + controller: CoreController = getattr(self.mass, core_controller) + return ( + await controller.get_config_entries(action=action, values=values) + + DEFAULT_CORE_CONFIG_ENTRIES + ) + + @api_command("config/core/save") + async def save_core_config( + self, + core_controller: str, + values: dict[str, ConfigValueType], + ) -> CoreConfig: + """Save CoreController Config values.""" + config = await self.get_core_config(core_controller) + changed_keys = config.update(values) + # validate the new config + config.validate() + if not changed_keys: + # no changes + return config + # try to load the provider first to catch errors before we save it. + controller: CoreController = getattr(self.mass, core_controller) + await controller.reload() + # reload succeeded, save new config + config.last_error = None + conf_key = f"{CONF_CORE}/{core_controller}" + self.set(conf_key, config.to_raw()) + # return full config, just in case + return await self.get_core_config(core_controller) + + def get_raw_core_config_value( + self, core_module: str, key: str, default: ConfigValueType = None + ) -> ConfigValueType: + """ + Return (raw) single configentry value for a core controller. + + Note that this only returns the stored value without any validation or default. + """ + return self.get(f"{CONF_CORE}/{core_module}/{key}", default) + def save(self, immediate: bool = False) -> None: """Schedule save of data to disk.""" self._value_cache = {} diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index fa435bcc8..759f150a7 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -29,6 +29,7 @@ ) from music_assistant.constants import ROOT_LOGGER_NAME from music_assistant.server.helpers.images import create_collage, get_image_thumb +from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: from music_assistant.server import MusicAssistant @@ -37,7 +38,7 @@ LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.metadata") -class MetaDataController: +class MetaDataController(CoreController): """Several helpers to search and store metadata for mediaitems.""" def __init__(self, mass: MusicAssistant) -> None: @@ -49,7 +50,7 @@ def __init__(self, mass: MusicAssistant) -> None: async def setup(self) -> None: """Async initialize of module.""" - self.mass.webserver.register_route("/imageproxy", self._handle_imageproxy) + self.mass.streams.register_dynamic_route("/imageproxy", self._handle_imageproxy) async def close(self) -> None: """Handle logic on server stop.""" @@ -312,7 +313,7 @@ def get_image_url(self, image: MediaItemImage, size: int = 0) -> str: # return imageproxy url for images that need to be resolved # the original path is double encoded encoded_url = urllib.parse.quote(urllib.parse.quote(image.path)) - return f"{self.mass.webserver.base_url}/imageproxy?path={encoded_url}&provider={image.provider}&size={size}" # noqa: E501 + return f"{self.mass.streams.base_url}/imageproxy?path={encoded_url}&provider={image.provider}&size={size}" # noqa: E501 return image.path async def get_thumbnail( diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index 1f459af57..871c1ed3f 100755 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -29,6 +29,7 @@ ) from music_assistant.server.helpers.api import api_command from music_assistant.server.helpers.database import DatabaseConnection +from music_assistant.server.models.core_controller import CoreController from music_assistant.server.models.music_provider import MusicProvider from .media.albums import AlbumsController @@ -44,7 +45,7 @@ SYNC_INTERVAL = 3 * 3600 -class MusicController: +class MusicController(CoreController): """Several helpers around the musicproviders.""" database: DatabaseConnection | None = None diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 20377c416..7be63c624 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -486,16 +486,25 @@ async def play_index( queue.index_in_buffer = index # power on player if needed await self.mass.players.cmd_power(queue_id, True) - # execute the play_media command on the player(s) + # execute the play_media command on the player player_prov = self.mass.players.get_player_provider(queue_id) - flow_mode = await self.mass.config.get_player_config_value(queue.queue_id, CONF_FLOW_MODE) - queue.flow_mode = flow_mode - await player_prov.cmd_play_media( - queue_id, + # resolve stream url + queue.flow_mode = await self.mass.config.get_player_config_value( + queue.queue_id, CONF_FLOW_MODE + ) + url = await self.mass.streams.resolve_stream_url( + queue_id=queue_id, queue_item=queue_item, seek_position=seek_position, fade_in=fade_in, - flow_mode=flow_mode, + flow_mode=queue.flow_mode, + ) + await player_prov.cmd_play_url( + player_id=queue_id, + url=url, + # set queue_item to None if we're sending a flow mode url + # as the metadata is rather useless then + queue_item=None if queue.flow_mode else queue_item, ) # Interaction with player @@ -542,6 +551,7 @@ def on_player_update( queue.active = player.active_source == queue.queue_id if queue.active: queue.state = player.state + queue.flow_mode = player.current_url and "/flow/" in player.current_url # update current item from player report player_item_index = self.index_by_id(queue_id, player.current_item_id) if player_item_index is None: @@ -622,25 +632,22 @@ def on_player_remove(self, player_id: str) -> None: self._queues.pop(player_id, None) self._queue_items.pop(player_id, None) - async def player_ready_for_next_track( - self, queue_or_player_id: str, current_item_id: str - ) -> tuple[QueueItem, bool]: - """Call when a player is ready to load the next track into the buffer. + async def preload_next_url( + self, queue_id: str, current_item_id: str + ) -> tuple[str, QueueItem, bool]: + """Call when a player wants to load the next track/url into the buffer. - The result is a tuple of the next QueueItem to Play, + The result is a tuple of the next url + QueueItem to Play, and a bool if the player should crossfade (if supported). Raises QueueEmpty if there are no more tracks left. - - NOTE: The player(s) should resolve the stream URL for the QueueItem, - just like with the play_media call. """ - queue = self.get_active_queue(queue_or_player_id) - cur_index = self.index_by_id(queue.queue_id, current_item_id) - cur_item = self.get_item(queue.queue_id, cur_index) + queue = self.get(queue_id) + cur_index = self.index_by_id(queue_id, current_item_id) + cur_item = self.get_item(queue_id, cur_index) idx = 0 while True: - next_index = self.get_next_index(queue.queue_id, cur_index + idx) - next_item = self.get_item(queue.queue_id, next_index) + next_index = self.get_next_index(queue_id, cur_index + idx) + next_item = self.get_item(queue_id, next_index) if not cur_item or not next_item: raise QueueEmpty("No more tracks left in the queue.") try: @@ -667,7 +674,11 @@ async def player_ready_for_next_track( # disable crossfade if playing tracks from same album # TODO: make this a bit more intelligent. crossfade = False - return (next_item, crossfade) + url = await self.mass.streams.resolve_stream_url( + queue_id=queue_id, + queue_item=next_item, + ) + return (url, next_item, crossfade) # Main queue manipulation methods @@ -847,9 +858,9 @@ def __get_queue_stream_index( return queue_index, track_time def _get_player_item_index(self, queue_id: str, url: str) -> str | None: - """Parse QueueItem ID from Player's current url.""" - if url and self.mass.webserver.base_url in url and "/stream/" in url: + """Parse (start) QueueItem ID from Player's current url.""" + if url and self.mass.streams.base_url in url and queue_id in url: # try to extract the item id from the uri - current_item_id = url.rsplit("/")[-2] + current_item_id = url.rsplit("/")[-1].split(".")[0] return self.index_by_id(queue_id, current_item_id) return None diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index defd6a5e0..fdc114283 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -23,6 +23,7 @@ from music_assistant.common.models.player import Player from music_assistant.constants import CONF_HIDE_GROUP_CHILDS, CONF_PLAYERS, ROOT_LOGGER_NAME from music_assistant.server.helpers.api import api_command +from music_assistant.server.models.core_controller import CoreController from music_assistant.server.models.player_provider import PlayerProvider from .player_queues import PlayerQueuesController @@ -33,7 +34,7 @@ LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.players") -class PlayerController: +class PlayerController(CoreController): """Controller holding all logic to control registered players.""" def __init__(self, mass: MusicAssistant) -> None: @@ -527,7 +528,7 @@ def _get_active_source(self, player: Player) -> str: return group_player.player_id # guess source from player's current url if player.current_url and player.state in (PlayerState.PLAYING, PlayerState.PAUSED): - if self.mass.webserver.base_url in player.current_url: + if self.mass.streams.base_url in player.current_url: return player.player_id if ":" in player.current_url: # extract source from uri/url diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index eaf86f47a..0e0ecedac 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -1,26 +1,40 @@ -"""Controller to stream audio to players.""" +""" +Controller to stream audio to players. + +The streams controller hosts a basic, unprotected HTTP-only webserver +purely to stream audio packets to players and some control endpoints such as +the upnp callbacks and json rpc api for slimproto clients. +""" from __future__ import annotations import asyncio import logging import urllib.parse from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import shortuuid from aiohttp import web -from music_assistant.common.helpers.util import empty_queue -from music_assistant.common.models.enums import ContentType, PlayerState +from music_assistant.common.helpers.util import get_ip, select_free_port +from music_assistant.common.models.config_entries import ( + DEFAULT_CORE_CONFIG_ENTRIES, + ConfigEntry, + ConfigValueType, +) +from music_assistant.common.models.enums import ConfigEntryType, ContentType from music_assistant.common.models.errors import MediaNotFoundError, QueueEmpty +from music_assistant.common.models.media_items import AudioFormat +from music_assistant.common.models.player_queue import PlayerQueue from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import ( + CONF_BIND_IP, + CONF_BIND_PORT, CONF_EQ_BASS, CONF_EQ_MID, CONF_EQ_TREBLE, CONF_OUTPUT_CHANNELS, CONF_OUTPUT_CODEC, - ROOT_LOGGER_NAME, ) from music_assistant.server.helpers.audio import ( check_audio_support, @@ -30,66 +44,112 @@ get_stream_details, ) from music_assistant.server.helpers.process import AsyncProcess +from music_assistant.server.helpers.webserver import Webserver +from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: from music_assistant.common.models.player import Player - from music_assistant.server import MusicAssistant -LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.streams") +DEFAULT_STREAM_HEADERS = { + "transferMode.dlna.org": "Streaming", + "contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000", # noqa: E501 + "Cache-Control": "no-cache", + "Connection": "close", + "icy-name": "Music Assistant", + "icy-pub": "0", +} +FLOW_MAX_SAMPLE_RATE = 96000 +FLOW_MAX_BIT_DEPTH = 24 -class StreamJob: - """Representation of a (multisubscriber) Audio Queue (item)stream job/task. + +class MultiClientStreamJob: + """Representation of a (multiclient) Audio Queue stream job/task. The whole idea here is that in case of a player (sync)group, - all players receive the exact same PCM audio chunks from the source audio. - A StreamJob is tied to a queueitem, - meaning that streaming of each QueueItem will have its own StreamJob. - In case a QueueItem is restarted (e.g. when seeking), a new StreamJob will be created. + all client players receive the exact same (PCM) audio chunks from the source audio. + A StreamJob is tied to a Queue and streams the queue flow stream, + In case a stream is restarted (e.g. when seeking), a new MultiClientStreamJob will be created. """ def __init__( self, - queue_item: QueueItem, - pcm_sample_rate: int, - pcm_bit_depth: int, - audio_source: AsyncGenerator[bytes, None] | None = None, - flow_mode: bool = False, + stream_controller: StreamsController, + queue_id: str, + pcm_format: AudioFormat, + start_queue_item_id: str, + seek_position: int = 0, + fade_in: bool = False, ) -> None: - """Initialize MultiQueue instance.""" - self.queue_item = queue_item - self.audio_source = audio_source - # internally all audio within MA is raw PCM, hence the pcm details - self.pcm_sample_rate = pcm_sample_rate - self.pcm_bit_depth = pcm_bit_depth - self.pcm_sample_size = int(pcm_sample_rate * (pcm_bit_depth / 8) * 2) - self.stream_id = shortuuid.uuid() - self.expected_consumers: set[str] = set() - self.flow_mode = flow_mode - self.subscribers: dict[str, asyncio.Queue[bytes]] = {} + """Initialize MultiClientStreamJob instance.""" + self.stream_controller = stream_controller + self.queue_id = queue_id + self.queue = self.stream_controller.mass.players.queues.get(queue_id) + assert self.queue # just in case + self.pcm_format = pcm_format + self.start_queue_item_id = start_queue_item_id + self.seek_position = seek_position + self.fade_in = fade_in + self.job_id = shortuuid.uuid() + self.expected_players: set[str] = set() + self.subscribed_players: dict[str, asyncio.Queue[bytes]] = {} + self.start_chunk: dict[str, int] = {} self._all_clients_connected = asyncio.Event() - self._audio_task: asyncio.Task | None = None self.seen_players: set[str] = set() + # start running the audio task in the background + self._audio_task = asyncio.create_task(self._stream_job_runner()) @property def finished(self) -> bool: """Return if this StreamJob is finished.""" - if self._audio_task is None: - return False - if not self._all_clients_connected.is_set(): - return False return self._audio_task.cancelled() or self._audio_task.done() @property def pending(self) -> bool: """Return if this Job is pending start.""" - return not self._all_clients_connected.is_set() + return not self.finished and not self._all_clients_connected.is_set() @property def running(self) -> bool: """Return if this Job is running.""" return not self.finished and not self.pending + async def stop(self) -> None: + """Stop running this job.""" + if not self.running: + return + self._audio_task.cancel() + await self._audio_task + + async def resolve_stream_url( + self, + child_player_id: str, + ) -> str: + """Resolve the childplayer specific stream URL to this streamjob.""" + output_codec = ContentType( + await self.stream_controller.mass.config.get_player_config_value( + child_player_id, CONF_OUTPUT_CODEC + ) + ) + fmt = output_codec.value + # handle raw pcm + if output_codec.is_pcm(): + player = self.stream_controller.mass.players.get(child_player_id) + player_max_bit_depth = 32 if player.supports_24bit else 16 + output_sample_rate = min(self.pcm_format.sample_rate, player.max_sample_rate) + output_bit_depth = min(self.pcm_format.bit_depth, player_max_bit_depth) + output_channels = await self.stream_controller.mass.config.get_player_config_value( + child_player_id, CONF_OUTPUT_CHANNELS + ) + channels = 1 if output_channels != "stereo" else 2 + fmt += ( + f";codec=pcm;rate={output_sample_rate};" + f"bitrate={output_bit_depth};channels={channels}" + ) + url = f"{self.stream_controller.webserver.base_url}/{self.queue_id}/multi/{child_player_id}/{self.start_queue_item_id}.{fmt}?job_id={self.job_id}" # noqa: E501 + self.expected_players.add(child_player_id) + return url + async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: """Subscribe consumer and iterate incoming chunks on the queue.""" self.start() @@ -98,19 +158,16 @@ async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: sub_queue = asyncio.Queue(1) # some checks - assert player_id not in self.subscribers, "No duplicate subscriptions allowed" + assert player_id not in self.subscribed_players, "No duplicate subscriptions allowed" + assert player_id in self.expected_players, "Unexpected player id" assert not self.finished, "Already finished" assert not self.running, "Already running" - self.subscribers[player_id] = sub_queue - if len(self.subscribers) == len(self.expected_consumers): + self.subscribed_players[player_id] = sub_queue + if len(self.subscribed_players) == len(self.expected_players): # we reached the number of expected subscribers, set event # so that chunks can be pushed self._all_clients_connected.set() - else: - # wait until all expected subscribers arrived - # TODO: handle edge case where a player does not connect at all ?! - await self._all_clients_connected.wait() # keep reading audio chunks from the queue until we receive an empty one while True: @@ -120,254 +177,385 @@ async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: break yield chunk finally: - empty_queue(sub_queue) - self.subscribers.pop(player_id) - # some delay here to detect misbehaving (reconnecting) players + self.subscribed_players.pop(player_id) + # some delay here to handle misbehaving (reconnecting) players + if len(self.subscribed_players) == 0: + self._all_clients_connected.clear() await asyncio.sleep(2) # check if this was the last subscriber and we should cancel - if len(self.subscribers) == 0 and self._audio_task and not self.finished: + if len(self.subscribed_players) == 0 and self._audio_task and not self.finished: self._audio_task.cancel() - async def _put_data(self, data: Any, timeout: float = 120) -> None: + async def _put_chunk(self, chunk: bytes, chunk_num: int, timeout: float = 120) -> None: """Put chunk of data to all subscribers.""" async with asyncio.timeout(timeout): - while len(self.subscribers) == 0: - # this may happen with misbehaving clients that do - # multiple GET requests for the same audio stream. - # they receive the first chunk, disconnect and then - # directly reconnect again. - if not self._audio_task or self.finished: - return - await asyncio.sleep(0.1) async with asyncio.TaskGroup() as tg: - for sub_id in self.subscribers: - sub_queue = self.subscribers[sub_id] - tg.create_task(sub_queue.put(data)) + for player_id in self.subscribed_players: + try: + sub_queue = self.subscribed_players[player_id] + tg.create_task(sub_queue.put(chunk)) + if player_id not in self.start_chunk: + self.start_chunk[player_id] = chunk_num + except KeyError: + pass # race condition async def _stream_job_runner(self) -> None: """Feed audio chunks to StreamJob subscribers.""" chunk_num = 0 - async for chunk in self.audio_source: + start_queue_item = self.stream_controller.mass.players.queues.get_item( + self.queue_id, self.start_queue_item_id + ) + if not start_queue_item: + raise RuntimeError(reason=f"Unknown Queue item: {self.start_queue_item_id}") + async for chunk in self.stream_controller.get_flow_stream( + self.queue, start_queue_item, self.pcm_format, self.seek_position, self.fade_in + ): chunk_num += 1 - if chunk_num == 1: - # wait until all expected clients are connected - try: - async with asyncio.timeout(10): - await self._all_clients_connected.wait() - except TimeoutError as err: - if len(self.subscribers) == 0: - raise TimeoutError("Clients did not connect within 10 seconds.") from err - self._all_clients_connected.set() - LOGGER.warning( - "Starting stream job %s but not all clients connected within 10 seconds." + # wait until all expected clients are connected + try: + async with asyncio.timeout(10): + await self._all_clients_connected.wait() + except TimeoutError: + if len(self.subscribed_players) == 0: + self.stream_controller.logger.error( + "Abort multi client stream job for queue %s: " + "clients did not (re)connect within timeout", + self.queue.display_name, ) + break + # not all clients connected but timeout expired, set flasg and move on + # with all clients that did connect + self._all_clients_connected.set() - await self._put_data(chunk) + if chunk_num == 1: + self.stream_controller.logger.debug( + "Starting multi client stream job for queue %s " + "with %s out of %s connected clients", + self.queue.display_name, + len(self.subscribed_players), + len(self.expected_players), + ) + + await self._put_chunk(chunk) # mark EOF with empty chunk - await self._put_data(b"") + await self._put_chunk(b"") - def start(self) -> None: - """Start running the stream job.""" - if self._audio_task: - return - self._audio_task = asyncio.create_task(self._stream_job_runner()) + +def parse_pcm_info(content_type: str) -> tuple[int, int, int]: + """Parse PCM info from a codec/content_type string.""" + params = ( + dict(urllib.parse.parse_qsl(content_type.replace(";", "&"))) if ";" in content_type else {} + ) + sample_rate = int(params.get("rate", 44100)) + sample_size = int(params.get("bitrate", 16)) + channels = int(params.get("channels", 2)) + return (sample_rate, sample_size, channels) -class StreamsController: - """Controller to stream audio to players.""" +class StreamsController(CoreController): + """Webserver Controller to stream audio to players.""" - def __init__(self, mass: MusicAssistant): + name: str = "streams" + friendly_name: str = "Streamserver" + + def __init__(self, *args, **kwargs): """Initialize instance.""" - self.mass = mass - # streamjobs contains all active stream jobs - # there may be multiple jobs for the same queue item (e.g. when seeking) - # the key is the (unique) stream_id for the StreamJob - self.stream_jobs: dict[str, StreamJob] = {} - # some players do multiple GET requests for the same audio stream - # to determine content type or content length - # we try to detect/report these players and workaround it. - # if a player_id is in the below set of player_ids, the first GET request - # of that player will be ignored and audio is served only in the 2nd request - self.workaround_players: set[str] = set() + super().__init__(*args, **kwargs) + self._server = Webserver(self.logger, enable_dynamic_routes=True) + self.multi_client_jobs: dict[str, MultiClientStreamJob] = {} + self.register_dynamic_route = self._server.register_dynamic_route + self.unregister_dynamic_route = self._server.unregister_dynamic_route + + @property + def base_url(self) -> str: + """Return the base_url for the streamserver.""" + return self._server.base_url + + async def get_config_entries( + self, + action: str | None = None, # noqa: ARG002 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + ) -> tuple[ConfigEntry, ...]: + """Return all Config Entries for this core module (if any).""" + return DEFAULT_CORE_CONFIG_ENTRIES + ( + ConfigEntry( + key=CONF_BIND_PORT, + type=ConfigEntryType.STRING, + default_value=self._default_port, + label="TCP Port", + description="The TCP port to run the server. " + "Make sure that this server can be reached " + "on the given IP and TCP port by players on the local network.", + ), + ConfigEntry( + key=CONF_BIND_IP, + type=ConfigEntryType.STRING, + default_value=self._default_ip, + label="Bind to IP/interface", + description="Start the (web)server on this specific interface. \n" + "This IP address is communicated to players where to find this server. " + "Override the default in advanced scenarios, such as multi NIC configurations. \n" + "Make sure that this server can be reached " + "on the given IP and TCP port by players on the local network. \n" + "This is an advanced setting that should normally " + "not be adjusted in regular setups.", + advanced=True, + ), + ) async def setup(self) -> None: """Async initialize of module.""" + self._default_ip = await get_ip() + self._default_port = await select_free_port(8096, 9200) ffmpeg_present, libsoxr_support, version = await check_audio_support() if not ffmpeg_present: - LOGGER.error("FFmpeg binary not found on your system, playback will NOT work!.") + self.logger.error("FFmpeg binary not found on your system, playback will NOT work!.") elif not libsoxr_support: - LOGGER.warning( + self.logger.warning( "FFmpeg version found without libsoxr support, " "highest quality audio not available. " ) - await self._cleanup_stale() - LOGGER.info( - "Started stream controller (using ffmpeg version %s %s)", + self.logger.info( + "Detected ffmpeg version %s %s", version, "with libsoxr support" if libsoxr_support else "", ) + # start the webserver + self.publish_port = bind_port = self.mass.config.get_raw_core_config_value( + self.name, CONF_BIND_IP, self._default_port + ) + self.publish_ip = bind_ip = self.mass.config.get_raw_core_config_value( + self.name, CONF_BIND_IP, self._default_ip + ) + await self._server.setup( + bind_ip=bind_ip, + bind_port=bind_port, + base_url=f"http://{bind_ip}:{bind_port}", + static_routes=[ + ("GET", "/preview", self.serve_preview_stream), + ( + "GET", + "/{queue_id}/multi/{player_id}/{queue_item_id}.{fmt}", + self.serve_multi_subscriber_stream, + ), + ( + "GET", + "/{queue_id}/flow/{queue_item_id}.{fmt}", + self.serve_queue_flow_stream, + ), + ( + "GET", + "/{queue_id}/single/{queue_item_id}.{fmt}", + self.serve_queue_item_stream, + ), + ], + ) async def close(self) -> None: """Cleanup on exit.""" + await self._server.close() async def resolve_stream_url( self, + queue_id: str, queue_item: QueueItem, - player_id: str, seek_position: int = 0, fade_in: bool = False, - auto_start_runner: bool = True, flow_mode: bool = False, - output_codec: ContentType | None = None, ) -> str: - """Resolve the stream URL for the given QueueItem. - - This is called just-in-time by the player implementation to get the URL to the audio. - It will create a StreamJob which is a background task responsible for feeding - the PCM audio chunks to the consumer(s). - - - queue_item: the QueueItem that is about to be played (or buffered). - - player_id: the player_id of the player that will play the stream. - In case of a multi subscriber stream (e.g. sync/groups), - call resolve for every child player. - - seek_position: start playing from this specific position. - - fade_in: fade in the music at start (e.g. at resume). - - auto_start_runner: Start the audio stream in advance (stream track now). - - flow_mode: enable flow mode where the queue tracks are streamed as continuous stream. - - output_codec: Encode the stream in the given format (None for auto select). - """ - # check if there is already a pending job - for stream_job in self.stream_jobs.values(): - if stream_job.finished or stream_job.running: - continue - if stream_job.queue_item.queue_id != queue_item.queue_id: - continue - if stream_job.queue_item.queue_item_id != queue_item.queue_item_id: - continue - # if we hit this point, we have a match - break - else: - # register a new stream job - if flow_mode: - # flow mode streamjob - sample_rate = 48000 # hardcoded for now - bit_depth = 24 # hardcoded for now - stream_job = StreamJob( - queue_item=queue_item, - pcm_sample_rate=sample_rate, - pcm_bit_depth=bit_depth, - flow_mode=True, - ) - stream_job.audio_source = self._get_flow_stream( - stream_job, seek_position=seek_position, fade_in=fade_in - ) - else: - # regular streamjob - streamdetails = await get_stream_details(self.mass, queue_item) - stream_job = StreamJob( - queue_item=queue_item, - audio_source=get_media_stream( - self.mass, - streamdetails=streamdetails, - seek_position=seek_position, - fade_in=fade_in, - ), - pcm_sample_rate=streamdetails.sample_rate, - pcm_bit_depth=streamdetails.bit_depth, - ) - - stream_job.expected_consumers.add(player_id) - self.stream_jobs[stream_job.stream_id] = stream_job - if auto_start_runner: - stream_job.start() + """Resolve the (regular, single player) stream URL for the given QueueItem. - # generate player-specific URL for the stream job - if output_codec is None: - output_codec = ContentType( - await self.mass.config.get_player_config_value(player_id, CONF_OUTPUT_CODEC) - ) + This is called just-in-time by the Queue controller to get the URL to the audio. + """ + output_codec = ContentType( + await self.mass.config.get_player_config_value(queue_id, CONF_OUTPUT_CODEC) + ) fmt = output_codec.value - url = f"{self.mass.webserver.base_url}/stream/{player_id}/{queue_item.queue_item_id}/{stream_job.stream_id}.{fmt}" # noqa: E501 - # handle pcm + # handle raw pcm if output_codec.is_pcm(): - player = self.mass.players.get(player_id) - output_sample_rate = min(stream_job.pcm_sample_rate, player.max_sample_rate) + player = self.mass.players.get(queue_id) player_max_bit_depth = 32 if player.supports_24bit else 16 - output_bit_depth = min(stream_job.pcm_bit_depth, player_max_bit_depth) + if flow_mode: + output_sample_rate = min(FLOW_MAX_SAMPLE_RATE, player.max_sample_rate) + output_bit_depth = min(FLOW_MAX_BIT_DEPTH, player_max_bit_depth) + else: + streamdetails = await get_stream_details(self.mass, queue_item) + output_sample_rate = min(streamdetails.sample_rate, player.max_sample_rate) + output_bit_depth = min(streamdetails.bit_depth, player_max_bit_depth) output_channels = await self.mass.config.get_player_config_value( - player_id, CONF_OUTPUT_CHANNELS + queue_id, CONF_OUTPUT_CHANNELS ) channels = 1 if output_channels != "stereo" else 2 - url += ( + fmt += ( f";codec=pcm;rate={output_sample_rate};" f"bitrate={output_bit_depth};channels={channels}" ) + query_params = {} + base_path = "flow" if flow_mode else "single" + url = f"{self._server.base_url}/{queue_id}/{base_path}/{queue_item.queue_item_id}.{fmt}" + if seek_position: + query_params["seek_position"] = str(seek_position) + if fade_in: + query_params["fade_in"] = "1" + if query_params: + url += "?" + urllib.parse.urlencode(query_params) return url - def get_preview_url(self, provider_instance_id_or_domain: str, track_id: str) -> str: + def resolve_preview_url(self, provider_instance_id_or_domain: str, track_id: str) -> str: """Return url to short preview sample.""" enc_track_id = urllib.parse.quote(track_id) return ( - f"{self.mass.webserver.base_url}/stream/preview?" + f"{self._server.base_url}/preview?" f"provider={provider_instance_id_or_domain}&item_id={enc_track_id}" ) - async def serve_queue_stream(self, request: web.Request) -> web.Response: - """Serve Queue Stream audio to player(s).""" - LOGGER.debug( - "Got %s request to %s from %s\nheaders: %s\n", - request.method, - request.path, - request.remote, - request.headers, + async def create_multi_client_stream_job( + self, + queue_id: str, + start_queue_item_id: str, + seek_position: int = 0, + fade_in: bool = False, + ) -> MultiClientStreamJob: + """Create a MultiClientStreamJob for the given queue.. + + This is called by player/sync group implementations to start streaming + the queue audio to multiple players at once. + """ + if existing_job := self.multi_client_jobs.pop(queue_id, None): # noqa: SIM102 + # cleanup existing job first + if not existing_job.finished: + await existing_job.stop() + + self.multi_client_jobs[queue_id] = stream_job = MultiClientStreamJob( + self, + queue_id=queue_id, + pcm_format=AudioFormat( + # hardcoded pcm quality of 48/24 for now + # TODO: change this to the highest quality supported by all child players ? + content_type=ContentType.from_bit_depth(24), + sample_rate=4800, + bit_depth=24, + channels=2, + ), + start_queue_item_id=start_queue_item_id, + seek_position=seek_position, + fade_in=fade_in, ) - player_id = request.match_info["player_id"] - player = self.mass.players.get(player_id) - queue = self.mass.players.queues.get_active_queue(player_id) - if not player: - raise web.HTTPNotFound(reason=f"Unknown player_id: {player_id}") - stream_id = request.match_info["stream_id"] - stream_job = self.stream_jobs.get(stream_id) - if not stream_job or stream_job.finished: - # Player is trying to play a stream that already exited - if player.state == PlayerState.PAUSED: - await self.mass.players.queues.resume(player_id) - LOGGER.warning( - "Got stream request for an already finished stream job for player %s", - player.display_name, - ) - raise web.HTTPNotFound(reason=f"Unknown stream_id: {stream_id}") - - output_format_str = request.match_info["fmt"] - output_format = ContentType.try_parse(output_format_str) - output_sample_rate = min(stream_job.pcm_sample_rate, player.max_sample_rate) - player_max_bit_depth = 32 if player.supports_24bit else 16 - output_bit_depth = min(stream_job.pcm_bit_depth, player_max_bit_depth) - if output_format == ContentType.PCM: - # resolve generic pcm type - output_format = ContentType.from_bit_depth(output_bit_depth) - if output_format.is_pcm() or output_format == ContentType.WAV: - output_channels = await self.mass.config.get_player_config_value( - player_id, CONF_OUTPUT_CHANNELS - ) - channels = 1 if output_channels != "stereo" else 2 - output_format_str = ( - f"x-wav;codec=pcm;rate={output_sample_rate};" - f"bitrate={output_bit_depth};channels={channels}" + return stream_job + + async def serve_queue_item_stream(self, request: web.Request) -> web.Response: + """Stream single queueitem audio to a player.""" + self._log_request(request) + queue_id = request.match_info["queue_id"] + queue = self.mass.players.queues.get(queue_id) + if not queue: + raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}") + queue_player = self.mass.players.get(queue_id) + queue_item_id = request.match_info["queue_item_id"] + queue_item = self.mass.players.queues.get_item(queue_id, queue_item_id) + if not queue_item: + raise web.HTTPNotFound(reason=f"Unknown Queue item: {queue_item_id}") + try: + streamdetails = await get_stream_details(self.mass, queue_item=queue_item) + except MediaNotFoundError: + raise web.HTTPNotFound( + reason=f"Unable to retrieve streamdetails for item: {queue_item}" ) + seek_position = int(request.query.get("seek_position", 0)) + fade_in = bool(request.query.get("fade_in", 0)) + # work out output format/details + output_format = await self._get_output_format( + output_format_str=request.match_info["fmt"], + queue_player=queue_player, + default_sample_rate=streamdetails.audio_format.sample_rate, + default_bit_depth=streamdetails.audio_format.bit_depth, + ) + + # prepare request, add some DLNA/UPNP compatible headers + headers = { + **DEFAULT_STREAM_HEADERS, + "Content-Type": f"audio/{output_format.output_format_str}", + } + resp = web.StreamResponse( + status=200, + reason="OK", + headers=headers, + ) + await resp.prepare(request) + + # return early if this is only a HEAD request + if request.method == "HEAD": + return resp + + # all checks passed, start streaming! + self.logger.debug( + "Start serving audio stream for QueueItem %s to %s", queue_item.uri, queue.display_name + ) + + # collect player specific ffmpeg args to re-encode the source PCM stream + ffmpeg_args = await self._get_player_ffmpeg_args( + queue_player, + input_format=streamdetails.audio_format, + output_format=output_format, + ) + + async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc: + # feed stdin with pcm audio chunks from origin + async def read_audio(): + try: + async for chunk in get_media_stream( + self.mass, + streamdetails=streamdetails, + seek_position=seek_position, + fade_in=fade_in, + ): + try: + await ffmpeg_proc.write(chunk) + except BrokenPipeError: + break + finally: + ffmpeg_proc.write_eof() + ffmpeg_proc.attach_task(read_audio()) + + # read final chunks from stdout + async for chunk in ffmpeg_proc.iter_any(): + try: + await resp.write(chunk) + except (BrokenPipeError, ConnectionResetError): + # race condition + break + + return resp + + async def serve_queue_flow_stream(self, request: web.Request) -> web.Response: + """Stream Queue Flow audio to player.""" + self._log_request(request) + queue_id = request.match_info["queue_id"] + queue = self.mass.players.queues.get(queue_id) + if not queue: + raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}") + start_queue_item_id = request.match_info["queue_item_id"] + start_queue_item = self.mass.players.queues.get_item(queue_id, start_queue_item_id) + if not start_queue_item: + raise web.HTTPNotFound(reason=f"Unknown Queue item: {start_queue_item_id}") + seek_position = int(request.query.get("seek_position", 0)) + fade_in = bool(request.query.get("fade_in", 0)) + queue_player = self.mass.players.get(queue_id) + # work out output format/details + output_format = await self._get_output_format( + output_format_str=request.match_info["fmt"], + queue_player=queue_player, + default_sample_rate=FLOW_MAX_SAMPLE_RATE, + default_bit_depth=FLOW_MAX_BIT_DEPTH, + ) # prepare request, add some DLNA/UPNP compatible headers enable_icy = request.headers.get("Icy-MetaData", "") == "1" - icy_meta_interval = 65536 if output_format.is_lossless() else 8192 + icy_meta_interval = 65536 if output_format.content_type.is_lossless() else 8192 headers = { - "Content-Type": f"audio/{output_format_str}", - "transferMode.dlna.org": "Streaming", - "contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000", # noqa: E501 - "Cache-Control": "no-cache", - "Connection": "close", - "icy-name": "Music Assistant", - "icy-pub": "1", + **DEFAULT_STREAM_HEADERS, + "Content-Type": f"audio/{output_format.output_format_str}", } if enable_icy: headers["icy-metaint"] = str(icy_meta_interval) @@ -383,48 +571,33 @@ async def serve_queue_stream(self, request: web.Request) -> web.Response: if request.method == "HEAD": return resp - # handle workaround for players that do 2 multiple GET requests - # for the same audio stream (because of the missing duration/length) - if player_id in self.workaround_players and player_id not in stream_job.seen_players: - stream_job.seen_players.add(player_id) - return resp - - # guard for the same player connecting multiple times for the same stream - if player_id in stream_job.subscribers: - LOGGER.error( - "Player %s is making multiple requests for the same stream," - " please create an issue report on the Music Assistant issue tracker.", - player.display_name, - ) - # add the player to the list of players that need the workaround - self.workaround_players.add(player_id) - raise web.HTTPBadRequest(reason="Multiple connections are not allowed.") - if stream_job.running: - LOGGER.error( - "Player %s is making a request for an already running stream," - " please create an issue report on the Music Assistant issue tracker.", - player.display_name, - ) - self.mass.create_task(self.mass.players.queues.next(player_id)) - raise web.HTTPBadRequest(reason="Stream is already running.") - # all checks passed, start streaming! - LOGGER.debug("Start serving audio stream %s to %s", stream_id, player.name) + self.logger.debug("Start serving Queue flow audio stream for %s", queue_player.name) # collect player specific ffmpeg args to re-encode the source PCM stream + pcm_format = AudioFormat( + content_type=ContentType.from_bit_depth(output_format.bit_depth), + sample_rate=output_format.sample_rate, + bit_depth=output_format.bit_depth, + channels=2, + ) ffmpeg_args = await self._get_player_ffmpeg_args( - player, - input_sample_rate=stream_job.pcm_sample_rate, - input_bit_depth=stream_job.pcm_bit_depth, + queue_player, + input_format=pcm_format, output_format=output_format, - output_sample_rate=output_sample_rate, ) async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc: # feed stdin with pcm audio chunks from origin async def read_audio(): try: - async for chunk in stream_job.subscribe(player_id): + async for chunk in self.get_flow_stream( + queue=queue, + start_queue_item=start_queue_item, + pcm_format=pcm_format, + seek_position=seek_position, + fade_in=fade_in, + ): try: await ffmpeg_proc.write(chunk) except BrokenPipeError: @@ -473,55 +646,140 @@ async def read_audio(): return resp - async def _get_flow_stream( + async def serve_multi_subscriber_stream(self, request: web.Request) -> web.Response: + """Stream Queue Flow audio to a child player within a multi subscriber setup.""" + self._log_request(request) + queue_id = request.match_info["queue_id"] + streamjob = self.multi_client_jobs.get(queue_id) + if not streamjob: + raise web.HTTPNotFound(reason=f"Unknown StreamJob for queue: {queue_id}") + job_id = request.query.get("job_id") + if job_id and job_id != streamjob.job_id: + raise web.HTTPNotFound(reason=f"StreamJob ID {job_id} mismatch for queue: {queue_id}") + child_player_id = request.match_info["player_id"] + child_player = self.mass.players.get(child_player_id) + if not child_player: + raise web.HTTPNotFound(reason=f"Unknown player: {child_player_id}") + # work out (childplayer specific!) output format/details + output_format = await self._get_output_format( + output_format_str=request.match_info["fmt"], + queue_player=child_player, + default_sample_rate=streamjob.pcm_format.sample_rate, + default_bit_depth=streamjob.pcm_format.bit_depth, + ) + # prepare request, add some DLNA/UPNP compatible headers + headers = { + **DEFAULT_STREAM_HEADERS, + "Content-Type": f"audio/{output_format.output_format_str}", + } + resp = web.StreamResponse( + status=200, + reason="OK", + headers=headers, + ) + await resp.prepare(request) + + # return early if this is only a HEAD request + if request.method == "HEAD": + return resp + + # all checks passed, start streaming! + self.logger.debug( + "Start serving multi-subscriber Queue flow audio stream for queue %s to player %s", + streamjob.queue.display_name, + child_player.display_name, + ) + + # collect player specific ffmpeg args to re-encode the source PCM stream + ffmpeg_args = await self._get_player_ffmpeg_args( + child_player, + input_format=streamjob.pcm_format, + output_format=output_format, + ) + + async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc: + # feed stdin with pcm audio chunks from origin + async def read_audio(): + try: + async for chunk in streamjob.subscribe(child_player_id): + try: + await ffmpeg_proc.write(chunk) + except BrokenPipeError: + break + finally: + ffmpeg_proc.write_eof() + + ffmpeg_proc.attach_task(read_audio()) + + # read final chunks from stdout + async for chunk in ffmpeg_proc.iter_any(): + try: + await resp.write(chunk) + except (BrokenPipeError, ConnectionResetError): + # race condition + break + + return resp + + async def serve_preview_stream(self, request: web.Request): + """Serve short preview sample.""" + self._log_request(request) + provider_instance_id_or_domain = request.query["provider"] + item_id = urllib.parse.unquote(request.query["item_id"]) + resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/mp3"}) + await resp.prepare(request) + async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id): + await resp.write(chunk) + return resp + + async def get_flow_stream( self, - stream_job: StreamJob, + queue: PlayerQueue, + start_queue_item: QueueItem, + pcm_format: AudioFormat, seek_position: int = 0, fade_in: bool = False, ) -> AsyncGenerator[bytes, None]: """Get a flow stream of all tracks in the queue.""" # ruff: noqa: PLR0915 - queue_id = stream_job.queue_item.queue_id - queue = self.mass.players.queues.get(queue_id) - queue_player = self.mass.players.get(queue_id) + assert pcm_format.content_type.is_pcm() + queue_player = self.mass.players.get(queue.queue_id) queue_track = None last_fadeout_part = b"" - - LOGGER.info("Start Queue Flow stream for Queue %s", queue.display_name) + self.logger.info("Start Queue Flow stream for Queue %s", queue.display_name) while True: # get (next) queue item to stream if queue_track is None: - queue_track = stream_job.queue_item + queue_track = start_queue_item use_crossfade = queue.crossfade_enabled else: seek_position = 0 fade_in = False try: ( + _, queue_track, use_crossfade, - ) = await self.mass.players.queues.player_ready_for_next_track( - queue_id, queue_track.queue_item_id + ) = await self.mass.players.queues.preload_next_url( + queue.queue_id, queue_track.queue_item_id ) except QueueEmpty: break - # store reference to the current queueitem on the streamjob - stream_job.queue_item = queue_track # get streamdetails try: streamdetails = await get_stream_details(self.mass, queue_track) except MediaNotFoundError as err: # streamdetails retrieval failed, skip to next track instead of bailing out... - LOGGER.warning( + self.logger.warning( "Skip track %s due to missing streamdetails", queue_track.name, exc_info=err, ) continue - LOGGER.debug( + self.logger.debug( "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s", streamdetails.uri, queue_track.name, @@ -530,9 +788,7 @@ async def _get_flow_stream( ) # set some basic vars - sample_rate = stream_job.pcm_sample_rate - bit_depth = stream_job.pcm_bit_depth - pcm_sample_size = int(sample_rate * (bit_depth / 8) * 2) + pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2) crossfade_duration = 10 crossfade_size = int(pcm_sample_size * crossfade_duration) queue_track.streamdetails.seconds_skipped = seek_position @@ -547,15 +803,14 @@ async def _get_flow_stream( streamdetails, seek_position=seek_position, fade_in=fade_in, - sample_rate=sample_rate, - bit_depth=bit_depth, + output_format=pcm_format, # only allow strip silence from begin if track is being crossfaded strip_silence_begin=last_fadeout_part != b"", ): chunk_num += 1 # slow down if the player buffers too aggressively - seconds_streamed = int(bytes_written / stream_job.pcm_sample_size) + seconds_streamed = int(bytes_written / pcm_sample_size) if ( seconds_streamed > 10 and queue_player.corrected_elapsed_time > 10 @@ -574,8 +829,8 @@ async def _get_flow_stream( crossfade_part = await crossfade_pcm_parts( fadein_part, last_fadeout_part, - bit_depth, - sample_rate, + pcm_format.bit_depth, + pcm_format.sample_rate, ) # send crossfade_part yield crossfade_part @@ -605,7 +860,7 @@ async def _get_flow_stream( if bytes_written == 0: # stream error: got empty first chunk ?! - LOGGER.warning("Stream error on %s", streamdetails.uri) + self.logger.warning("Stream error on %s", streamdetails.uri) queue_track.streamdetails.seconds_streamed = 0 continue @@ -622,73 +877,60 @@ async def _get_flow_stream( # end of the track reached - store accurate duration queue_track.streamdetails.seconds_streamed = bytes_written / pcm_sample_size - LOGGER.debug( + self.logger.debug( "Finished Streaming queue track: %s (%s) on queue %s", queue_track.streamdetails.uri, queue_track.name, queue.display_name, ) - LOGGER.info("Finished Queue Flow stream for Queue %s", queue.display_name) - - async def serve_preview(self, request: web.Request): - """Serve short preview sample.""" - provider_instance_id_or_domain = request.query["provider"] - item_id = urllib.parse.unquote(request.query["item_id"]) - resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/mp3"}) - await resp.prepare(request) - async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id): - await resp.write(chunk) - return resp + self.logger.info("Finished Queue Flow stream for Queue %s", queue.display_name) async def _get_player_ffmpeg_args( self, player: Player, - input_sample_rate: int, - input_bit_depth: int, - output_format: ContentType, - output_sample_rate: int, + input_format: AudioFormat, + output_format: AudioFormat, ) -> list[str]: """Get player specific arguments for the given (pcm) input and output details.""" player_conf = await self.mass.config.get_player_config(player.player_id) - conf_channels = player_conf.get_value(CONF_OUTPUT_CHANNELS) # generic args generic_args = [ "ffmpeg", "-hide_banner", "-loglevel", - "warning" if LOGGER.isEnabledFor(logging.DEBUG) else "quiet", + "warning" if self.logger.isEnabledFor(logging.DEBUG) else "quiet", "-ignore_unknown", ] # input args input_args = [ "-f", - ContentType.from_bit_depth(input_bit_depth).value, + input_format.content_type.value, "-ac", "2", "-ar", - str(input_sample_rate), + str(input_format.sample_rate), "-i", "-", ] input_args += ["-metadata", 'title="Music Assistant"'] # select output args - if output_format == ContentType.FLAC: + if output_format.content_type == ContentType.FLAC: output_args = ["-f", "flac", "-compression_level", "3"] - elif output_format == ContentType.AAC: + elif output_format.content_type == ContentType.AAC: output_args = ["-f", "adts", "-c:a", output_format.value, "-b:a", "320k"] - elif output_format == ContentType.MP3: + elif output_format.content_type == ContentType.MP3: output_args = ["-f", "mp3", "-c:a", output_format.value, "-b:a", "320k"] else: - output_args = ["-f", output_format.value] + output_args = ["-f", output_format.content_type.value] output_args += [ # append channels "-ac", - "1" if conf_channels != "stereo" else "2", + str(output_format.channels), # append sample rate "-ar", - str(output_sample_rate), + str(output_format.sample_rate), # output = pipe "-", ] @@ -708,6 +950,7 @@ async def _get_player_ffmpeg_args( f"equalizer=frequency=9000:width=18000:width_type=h:gain={eq_treble}" ) # handle output mixing only left or right + conf_channels = player_conf.get_value(CONF_OUTPUT_CHANNELS) if conf_channels == "left": filter_params.append("pan=mono|c0=FL") elif conf_channels == "right": @@ -718,17 +961,48 @@ async def _get_player_ffmpeg_args( return generic_args + input_args + extra_args + output_args - async def _cleanup_stale(self) -> None: - """Cleanup stale/done stream tasks.""" - stale = set() - for stream_id, job in self.stream_jobs.items(): - if job.finished: - stale.add(stream_id) - for stream_id in stale: - self.stream_jobs.pop(stream_id, None) + def _log_request(self, request: web.Request) -> None: + """Log request.""" + if not self.logger.isEnabledFor(logging.DEBUG): + return + self.logger.debug( + "Got %s request to %s from %s\nheaders: %s\n", + request.method, + request.path, + request.remote, + request.headers, + ) - # reschedule self to run every 5 minutes - def reschedule(): - self.mass.create_task(self._cleanup_stale()) + async def _get_output_format( + self, + output_format_str: str, + queue_player: Player, + default_sample_rate: int, + default_bit_depth: int, + ) -> AudioFormat: + """Parse (player specific) output format details for given format string.""" + content_type = ContentType.try_parse(output_format_str) + if content_type.is_pcm() or content_type == ContentType.WAV: + # parse pcm details from format string + output_sample_rate, output_bit_depth, output_channels = parse_pcm_info( + output_format_str + ) + if content_type == ContentType.PCM: + # resolve generic pcm type + output_format = ContentType.from_bit_depth(output_bit_depth) - self.mass.loop.call_later(300, reschedule) + else: + output_sample_rate = min(default_sample_rate, queue_player.max_sample_rate) + player_max_bit_depth = 32 if queue_player.supports_24bit else 16 + output_bit_depth = min(default_bit_depth, player_max_bit_depth) + output_channels_str = await self.mass.config.get_player_config_value( + queue_player.player_id, CONF_OUTPUT_CHANNELS + ) + output_channels = 1 if output_channels_str != "stereo" else 2 + return AudioFormat( + content_type=output_format, + sample_rate=output_sample_rate, + bit_depth=output_bit_depth, + channels=output_channels, + output_format_str=output_format_str, + ) diff --git a/music_assistant/server/controllers/webserver.py b/music_assistant/server/controllers/webserver.py index da6cd28ab..9e2f40727 100644 --- a/music_assistant/server/controllers/webserver.py +++ b/music_assistant/server/controllers/webserver.py @@ -1,128 +1,353 @@ -"""Controller that manages the builtin webserver(s) needed for the music Assistant server.""" +""" +Controller that manages the builtin webserver that hosts the api and frontend. + +Unlike the streamserver (which is as simple and unprotected as possible), +this webserver allows for more fine grained configuration to better secure it. +""" from __future__ import annotations +import asyncio +import inspect import logging import os -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable +from concurrent import futures +from contextlib import suppress from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Final -from aiohttp import web +from aiohttp import WSMsgType, web from music_assistant_frontend import where as locate_frontend -from music_assistant.common.helpers.util import select_free_port -from music_assistant.constants import ROOT_LOGGER_NAME +from music_assistant.common.helpers.util import get_ip, select_free_port +from music_assistant.common.models.api import ( + ChunkedResultMessage, + CommandMessage, + ErrorResultMessage, + MessageType, + SuccessResultMessage, +) +from music_assistant.common.models.config_entries import DEFAULT_CORE_CONFIG_ENTRIES, ConfigEntry +from music_assistant.common.models.enums import ConfigEntryType +from music_assistant.common.models.errors import InvalidCommand +from music_assistant.common.models.event import MassEvent +from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT +from music_assistant.server.helpers.api import APICommandHandler, parse_arguments +from music_assistant.server.helpers.webserver import Webserver +from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: - from music_assistant.server import MusicAssistant + from music_assistant.common.models.config_entries import ConfigValueType -LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.web") +CONF_BASE_URL = "base_url" +DEBUG = False # Set to True to enable very verbose logging of all incoming/outgoing messages +MAX_PENDING_MSG = 512 +CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError) -class WebserverController: - """Controller to stream audio to players.""" +class WebserverController(CoreController): + """Core Controller that manages the builtin webserver that hosts the api and frontend.""" - port: int - webapp: web.Application + name: str = "webserver" + friendly_name: str = "Web Server (frontend and api)" - def __init__(self, mass: MusicAssistant): + def __init__(self, *args, **kwargs): """Initialize instance.""" - self.mass = mass - self._apprunner: web.AppRunner - self._tcp: web.TCPSite - self._route_handlers: dict[str, Callable] = {} + super().__init__(*args, **kwargs) + self._server = Webserver(self.logger, enable_dynamic_routes=False) + self.clients: set[WebsocketClientHandler] = set() @property def base_url(self) -> str: - """Return the (web)server's base url.""" - return f"http://{self.mass.base_ip}:{self.port}" + """Return the base_url for the streamserver.""" + return self._server.base_url - async def setup(self) -> None: - """Async initialize of module.""" - self.webapp = web.Application() - self.port = await select_free_port(8095, 9200) - LOGGER.info("Starting webserver on port %s", self.port) - self._apprunner = web.AppRunner(self.webapp, access_log=None) - # setup stream paths - self.webapp.router.add_get("/stream/preview", self.mass.streams.serve_preview) - self.webapp.router.add_get( - "/stream/{player_id}/{queue_item_id}/{stream_id}.{fmt}", - self.mass.streams.serve_queue_stream, + async def get_config_entries( + self, + action: str | None = None, # noqa: ARG002 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + ) -> tuple[ConfigEntry, ...]: + """Return all Config Entries for this core module (if any).""" + return DEFAULT_CORE_CONFIG_ENTRIES + ( + ConfigEntry( + key=CONF_BIND_PORT, + type=ConfigEntryType.STRING, + default_value=self._default_port, + label="TCP Port", + description="The TCP port to run the webserver.", + ), + ConfigEntry( + key=CONF_BASE_URL, + type=ConfigEntryType.STRING, + default_value=self._default_base_url, + label="Base URL", + description="The (base) URL to reach this webserver in the network. \n" + "Override this in advanced scenarios where for example you're running " + "the webserver behind a reverse proxy.", + advanced=True, + ), + ConfigEntry( + key=CONF_BIND_IP, + type=ConfigEntryType.STRING, + default_value="0.0.0.0", + label="Bind to IP/interface", + description="Start the (web)server on this specific interface. \n" + "Use 0.0.0.0 to bind to all interfaces. \n" + "Set this address for example to a docker-internal network, " + "to enhance security and protect outside access to the webinterface and API. \n\n" + "This is an advanced setting that should normally " + "not be adjusted in regular setups.", + advanced=True, + ), ) - # setup frontend + async def setup(self) -> None: + """Async initialize of module.""" + self._default_ip = await get_ip() + self._default_port = await select_free_port(8095, 9200) + self._default_base_url = f"http://{self._default_ip}:{self._default_port}" + # work out all routes + routes: list[tuple[str, str, Awaitable]] = [] + # frontend routes frontend_dir = locate_frontend() for filename in next(os.walk(frontend_dir))[2]: if filename.endswith(".py"): continue filepath = os.path.join(frontend_dir, filename) - handler = partial(self.serve_static, filepath) - self.webapp.router.add_get(f"/{filename}", handler) - # add assets subdir as static - self.webapp.router.add_static( - "/assets", os.path.join(frontend_dir, "assets"), name="assets" - ) + handler = partial(self._server.serve_static, filepath) + routes.append(("GET", f"/{filename}", handler)) # add index index_path = os.path.join(frontend_dir, "index.html") - handler = partial(self.serve_static, index_path) - self.webapp.router.add_get("/", handler) + handler = partial(self._server.serve_static, index_path) + routes.append(("GET", "/", handler)) # add info - self.webapp.router.add_get("/info", self._handle_server_info) - # register catch-all route to handle our custom paths - self.webapp.router.add_route("*", "/{tail:.*}", self._handle_catch_all) - await self._apprunner.setup() - # set host to None to bind to all addresses on both IPv4 and IPv6 - host = None - self._tcp_site = web.TCPSite(self._apprunner, host=host, port=self.port) - await self._tcp_site.start() + routes.append(("GET", "/info", self._handle_server_info)) + # add websocket api + routes.append(("GET", "/ws", self._handle_ws_client)) + # start the webserver + bind_port = self.mass.config.get_raw_core_config_value( + self.name, CONF_BIND_IP, self._default_port + ) + bind_ip = self.mass.config.get_raw_core_config_value( + self.name, CONF_BIND_IP, self._default_ip + ) + base_url = self.mass.config.get_raw_core_config_value( + self.name, CONF_BASE_URL, self._default_ip + ) + await self._server.setup( + bind_ip=bind_ip, + bind_port=bind_port, + base_url=base_url, + static_routes=routes, + # add assets subdir as static_content + static_content=("/assets", os.path.join(frontend_dir, "assets"), "assets"), + ) async def close(self) -> None: """Cleanup on exit.""" - # stop/clean webserver - await self._tcp_site.stop() - await self._apprunner.cleanup() - await self.webapp.shutdown() - await self.webapp.cleanup() - - def register_route(self, path: str, handler: Awaitable, method: str = "*") -> Callable: - """Register a route on the (main) webserver, returns handler to unregister.""" - key = f"{method}.{path}" - if key in self._route_handlers: - raise RuntimeError(f"Route {path} already registered.") - self._route_handlers[key] = handler - - def _remove(): - return self._route_handlers.pop(key) - - return _remove - - def unregister_route(self, path: str, method: str = "*") -> None: - """Unregister a route from the (main) webserver.""" - key = f"{method}.{path}" - self._route_handlers.pop(key) - - async def serve_static(self, file_path: str, _request: web.Request) -> web.FileResponse: - """Serve file response.""" - headers = {"Cache-Control": "no-cache"} - return web.FileResponse(file_path, headers=headers) - - async def _handle_catch_all(self, request: web.Request) -> web.Response: - """Redirect request to correct destination.""" - # find handler for the request - for key in (f"{request.method}.{request.path}", f"*.{request.path}"): - if handler := self._route_handlers.get(key): - return await handler(request) - # deny all other requests - LOGGER.debug( - "Received unhandled %s request to %s from %s\nheaders: %s\n", - request.method, - request.path, - request.remote, - request.headers, - ) - return web.Response(status=404) + for client in set(self.clients): + await client.disconnect() + await self._server.close() async def _handle_server_info(self, request: web.Request) -> web.Response: # noqa: ARG002 """Handle request for server info.""" return web.json_response(self.mass.get_server_info().to_dict()) + + async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse: + connection = WebsocketClientHandler(self.mass, request) + try: + self.clients.add(connection) + return await connection.handle_client() + finally: + self.clients.remove(connection) + + +class WebSocketLogAdapter(logging.LoggerAdapter): + """Add connection id to websocket log messages.""" + + def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: + """Add connid to websocket log messages.""" + return f'[{self.extra["connid"]}] {msg}', kwargs + + +class WebsocketClientHandler: + """Handle an active websocket client connection.""" + + def __init__(self, webserver: WebserverController, request: web.Request) -> None: + """Initialize an active connection.""" + self.mass = webserver.mass + self.request = request + self.wsock = web.WebSocketResponse(heartbeat=55) + self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG) + self._handle_task: asyncio.Task | None = None + self._writer_task: asyncio.Task | None = None + self._logger = WebSocketLogAdapter(webserver.logger, {"connid": id(self)}) + + async def disconnect(self) -> None: + """Disconnect client.""" + self._cancel() + if self._writer_task is not None: + await self._writer_task + + async def handle_client(self) -> web.WebSocketResponse: + """Handle a websocket response.""" + # ruff: noqa: PLR0915 + request = self.request + wsock = self.wsock + try: + async with asyncio.timeout(10): + await wsock.prepare(request) + except asyncio.TimeoutError: + self._logger.warning("Timeout preparing request from %s", request.remote) + return wsock + + self._logger.debug("Connection from %s", request.remote) + self._handle_task = asyncio.current_task() + self._writer_task = asyncio.create_task(self._writer()) + + # send server(version) info when client connects + self._send_message(self.mass.get_server_info()) + + # forward all events to clients + def handle_event(event: MassEvent) -> None: + self._send_message(event) + + unsub_callback = self.mass.subscribe(handle_event) + + disconnect_warn = None + + try: + while not wsock.closed: + msg = await wsock.receive() + + if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING): + break + + if msg.type != WSMsgType.TEXT: + disconnect_warn = "Received non-Text message." + break + + if DEBUG: + self._logger.debug("Received: %s", msg.data) + + try: + command_msg = CommandMessage.from_json(msg.data) + except ValueError: + disconnect_warn = f"Received invalid JSON: {msg.data}" + break + + self._handle_command(command_msg) + + except asyncio.CancelledError: + self._logger.debug("Connection closed by client") + + except Exception: # pylint: disable=broad-except + self._logger.exception("Unexpected error inside websocket API") + + finally: + # Handle connection shutting down. + unsub_callback() + self._logger.debug("Unsubscribed from events") + + try: + self._to_write.put_nowait(None) + # Make sure all error messages are written before closing + await self._writer_task + await wsock.close() + except asyncio.QueueFull: # can be raised by put_nowait + self._writer_task.cancel() + + finally: + if disconnect_warn is None: + self._logger.debug("Disconnected") + else: + self._logger.warning("Disconnected: %s", disconnect_warn) + + return wsock + + def _handle_command(self, msg: CommandMessage) -> None: + """Handle an incoming command from the client.""" + self._logger.debug("Handling command %s", msg.command) + + # work out handler for the given path/command + handler = self.mass.command_handlers.get(msg.command) + + if handler is None: + self._send_message( + ErrorResultMessage( + msg.message_id, + InvalidCommand.error_code, + f"Invalid command: {msg.command}", + ) + ) + self._logger.warning("Invalid command: %s", msg.command) + return + + # schedule task to handle the command + asyncio.create_task(self._run_handler(handler, msg)) + + async def _run_handler(self, handler: APICommandHandler, msg: CommandMessage) -> None: + try: + args = parse_arguments(handler.signature, handler.type_hints, msg.args) + result = handler.target(**args) + if inspect.isasyncgen(result): + # async generator = send chunked response + chunk_size = 100 + batch: list[Any] = [] + async for item in result: + batch.append(item) + if len(batch) == chunk_size: + self._send_message(ChunkedResultMessage(msg.message_id, batch)) + batch = [] + # send last chunk + self._send_message(ChunkedResultMessage(msg.message_id, batch, True)) + del batch + return + if asyncio.iscoroutine(result): + result = await result + self._send_message(SuccessResultMessage(msg.message_id, result)) + except Exception as err: # pylint: disable=broad-except + self._logger.exception("Error handling message: %s", msg) + self._send_message( + ErrorResultMessage(msg.message_id, getattr(err, "error_code", 999), str(err)) + ) + + async def _writer(self) -> None: + """Write outgoing messages.""" + # Exceptions if Socket disconnected or cancelled by connection handler + with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS): + while not self.wsock.closed: + if (process := await self._to_write.get()) is None: + break + + if not isinstance(process, str): + message: str = process() + else: + message = process + if DEBUG: + self._logger.debug("Writing: %s", message) + await self.wsock.send_str(message) + + def _send_message(self, message: MessageType) -> None: + """Send a message to the client. + + Closes connection if the client is not reading the messages. + + Async friendly. + """ + _message = message.to_json() + + try: + self._to_write.put_nowait(_message) + except asyncio.QueueFull: + self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG) + + self._cancel() + + def _cancel(self) -> None: + """Cancel the connection.""" + if self._handle_task is not None: + self._handle_task.cancel() + if self._writer_task is not None: + self._writer_task.cancel() diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index b2d42c8c6..3d9aff2c8 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -15,7 +15,12 @@ from aiohttp import ClientTimeout from music_assistant.common.models.errors import AudioError, MediaNotFoundError, MusicAssistantError -from music_assistant.common.models.media_items import ContentType, MediaType, StreamDetails +from music_assistant.common.models.media_items import ( + AudioFormat, + ContentType, + MediaType, + StreamDetails, +) from music_assistant.constants import ( CONF_VOLUME_NORMALIZATION, CONF_VOLUME_NORMALIZATION_TARGET, @@ -379,12 +384,12 @@ async def get_media_stream( streamdetails: StreamDetails, seek_position: int = 0, fade_in: bool = False, - sample_rate: int | None = None, - bit_depth: int | None = None, + output_format: AudioFormat | None = None, strip_silence_begin: bool = False, strip_silence_end: bool = True, ) -> AsyncGenerator[bytes, None]: - """Get the (PCM) audio stream for the given streamdetails. + """ + Get the (raw PCM) audio stream for the given streamdetails. Other than stripping silence at end and beginning and optional volume normalization this is the pure, unaltered audio data as PCM chunks. @@ -394,11 +399,11 @@ async def get_media_stream( is_radio = streamdetails.media_type == MediaType.RADIO or not streamdetails.duration if is_radio or seek_position: strip_silence_begin = False - - sample_rate = sample_rate or streamdetails.sample_rate - bit_depth = bit_depth or streamdetails.bit_depth + # use audio format from streamdetails if no override is given + if output_format is None: + output_format = streamdetails.audio_format # chunk size = 2 seconds of pcm audio - pcm_sample_size = int(sample_rate * (bit_depth / 8) * 2) + pcm_sample_size = int(output_format.sample_rate * (output_format.bit_depth / 8) * 2) chunk_size = pcm_sample_size * (1 if is_radio else 2) expected_chunks = int((streamdetails.duration or 0) / 2) if expected_chunks < 60: @@ -408,8 +413,8 @@ async def get_media_stream( seek_pos = seek_position if (streamdetails.direct or not streamdetails.can_seek) else 0 args = await _get_ffmpeg_args( streamdetails=streamdetails, - sample_rate=sample_rate, - bit_depth=bit_depth, + sample_rate=output_format.sample_rate, + bit_depth=output_format.bit_depth, # only use ffmpeg seeking if the provider stream does not support seeking seek_position=seek_pos, fade_in=fade_in, @@ -445,8 +450,8 @@ async def writer(): stripped_audio = await strip_silence( mass, prev_chunk + chunk, - sample_rate=sample_rate, - bit_depth=bit_depth, + sample_rate=output_format.sample_rate, + bit_depth=output_format.bit_depth, ) yield stripped_audio bytes_sent += len(stripped_audio) @@ -470,8 +475,8 @@ async def writer(): stripped_audio = await strip_silence( mass, prev_chunk, - sample_rate=sample_rate, - bit_depth=bit_depth, + sample_rate=output_format.sample_rate, + bit_depth=output_format.bit_depth, reverse=True, ) yield stripped_audio diff --git a/music_assistant/server/helpers/auth.py b/music_assistant/server/helpers/auth.py index 197b136bf..aaf1ff709 100644 --- a/music_assistant/server/helpers/auth.py +++ b/music_assistant/server/helpers/auth.py @@ -30,18 +30,18 @@ def __init__(self, mass: MusicAssistant, session_id: str): @property def callback_url(self) -> str: """Return the callback URL.""" - return f"{self.mass.webserver.base_url}/callback/{self.session_id}" + return f"{self.mass.streams.base_url}/callback/{self.session_id}" async def __aenter__(self) -> AuthenticationHelper: """Enter context manager.""" - self.mass.webserver.register_route( + self.mass.streams.register_dynamic_route( f"/callback/{self.session_id}", self._handle_callback, "GET" ) return self async def __aexit__(self, exc_type, exc_value, traceback) -> bool: """Exit context manager.""" - self.mass.webserver.unregister_route(f"/callback/{self.session_id}", "GET") + self.mass.streams.unregister_dynamic_route(f"/callback/{self.session_id}", "GET") async def authenticate(self, auth_url: str, timeout: int = 60) -> dict[str, str]: """Start the auth process and return any query params if received on the callback.""" diff --git a/music_assistant/server/helpers/didl_lite.py b/music_assistant/server/helpers/didl_lite.py index 744e39f63..f08277d20 100644 --- a/music_assistant/server/helpers/didl_lite.py +++ b/music_assistant/server/helpers/didl_lite.py @@ -15,27 +15,23 @@ def create_didl_metadata( - mass: MusicAssistant, url: str, queue_item: QueueItem, flow_mode: bool = False + mass: MusicAssistant, url: str, queue_item: QueueItem | None = None ) -> str: - """Create DIDL metadata string from url and QueueItem.""" + """Create DIDL metadata string from url and (optional) QueueItem.""" ext = url.split(".")[-1] - is_radio = queue_item.media_type != MediaType.TRACK or not queue_item.duration - image_url = mass.metadata.get_image_url(queue_item.image) if queue_item.image else "" - - if flow_mode: + if queue_item is None: return ( '' - f'' f"Music Assistant" f"{MASS_LOGO_ONLINE}" - f"{queue_item.queue_item_id}" "object.item.audioItem.audioBroadcast" f"audio/{ext}" f'{url}' "" "" ) - + is_radio = queue_item.media_type != MediaType.TRACK or not queue_item.duration + image_url = mass.metadata.get_image_url(queue_item.image) if queue_item.image else "" if is_radio: # radio or other non-track item return ( diff --git a/music_assistant/server/helpers/webserver.py b/music_assistant/server/helpers/webserver.py new file mode 100644 index 000000000..b41663f15 --- /dev/null +++ b/music_assistant/server/helpers/webserver.py @@ -0,0 +1,118 @@ +"""Base Webserver logic for an HTTPServer that can handle dynamic routes.""" +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable + +from aiohttp import web + + +class Webserver: + """Base Webserver logic for an HTTPServer that can handle dynamic routes.""" + + def __init__( + self, + logger: logging.Logger, + enable_dynamic_routes: bool = False, + ): + """Initialize instance.""" + self.logger = logger + # the below gets initialized in async setup + self._apprunner: web.AppRunner | None = None + self._webapp: web.Application | None = None + self._tcp_site: web.TCPSite | None = None + self._static_routes: list[tuple[str, str, Awaitable]] | None = None + self._dynamic_routes: dict[str, Callable] | None = {} if enable_dynamic_routes else None + self._bind_port: int | None = None + + async def setup( + self, + bind_ip: str | None, + bind_port: int, + base_url: str, + static_routes: list[tuple[str, str, Awaitable]] | None = None, + static_content: tuple[str, str, str] | None = None, + ) -> None: + """Async initialize of module.""" + self._base_url = base_url[:-1] if base_url.endswith("/") else base_url + self._bind_port = bind_port + self._static_routes = static_routes + self._webapp = web.Application(logger=self.logger) + self.logger.info("Starting server on %s:%s", bind_ip, bind_port) + self._apprunner = web.AppRunner(self._webapp, access_log=None) + # add static routes + if self._static_routes: + for method, path, handler in self._static_routes: + self._webapp.router.add_route(method, path, handler) + if static_content: + self._webapp.router.add_static( + static_content[0], static_content[1], name=static_content[2] + ) + # register catch-all route to handle dynamic routes (if enabled) + if self._dynamic_routes is not None: + self._webapp.router.add_route("*", "/{tail:.*}", self._handle_catch_all) + await self._apprunner.setup() + # set host to None to bind to all addresses on both IPv4 and IPv6 + host = None if bind_ip == "0.0.0.0" else bind_ip + self._tcp_site = web.TCPSite(self._apprunner, host=host, port=bind_port) + await self._tcp_site.start() + + async def close(self) -> None: + """Cleanup on exit.""" + # stop/clean webserver + await self._tcp_site.stop() + await self._apprunner.cleanup() + await self._webapp.shutdown() + await self._webapp.cleanup() + + @property + def base_url(self): + """Return the base URL of this webserver.""" + return self._base_url + + @property + def port(self): + """Return the port of this webserver.""" + return self._bind_port + + def register_dynamic_route(self, path: str, handler: Awaitable, method: str = "*") -> Callable: + """Register a dynamic route on the webserver, returns handler to unregister.""" + if self._dynamic_routes is None: + raise RuntimeError("Dynamic routes are not enabled") + key = f"{method}.{path}" + if key in self._dynamic_routes: + raise RuntimeError(f"Route {path} already registered.") + self._dynamic_routes[key] = handler + + def _remove(): + return self._dynamic_routes.pop(key) + + return _remove + + def unregister_dynamic_route(self, path: str, method: str = "*") -> None: + """Unregister a dynamic route from the webserver.""" + if self._dynamic_routes is None: + raise RuntimeError("Dynamic routes are not enabled") + key = f"{method}.{path}" + self._dynamic_routes.pop(key) + + async def serve_static(self, file_path: str, _request: web.Request) -> web.FileResponse: + """Serve file response.""" + headers = {"Cache-Control": "no-cache"} + return web.FileResponse(file_path, headers=headers) + + async def _handle_catch_all(self, request: web.Request) -> web.Response: + """Redirect request to correct destination.""" + # find handler for the request + for key in (f"{request.method}.{request.path}", f"*.{request.path}"): + if handler := self._dynamic_routes.get(key): + return await handler(request) + # deny all other requests + self.logger.debug( + "Received unhandled %s request to %s from %s\nheaders: %s\n", + request.method, + request.path, + request.remote, + request.headers, + ) + return web.Response(status=404) diff --git a/music_assistant/server/models/core_controller.py b/music_assistant/server/models/core_controller.py new file mode 100644 index 000000000..edace3264 --- /dev/null +++ b/music_assistant/server/models/core_controller.py @@ -0,0 +1,48 @@ +"""Model/base for a Core controller within Music Assistant.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from music_assistant.constants import CONF_LOG_LEVEL, ROOT_LOGGER_NAME + +if TYPE_CHECKING: + from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType + from music_assistant.server import MusicAssistant + + +class CoreController: + """Base representation of a Core controller within Music Assistant.""" + + name: str + friendly_name: str + + def __init__(self, mass: MusicAssistant) -> None: + """Initialize MusicProvider.""" + self.mass = mass + self.logger = logging.getLogger(f"{ROOT_LOGGER_NAME}.{self.name}") + log_level = self.mass.config.get_raw_core_config_value(self.name, CONF_LOG_LEVEL, "GLOBAL") + if log_level != "GLOBAL": + self.logger.setLevel(log_level) + + async def get_config_entries( + self, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, + ) -> tuple[ConfigEntry, ...]: + """Return all Config Entries for this core module (if any).""" + + async def setup(self) -> None: + """Async initialize of module.""" + + async def close(self) -> None: + """Handle logic on server stop.""" + + async def reload(self) -> None: + """Reload this core controller.""" + await self.close() + log_level = self.mass.config.get_raw_core_config_value(self.name, CONF_LOG_LEVEL, "GLOBAL") + if log_level == "GLOBAL": + log_level = logging.getLogger(ROOT_LOGGER_NAME).level + self.logger.setLevel(log_level) + await self.setup() diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index 6127da977..8edd260b3 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -53,25 +53,21 @@ async def cmd_pause(self, player_id: str) -> None: """ @abstractmethod - async def cmd_play_media( + async def cmd_play_url( self, player_id: str, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, - flow_mode: bool = False, + url: str, + queue_item: QueueItem | None, ) -> None: - """Send PLAY MEDIA command to given player. + """Send PLAY URL command to given player. - This is called when the Queue wants the player to start playing a specific QueueItem. - The player implementation can decide how to process the request, such as playing - queue items one-by-one or enqueue all/some items. + This is called when the Queue wants the player to start playing a specific url. + If an item from the Queue is being played, the QueueItem will be provided with + all metadata present. - player_id: player_id of the player to handle the command. - - queue_item: the QueueItem to start playing on the player. - - seek_position: start playing from this specific position. - - fade_in: fade in the music at start (e.g. at resume). - - flow_mode: enable flow mode where the queue tracks are streamed as continuous stream. + - url: the url that the player should start playing. + - queue_item: the QueueItem that is related to the URL (None when playing direct url). """ async def cmd_power(self, player_id: str, powered: bool) -> None: diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 53ff7e04f..18bb4e7b5 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -171,23 +171,28 @@ async def cmd_play(self, player_id: str) -> None: slimproto_prov = self.mass.get_provider("slimproto") await slimproto_prov.cmd_play(player_id) - async def cmd_play_media( + async def cmd_play_url( self, player_id: str, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, - flow_mode: bool = False, + url: str, + queue_item: QueueItem | None, ) -> None: - """Send PLAY MEDIA command to given player.""" + """Send PLAY URL command to given player. + + This is called when the Queue wants the player to start playing a specific url. + If an item from the Queue is being played, the QueueItem will be provided with + all metadata present. + + - player_id: player_id of the player to handle the command. + - url: the url that the player should start playing. + - queue_item: the QueueItem that is related to the URL (None when playing direct url). + """ # simply forward to underlying slimproto player slimproto_prov = self.mass.get_provider("slimproto") - await slimproto_prov.cmd_play_media( + await slimproto_prov.cmd_play_url( player_id, + url=url, queue_item=queue_item, - seek_position=seek_position, - fade_in=fade_in, - flow_mode=flow_mode, ) async def cmd_pause(self, player_id: str) -> None: diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index d6f04db9a..8d930bcf6 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -103,8 +103,7 @@ class CastPlayer: logger: Logger status_listener: CastStatusListener | None = None mz_controller: MultizoneController | None = None - next_item: str | None = None - flow_mode_active: bool = False + next_url: str | None = None active_group: str | None = None @@ -185,27 +184,26 @@ async def cmd_play(self, player_id: str) -> None: castplayer = self.castplayers[player_id] await asyncio.to_thread(castplayer.cc.media_controller.play) - async def cmd_play_media( + async def cmd_play_url( self, player_id: str, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, - flow_mode: bool = False, + url: str, + queue_item: QueueItem | None, ) -> None: - """Send PLAY MEDIA command to given player.""" + """Send PLAY URL command to given player. + + This is called when the Queue wants the player to start playing a specific url. + If an item from the Queue is being played, the QueueItem will be provided with + all metadata present. + + - player_id: player_id of the player to handle the command. + - url: the url that the player should start playing. + - queue_item: the QueueItem that is related to the URL (None when playing direct url). + """ castplayer = self.castplayers[player_id] - url = await self.mass.streams.resolve_stream_url( - queue_item=queue_item, - player_id=player_id, - seek_position=seek_position, - fade_in=fade_in, - flow_mode=flow_mode, - ) - castplayer.flow_mode_active = flow_mode - # in flow mode, we just send the url and the metadata is of no use - if flow_mode: + # in flow/direct url mode, we just send the url and the metadata is of no use + if not queue_item: await asyncio.to_thread( castplayer.cc.play_media, url, @@ -214,7 +212,7 @@ async def cmd_play_media( thumb=MASS_LOGO_ONLINE, media_info={ "customData": { - "queue_item_id": queue_item.queue_item_id, + "queue_item_id": "flow", } }, ) @@ -232,7 +230,7 @@ async def cmd_play_media( # make sure that media controller app is launched await self._launch_app(castplayer) # send queue info to the CC - castplayer.next_item = None + castplayer.next_url = None media_controller = castplayer.cc.media_controller await asyncio.to_thread(media_controller.send_message, queuedata, True) @@ -446,8 +444,8 @@ def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus): # enqueue next item if needed if castplayer.player.state == PlayerState.PLAYING and ( prev_item_id != castplayer.player.current_item_id - or not castplayer.next_item - or castplayer.next_item == castplayer.player.current_item_id + or not castplayer.next_url + or castplayer.next_url == castplayer.player.current_url ): asyncio.run_coroutine_threadsafe( self._enqueue_next_track(castplayer, queue_item_id), self.mass.loop @@ -487,35 +485,34 @@ def on_new_connection_status(self, castplayer: CastPlayer, status: ConnectionSta async def _enqueue_next_track(self, castplayer: CastPlayer, current_queue_item_id: str) -> None: """Enqueue the next track of the MA queue on the CC queue.""" - if castplayer.flow_mode_active: - # not possible when we're in flow mode - return - - if not current_queue_item_id: - return # guard try: - next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track( + next_url, next_item, _ = await self.mass.players.queues.preload_next_url( castplayer.player_id, current_queue_item_id ) except QueueEmpty: return - if castplayer.next_item == next_item.queue_item_id: + if castplayer.next_url == next_url: return # already set ?! - castplayer.next_item = next_item.queue_item_id + castplayer.next_url = next_url - if crossfade: - self.logger.warning( - "Crossfade requested but Chromecast does not support crossfading," - " consider using flow mode to enable crossfade on a Chromecast." + # in flow/direct url mode, we just send the url and the metadata is of no use + if not next_item: + await asyncio.to_thread( + castplayer.cc.play_media, + next_url, + content_type=f"audio/{next_url.split('.')[-1]}", + title="Music Assistant", + thumb=MASS_LOGO_ONLINE, + enqueue=True, + media_info={ + "customData": { + "queue_item_id": "flow", + } + }, ) - - url = await self.mass.streams.resolve_stream_url( - queue_item=next_item, - player_id=castplayer.player_id, - auto_start_runner=False, - ) - cc_queue_items = [self._create_queue_item(next_item, url)] + return + cc_queue_items = [self._create_queue_item(next_item, next_url)] queuedata = { "type": "QUEUE_INSERT", diff --git a/music_assistant/server/providers/deezer/__init__.py b/music_assistant/server/providers/deezer/__init__.py index bd1da036a..c85362996 100644 --- a/music_assistant/server/providers/deezer/__init__.py +++ b/music_assistant/server/providers/deezer/__init__.py @@ -26,6 +26,7 @@ from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, BrowseFolder, ItemMapping, MediaItemImage, @@ -378,7 +379,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails | None: return StreamDetails( item_id=item_id, provider=self.instance_id, - content_type=ContentType.try_parse(url_details["format"].split("_")[0]), + audio_format=AudioFormat( + content_type=ContentType.try_parse(url_details["format"].split("_")[0]) + ), duration=int(song_data["DURATION"]), data=url, expires=url_details["exp"], diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 13950dbe5..115664aa2 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -119,7 +119,7 @@ class DLNAPlayer: # Track BOOTID in SSDP advertisements for device changes bootid: int | None = None last_seen: float = field(default_factory=time.time) - next_item: str | None = None + next_url: str | None = None supports_next_uri = True end_of_track_reached = False @@ -224,7 +224,7 @@ async def unload(self) -> None: Called when provider is deregistered (e.g. MA exiting or config reloading). """ - self.mass.webserver.unregister_route("/notify", "NOTIFY") + self.mass.streams.unregister_dynamic_route("/notify", "NOTIFY") async with asyncio.TaskGroup() as tg: for dlna_player in self.dlnaplayers.values(): tg.create_task(self._device_disconnect(dlna_player)) @@ -241,7 +241,7 @@ async def cmd_stop(self, player_id: str) -> None: """Send STOP command to given player.""" dlna_player = self.dlnaplayers[player_id] dlna_player.end_of_track_reached = False - dlna_player.next_item = None + dlna_player.next_url = None assert dlna_player.device is not None await dlna_player.device.async_stop() @@ -253,29 +253,29 @@ async def cmd_play(self, player_id: str) -> None: await dlna_player.device.async_play() @catch_request_errors - async def cmd_play_media( + async def cmd_play_url( self, player_id: str, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, - flow_mode: bool = False, + url: str, + queue_item: QueueItem | None, ) -> None: - """Send PLAY MEDIA command to given player.""" + """Send PLAY URL command to given player. + + This is called when the Queue wants the player to start playing a specific url. + If an item from the Queue is being played, the QueueItem will be provided with + all metadata present. + + - player_id: player_id of the player to handle the command. + - url: the url that the player should start playing. + - queue_item: the QueueItem that is related to the URL (None when playing direct url). + """ dlna_player = self.dlnaplayers[player_id] # always clear queue (by sending stop) first if dlna_player.device.can_stop: await self.cmd_stop(player_id) - url = await self.mass.streams.resolve_stream_url( - queue_item=queue_item, - player_id=dlna_player.udn, - seek_position=seek_position, - fade_in=fade_in, - flow_mode=flow_mode, - ) - didl_metadata = create_didl_metadata(self.mass, url, queue_item, flow_mode) + didl_metadata = create_didl_metadata(self.mass, url, queue_item) await dlna_player.device.async_set_transport_uri(url, queue_item.name, didl_metadata) # Play it await dlna_player.device.async_wait_for_can_play(10) @@ -535,44 +535,37 @@ async def _enqueue_next_track( self, dlna_player: DLNAPlayer, current_queue_item_id: str ) -> None: """Enqueue the next track of the MA queue on the CC queue.""" - if not current_queue_item_id: - return # guard - if not self.mass.players.queues.get_item(dlna_player.udn, current_queue_item_id): - return # guard try: - next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track( + ( + next_url, + next_item, + _, + ) = await self.mass.players.queues.preload_next_url( dlna_player.udn, current_queue_item_id ) except QueueEmpty: return - if dlna_player.next_item == next_item.queue_item_id: + if dlna_player.next_url == next_url: return # already set ?! - dlna_player.next_item = next_item.queue_item_id + dlna_player.next_url = next_url # no need to try setting the next url if we already know the player does not support it if not dlna_player.supports_next_uri: return # send queue item to dlna queue - url = await self.mass.streams.resolve_stream_url( - queue_item=next_item, - player_id=dlna_player.udn, - # DLNA pre-caches pretty aggressively so do not yet start the runner - auto_start_runner=False, - ) - didl_metadata = create_didl_metadata(self.mass, url, next_item) + didl_metadata = create_didl_metadata(self.mass, next_url, next_item) + title = next_item.name if next_item else "Music Assistant" try: - await dlna_player.device.async_set_next_transport_uri( - url, next_item.name, didl_metadata - ) + await dlna_player.device.async_set_next_transport_uri(next_url, title, didl_metadata) except UpnpError: dlna_player.supports_next_uri = False self.logger.info("Player does not support next uri") self.logger.debug( "Enqued next track (%s) to player %s", - next_item.name, + title, dlna_player.player.display_name, ) @@ -596,8 +589,8 @@ async def _update_player(self, dlna_player: DLNAPlayer) -> None: # enqueue next item if needed if dlna_player.player.state == PlayerState.PLAYING and ( prev_item_id != current_item_id - or not dlna_player.next_item - or dlna_player.next_item == current_item_id + or not dlna_player.next_url + or dlna_player.next_url == current_url ): self.mass.create_task(self._enqueue_next_track(dlna_player, current_item_id)) # if player does not support next uri, manual play it @@ -605,9 +598,9 @@ async def _update_player(self, dlna_player: DLNAPlayer) -> None: not dlna_player.supports_next_uri and prev_state == PlayerState.PLAYING and current_state == PlayerState.IDLE - and dlna_player.next_item + and dlna_player.next_url and dlna_player.end_of_track_reached ): - await self.mass.players.queues.play_index(dlna_player.udn, dlna_player.next_item) + await self.cmd_play_url(dlna_player.udn, dlna_player.next_url) dlna_player.end_of_track_reached = False - dlna_player.next_item = None + dlna_player.next_url = None diff --git a/music_assistant/server/providers/dlna/helpers.py b/music_assistant/server/providers/dlna/helpers.py index bc3b9114f..0cbe74b91 100644 --- a/music_assistant/server/providers/dlna/helpers.py +++ b/music_assistant/server/providers/dlna/helpers.py @@ -23,7 +23,7 @@ def __init__( """Initialize.""" self.mass = mass self.event_handler = UpnpEventHandler(self, requester) - self.mass.webserver.register_route("/notify", self._handle_request, method="NOTIFY") + self.mass.streams.register_dynamic_route("/notify", self._handle_request, method="NOTIFY") async def _handle_request(self, request: Request) -> Response: """Handle incoming requests.""" @@ -40,4 +40,4 @@ async def _handle_request(self, request: Request) -> Response: @property def callback_url(self) -> str: """Return callback URL on which we are callable.""" - return f"{self.mass.webserver.base_url}/notify" + return f"{self.mass.streams.base_url}/notify" diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index e42351496..6a2d6b92e 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -26,6 +26,7 @@ from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, BrowseFolder, ContentType, ImageType, @@ -560,12 +561,14 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: return StreamDetails( provider=self.instance_id, item_id=item_id, - content_type=prov_mapping.content_type, + audio_format=AudioFormat( + content_type=prov_mapping.content_type, + sample_rate=prov_mapping.sample_rate, + bit_depth=prov_mapping.bit_depth, + ), media_type=MediaType.TRACK, duration=db_item.duration, size=file_item.file_size, - sample_rate=prov_mapping.sample_rate, - bit_depth=prov_mapping.bit_depth, direct=file_item.local_path, can_seek=prov_mapping.content_type in SEEKABLE_FILES, ) diff --git a/music_assistant/server/providers/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index 15d6a0e59..f4f30cb66 100644 --- a/music_assistant/server/providers/plex/__init__.py +++ b/music_assistant/server/providers/plex/__init__.py @@ -35,6 +35,7 @@ from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, ItemMapping, MediaItem, MediaItemImage, @@ -576,9 +577,11 @@ async def get_stream_details(self, item_id: str) -> StreamDetails | None: stream_details = StreamDetails( item_id=plex_track.key, provider=self.instance_id, - content_type=media_type, + audio_format=AudioFormat( + content_type=media_type, + channels=media.audioChannels, + ), duration=plex_track.duration, - channels=media.audioChannels, data=plex_track, ) @@ -588,18 +591,18 @@ async def get_stream_details(self, item_id: str) -> StreamDetails | None: if media_type != ContentType.M4A: stream_details.direct = self._plex_server.url(media_part.key, True) if audio_stream.samplingRate: - stream_details.sample_rate = audio_stream.samplingRate + stream_details.audio_format.sample_rate = audio_stream.samplingRate if audio_stream.bitDepth: - stream_details.bit_depth = audio_stream.bitDepth + stream_details.audio_format.bit_depth = audio_stream.bitDepth else: url = plex_track.getStreamURL() media_info = await parse_tags(url) - stream_details.channels = media_info.channels - stream_details.content_type = ContentType.try_parse(media_info.format) - stream_details.sample_rate = media_info.sample_rate - stream_details.bit_depth = media_info.bits_per_sample + stream_details.audio_format.channels = media_info.channels + stream_details.audio_format.content_type = ContentType.try_parse(media_info.format) + stream_details.audio_format.sample_rate = media_info.sample_rate + stream_details.audio_format.bit_depth = media_info.bits_per_sample return stream_details diff --git a/music_assistant/server/providers/qobuz/__init__.py b/music_assistant/server/providers/qobuz/__init__.py index 3d843b5c2..54f93c9f5 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -19,6 +19,7 @@ Album, AlbumType, Artist, + AudioFormat, ContentType, ImageType, MediaItemImage, @@ -372,10 +373,12 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: return StreamDetails( item_id=str(item_id), provider=self.instance_id, - content_type=content_type, + audio_format=AudioFormat( + content_type=content_type, + sample_rate=int(streamdata["sampling_rate"] * 1000), + bit_depth=streamdata["bit_depth"], + ), duration=streamdata["duration"], - sample_rate=int(streamdata["sampling_rate"] * 1000), - bit_depth=streamdata["bit_depth"], data=streamdata, # we need these details for reporting playback expires=time.time() + 3600, # not sure about the real allowed value direct=streamdata["url"], diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index 36efa06f8..de3251518 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -10,6 +10,7 @@ from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import LinkType, ProviderFeature from music_assistant.common.models.media_items import ( + AudioFormat, BrowseFolder, ContentType, ImageType, @@ -322,7 +323,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: return StreamDetails( provider=self.domain, item_id=item_id, - content_type=ContentType.try_parse(stream.codec), + audio_format=AudioFormat( + content_type=ContentType.try_parse(stream.codec), + ), media_type=MediaType.RADIO, data=url_resolved, expires=time() + 24 * 3600, diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index a7cddde1c..a42b20f73 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -9,6 +9,7 @@ from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qsl, urlparse from aioslimproto.client import PlayerState as SlimPlayerState from aioslimproto.client import SlimClient @@ -49,7 +50,7 @@ # sync constants MIN_DEVIATION_ADJUST = 10 # 10 milliseconds MAX_DEVIATION_ADJUST = 20000 # 10 seconds -MIN_REQ_PLAYPOINTS = 2 # we need at least 8 measurements +MIN_REQ_PLAYPOINTS = 2 # we need at least 2 measurements MIN_REQ_MILLISECONDS = 500 # TODO: Implement display support @@ -202,10 +203,10 @@ async def handle_setup(self) -> None: if self.config.get_value(CONF_DISCOVERY): self._socket_servers.append( await start_discovery( - self.mass.base_ip, + self.mass.streams.publish_ip, self.port, self._cli.cli_port if enable_telnet else None, - self.mass.webserver.port if enable_json else None, + self.mass.streams.publish_ip if enable_json else None, "Music Assistant", self.mass.server_id, ) @@ -355,71 +356,78 @@ async def cmd_play(self, player_id: str) -> None: continue tg.create_task(client.play()) - async def cmd_play_media( + async def cmd_play_url( self, player_id: str, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, - flow_mode: bool = False, + url: str, + queue_item: QueueItem | None, ) -> None: - """Send PLAY MEDIA command to given player. + """Send PLAY URL command to given player. - This is called when the Queue wants the player to start playing a specific QueueItem. - The player implementation can decide how to process the request, such as playing - queue items one-by-one or enqueue all/some items. + This is called when the Queue wants the player to start playing a specific url. + If an item from the Queue is being played, the QueueItem will be provided with + all metadata present. - player_id: player_id of the player to handle the command. - - queue_item: the QueueItem to start playing on the player. - - seek_position: start playing from this specific position. - - fade_in: fade in the music at start (e.g. at resume). + - url: the url that the player should start playing. + - queue_item: the QueueItem that is related to the URL (None when playing direct url). """ # send stop first await self.cmd_stop(player_id) player = self.mass.players.get(player_id) - # make sure that the (master) player is powered - # powering any client players must be done in other ways - if not player.synced_to: - await self._socket_clients[player_id].power(True) + if player.synced_to: + raise RuntimeError("A synced player cannot receive play commands directly") - # forward command to player and any connected sync child's + stream_job = None sync_clients = [x for x in self._get_sync_clients(player_id)] + if sync_clients > 1: + # we need to form/join a multiclient stream job + if player.active_source == player.player_id: + # this player is a sync leader of a regular sync group + # parse original params from the regular stream url + parsed_url = urlparse(url) + start_queue_item_id = parsed_url.path.rsplit("/")[-1].split(".")[0] + params = parse_qsl(parsed_url.query) + stream_job = await self.mass.streams.create_multi_client_stream_job( + queue_id=player.player_id, + start_queue_item_id=start_queue_item_id, + seek_position=int(params.get("seek_position", 0)), + fade_in=bool(params.get("fade_in", 0)), + ) + elif player.active_source in self.mass.streams.multi_client_jobs: + # join existing multiclient stream job (from universal group) + stream_job = self.mass.streams.multi_client_jobs[player.active_source] + else: + # edge case: regular url requested while synced + stream_job = None + + # forward command to player and any connected sync child's async with asyncio.TaskGroup() as tg: for client in sync_clients: + if stream_job: + url = await stream_job.resolve_stream_url(client.player_id) tg.create_task( - self._handle_play_media( + self._handle_play_url( client, + url=url, queue_item=queue_item, - seek_position=seek_position, - fade_in=fade_in, send_flush=True, - flow_mode=flow_mode, auto_play=len(sync_clients) == 1, ) ) - async def _handle_play_media( + async def _handle_play_url( self, client: SlimClient, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, + url: str, + queue_item: QueueItem | None, send_flush: bool = True, crossfade: bool = False, - flow_mode: bool = False, auto_play: bool = False, ) -> None: """Handle PlayMedia on slimproto player(s).""" player_id = client.player_id - - url = await self.mass.streams.resolve_stream_url( - queue_item=queue_item, - player_id=player_id, - seek_position=seek_position, - fade_in=fade_in, - flow_mode=flow_mode, - ) if crossfade: transition_duration = await self.mass.config.get_player_config_value( player_id, CONF_CROSSFADE_DURATION @@ -430,7 +438,9 @@ async def _handle_play_media( await client.play_url( url=url, mime_type=f"audio/{url.split('.')[-1]}", - metadata={"item_id": queue_item.queue_item_id, "title": queue_item.name}, + metadata={"item_id": queue_item.queue_item_id, "title": queue_item.name} + if queue_item + else None, send_flush=send_flush, transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE, transition_duration=transition_duration, @@ -677,15 +687,18 @@ async def _handle_decoder_ready(self, client: SlimClient) -> None: return if client.state == SlimPlayerState.STOPPED: return + if player.active_source != player.player_id: + return try: - next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track( + next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( client.player_id, client.current_metadata["item_id"] ) async with asyncio.TaskGroup() as tg: for client in self._get_sync_clients(client.player_id): tg.create_task( - self._handle_play_media( + self._handle_play_url( client, + url=next_url, queue_item=next_item, send_flush=False, crossfade=crossfade, diff --git a/music_assistant/server/providers/slimproto/cli.py b/music_assistant/server/providers/slimproto/cli.py index d758dd957..c585c169a 100644 --- a/music_assistant/server/providers/slimproto/cli.py +++ b/music_assistant/server/providers/slimproto/cli.py @@ -145,8 +145,8 @@ async def setup(self) -> None: """Handle async initialization of the plugin.""" if self.enable_json: self.logger.info("Registering jsonrpc endpoints on the webserver") - self.mass.webserver.register_route("/jsonrpc.js", self._handle_jsonrpc) - self.mass.webserver.register_route("/cometd", self._handle_cometd) + self.mass.streams.register_dynamic_route("/jsonrpc.js", self._handle_jsonrpc) + self.mass.streams.register_dynamic_route("/cometd", self._handle_cometd) self._unsub_callback = self.mass.subscribe( self._on_mass_event, (EventType.PLAYER_UPDATED, EventType.QUEUE_UPDATED), @@ -163,8 +163,8 @@ async def unload(self) -> None: Called when provider is deregistered (e.g. MA exiting or config reloading). """ - self.mass.webserver.unregister_route("/jsonrpc.js") - self.mass.webserver.unregister_route("/cometd") + self.mass.streams.unregister_dynamic_route("/jsonrpc.js") + self.mass.streams.unregister_dynamic_route("/cometd") if self._unsub_callback: self._unsub_callback() self._unsub_callback = None @@ -743,8 +743,8 @@ async def _handle_serverstatus( players.append(player_item_from_mass(start_index + index, mass_player)) return ServerStatusResponse( { - "httpport": self.mass.webserver.port, - "ip": self.mass.base_ip, + "httpport": self.mass.streams.port, + "ip": self.mass.streams.publish_ip, "version": "7.999.999", "uuid": self.mass.server_id, # TODO: set these vars ? diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index ccd3e57c8..4570d3ae5 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -15,13 +15,7 @@ from soco.groups import ZoneGroup from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType -from music_assistant.common.models.enums import ( - ContentType, - MediaType, - PlayerFeature, - PlayerState, - PlayerType, -) +from music_assistant.common.models.enums import MediaType, PlayerFeature, PlayerState, PlayerType from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty from music_assistant.common.models.player import DeviceInfo, Player from music_assistant.common.models.queue_item import QueueItem @@ -78,7 +72,7 @@ class SonosPlayer: soco: soco.SoCo player: Player is_stereo_pair: bool = False - next_item: str | None = None + next_url: str | None = None elapsed_time: int = 0 current_item_id: str | None = None radio_mode_started: float | None = None @@ -266,15 +260,22 @@ async def cmd_play(self, player_id: str) -> None: return await asyncio.to_thread(sonos_player.soco.play) - async def cmd_play_media( + async def cmd_play_url( self, player_id: str, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, - flow_mode: bool = False, + url: str, + queue_item: QueueItem | None, ) -> None: - """Send PLAY MEDIA command to given player.""" + """Send PLAY URL command to given player. + + This is called when the Queue wants the player to start playing a specific url. + If an item from the Queue is being played, the QueueItem will be provided with + all metadata present. + + - player_id: player_id of the player to handle the command. + - url: the url that the player should start playing. + - queue_item: the QueueItem that is related to the URL (None when playing direct url). + """ sonos_player = self.sonosplayers[player_id] if not sonos_player.soco.is_coordinator: self.logger.debug( @@ -283,34 +284,21 @@ async def cmd_play_media( ) return # always stop and clear queue first - sonos_player.next_item = None + sonos_player.next_url = None await asyncio.to_thread(sonos_player.soco.stop) await asyncio.to_thread(sonos_player.soco.clear_queue) - radio_mode = ( - flow_mode or not queue_item.duration or queue_item.media_type == MediaType.RADIO - ) - url = await self.mass.streams.resolve_stream_url( - queue_item=queue_item, - player_id=sonos_player.player_id, - seek_position=seek_position, - fade_in=fade_in, - flow_mode=flow_mode, - output_codec=ContentType.MP3 if radio_mode else None, + radio_mode = queue_item is not None and ( + not queue_item.duration or queue_item.media_type == MediaType.RADIO ) if radio_mode: sonos_player.radio_mode_started = time.time() url = url.replace("http", "x-rincon-mp3radio") - metadata = create_didl_metadata(self.mass, url, queue_item, flow_mode) - # sonos does multiple get requests if no duration is known - # our stream engine does not like that, hence the workaround - self.mass.streams.workaround_players.add(sonos_player.player_id) + metadata = create_didl_metadata(self.mass, url, queue_item) await asyncio.to_thread(sonos_player.soco.play_uri, url, meta=metadata) else: sonos_player.radio_mode_started = None - await self._enqueue_item( - sonos_player, queue_item=queue_item, url=url, flow_mode=flow_mode - ) + await self._enqueue_item(sonos_player, url=url, queue_item=queue_item) await asyncio.to_thread(sonos_player.soco.play_from_queue, 0) async def cmd_pause(self, player_id: str) -> None: @@ -536,15 +524,15 @@ async def _enqueue_next_track( if not self.mass.players.queues.get_item(sonos_player.player_id, current_queue_item_id): return # guard try: - next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track( + next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( sonos_player.player_id, current_queue_item_id ) except QueueEmpty: return - if sonos_player.next_item == next_item.queue_item_id: + if sonos_player.next_url == next_url: return # already set ?! - sonos_player.next_item = next_item.queue_item_id + sonos_player.next_url = next_url # set crossfade according to queue mode if sonos_player.soco.cross_fade != crossfade: @@ -556,25 +544,16 @@ def set_crossfade(): await asyncio.to_thread(set_crossfade) # send queue item to sonos queue - is_radio = next_item.media_type != MediaType.TRACK - url = await self.mass.streams.resolve_stream_url( - queue_item=next_item, - player_id=sonos_player.player_id, - # Sonos pre-caches pretty aggressively so do not yet start the runner - auto_start_runner=False, - output_codec=ContentType.MP3 if is_radio else None, - ) - await self._enqueue_item(sonos_player, queue_item=next_item, url=url) + await self._enqueue_item(sonos_player, url=next_url, queue_item=next_item) async def _enqueue_item( self, sonos_player: SonosPlayer, - queue_item: QueueItem, url: str, - flow_mode: bool = False, + queue_item: QueueItem | None = None, ) -> None: """Enqueue a queue item to the Sonos player Queue.""" - metadata = create_didl_metadata(self.mass, url, queue_item, flow_mode) + metadata = create_didl_metadata(self.mass, url, queue_item) await asyncio.to_thread( sonos_player.soco.avTransport.AddURIToQueue, [ @@ -586,11 +565,9 @@ async def _enqueue_item( ], timeout=60, ) - if sonos_player.player_id in self.mass.streams.workaround_players: - self.mass.streams.workaround_players.remove(sonos_player.player_id) self.logger.debug( "Enqued track (%s) to player %s", - queue_item.name, + queue_item.name if queue_item else url, sonos_player.player.display_name, ) @@ -623,8 +600,8 @@ async def _update_player(self, sonos_player: SonosPlayer, signal_update: bool = # enqueue next item if needed if sonos_player.player.state == PlayerState.PLAYING and ( prev_item_id != sonos_player.current_item_id - or not sonos_player.next_item - or sonos_player.next_item == sonos_player.current_item_id + or not sonos_player.next_url + or sonos_player.next_url == sonos_player.player.current_url ): self.mass.create_task( self._enqueue_next_track(sonos_player, sonos_player.current_item_id) diff --git a/music_assistant/server/providers/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index d9acbd4c1..151712bcb 100644 --- a/music_assistant/server/providers/soundcloud/__init__.py +++ b/music_assistant/server/providers/soundcloud/__init__.py @@ -12,6 +12,7 @@ from music_assistant.common.models.errors import InvalidDataError, LoginFailed from music_assistant.common.models.media_items import ( Artist, + AudioFormat, ContentType, ImageType, MediaItemImage, @@ -288,6 +289,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: provider=self.instance_id, item_id=item_id, content_type=ContentType.try_parse(stream_format), + audio_format=AudioFormat( + content_type=ContentType.try_parse(stream_format), + ), direct=url, ) diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index 908c27e4f..5ba4cf68e 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -23,6 +23,7 @@ Album, AlbumType, Artist, + AudioFormat, ContentType, ImageType, MediaItemImage, @@ -355,7 +356,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: return StreamDetails( item_id=track.item_id, provider=self.instance_id, - content_type=ContentType.OGG, + audio_format=AudioFormat( + content_type=ContentType.OGG, + ), duration=track.duration, ) diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index 92791a095..909270398 100644 --- a/music_assistant/server/providers/tidal/__init__.py +++ b/music_assistant/server/providers/tidal/__init__.py @@ -31,6 +31,7 @@ from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, ContentType, ItemMapping, MediaItemImage, @@ -364,9 +365,11 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: return StreamDetails( item_id=track.id, provider=self.instance_id, - content_type=ContentType.FLAC, - sample_rate=44100, - bit_depth=16, + audio_format=AudioFormat( + content_type=ContentType.FLAC, + sample_rate=44100, + bit_depth=16, + ), duration=track.duration, direct=url, ) diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index 8f22a4539..62e313883 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -12,6 +12,7 @@ from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( + AudioFormat, ContentType, ImageType, MediaItemImage, @@ -201,6 +202,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: provider=self.instance_id, item_id=item_id, content_type=ContentType.UNKNOWN, + audio_format=AudioFormat( + content_type=ContentType.UNKNOWN, + ), media_type=MediaType.RADIO, data=item_id, ) @@ -221,7 +225,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: return StreamDetails( provider=self.domain, item_id=item_id, - content_type=ContentType(stream["media_type"]), + audio_format=AudioFormat( + content_type=ContentType(stream["media_type"]), + ), media_type=MediaType.RADIO, data=url, expires=time() + 24 * 3600, diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index 46792a9f5..1aa4f0c85 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -8,6 +8,7 @@ import asyncio from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qsl, urlparse from music_assistant.common.models.config_entries import ( CONF_ENTRY_FLOW_MODE, @@ -181,24 +182,21 @@ async def cmd_play(self, player_id: str) -> None: ): tg.create_task(self.mass.players.cmd_play(member.player_id)) - async def cmd_play_media( + async def cmd_play_url( self, player_id: str, - queue_item: QueueItem, - seek_position: int = 0, - fade_in: bool = False, - flow_mode: bool = False, + url: str, + queue_item: QueueItem | None, ) -> None: - """Send PLAY MEDIA command to given player. + """Send PLAY URL command to given player. - This is called when the Queue wants the player to start playing a specific QueueItem. - The player implementation can decide how to process the request, such as playing - queue items one-by-one or enqueue all/some items. + This is called when the Queue wants the player to start playing a specific url. + If an item from the Queue is being played, the QueueItem will be provided with + all metadata present. - player_id: player_id of the player to handle the command. - - queue_item: the QueueItem to start playing on the player. - - seek_position: start playing from this specific position. - - fade_in: fade in the music at start (e.g. at resume). + - url: the url that the player should start playing. + - queue_item: the QueueItem that is related to the URL (None when playing direct url). """ # send stop first await self.cmd_stop(player_id) @@ -206,20 +204,36 @@ async def cmd_play_media( await self.cmd_power(player_id, True) group_player = self.mass.players.get(player_id) group_player.extra_data["optimistic_state"] = PlayerState.PLAYING + + # we need to form/join a multiclient stream job + if group_player.active_source == group_player.player_id: + # parse original params from the regular stream url + parsed_url = urlparse(url) + start_queue_item_id = parsed_url.path.split("/")[-1].split(".")[0] + params = parse_qsl(parsed_url.query) + stream_job = await self.mass.streams.create_multi_client_stream_job( + queue_id=group_player.player_id, + start_queue_item_id=start_queue_item_id, + seek_position=int(params.get("seek_position", 0)), + fade_in=bool(params.get("fade_in", 0)), + ) + elif group_player.active_source in self.mass.streams.multi_client_jobs: + # join existing multiclient stream job (from parent group) + stream_job = self.mass.streams.multi_client_jobs[group_player.active_source] + else: + # edge case: regular url requested not related to the queue ?! + stream_job = None + # forward command to all (powered) group child's async with asyncio.TaskGroup() as tg: for member in self._get_active_members( player_id, only_powered=True, skip_sync_childs=True ): player_prov = self.mass.players.get_player_provider(member.player_id) + if stream_job: + url = await stream_job.resolve_stream_url(member.player_id) tg.create_task( - player_prov.cmd_play_media( - member.player_id, - queue_item=queue_item, - seek_position=seek_position, - fade_in=fade_in, - flow_mode=flow_mode, - ) + player_prov.cmd_play_url(member.player_id, url=url, queue_item=queue_item) ) async def cmd_pause(self, player_id: str) -> None: diff --git a/music_assistant/server/providers/url/__init__.py b/music_assistant/server/providers/url/__init__.py index 1788a2cb0..b7ad42ffc 100644 --- a/music_assistant/server/providers/url/__init__.py +++ b/music_assistant/server/providers/url/__init__.py @@ -9,6 +9,7 @@ from music_assistant.common.models.enums import ContentType, ImageType, MediaType from music_assistant.common.models.media_items import ( Artist, + AudioFormat, MediaItemImage, MediaItemType, ProviderMapping, @@ -181,10 +182,12 @@ async def get_stream_details(self, item_id: str) -> StreamDetails | None: return StreamDetails( provider=self.instance_id, item_id=item_id, - content_type=ContentType.try_parse(media_info.format), + audio_format=AudioFormat( + content_type=ContentType.try_parse(media_info.format), + sample_rate=media_info.sample_rate, + bit_depth=media_info.bits_per_sample, + ), media_type=MediaType.RADIO if is_radio else MediaType.TRACK, - sample_rate=media_info.sample_rate, - bit_depth=media_info.bits_per_sample, direct=None if is_radio and not mpeg_dash_stream else url, data=url, ) diff --git a/music_assistant/server/providers/websocket_api/__init__.py b/music_assistant/server/providers/websocket_api/__init__.py deleted file mode 100644 index f55b7d71a..000000000 --- a/music_assistant/server/providers/websocket_api/__init__.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Default Music Assistant Websocket API.""" -from __future__ import annotations - -import asyncio -import inspect -import logging -import weakref -from concurrent import futures -from contextlib import suppress -from typing import TYPE_CHECKING, Any, Final - -from aiohttp import WSMsgType, web - -from music_assistant.common.models.api import ( - ChunkedResultMessage, - CommandMessage, - ErrorResultMessage, - MessageType, - SuccessResultMessage, -) -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType -from music_assistant.common.models.errors import InvalidCommand -from music_assistant.common.models.event import MassEvent -from music_assistant.constants import ROOT_LOGGER_NAME -from music_assistant.server.helpers.api import APICommandHandler, parse_arguments -from music_assistant.server.models.plugin import PluginProvider - -if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ProviderConfig - from music_assistant.common.models.provider import ProviderManifest - from music_assistant.server import MusicAssistant - from music_assistant.server.models import ProviderInstanceType - - -DEBUG = False # Set to True to enable very verbose logging of all incoming/outgoing messages -MAX_PENDING_MSG = 512 -CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError) -LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.websocket_api") - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - prov = WebsocketAPI(mass, manifest, config) - await prov.handle_setup() - return prov - - -async def get_config_entries( - mass: MusicAssistant, - instance_id: str | None = None, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, -) -> tuple[ConfigEntry, ...]: - """ - Return Config entries to setup this provider. - - instance_id: id of an existing provider instance (None if new instance setup). - action: [optional] action key called from config entries UI. - values: the (intermediate) raw values for config entries sent with the action. - """ - # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) - - -class WebsocketAPI(PluginProvider): - """Default Music Assistant Websocket API.""" - - clients: weakref.WeakSet[WebsocketClientHandler] = weakref.WeakSet() - - async def handle_setup(self) -> None: - """Handle async initialization of the plugin.""" - self.mass.webserver.register_route("/ws", self._handle_ws_client) - - async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse: - connection = WebsocketClientHandler(self.mass, request) - try: - self.clients.add(connection) - return await connection.handle_client() - finally: - self.clients.remove(connection) - - async def unload(self) -> None: - """ - Handle unload/close of the provider. - - Called when provider is deregistered (e.g. MA exiting or config reloading). - """ - self.mass.webserver.unregister_route("/ws") - for client in set(self.clients): - await client.disconnect() - - -class WebSocketLogAdapter(logging.LoggerAdapter): - """Add connection id to websocket log messages.""" - - def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: - """Add connid to websocket log messages.""" - return f'[{self.extra["connid"]}] {msg}', kwargs - - -class WebsocketClientHandler: - """Handle an active websocket client connection.""" - - def __init__(self, mass: MusicAssistant, request: web.Request) -> None: - """Initialize an active connection.""" - self.mass = mass - self.request = request - self.wsock = web.WebSocketResponse(heartbeat=55) - self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG) - self._handle_task: asyncio.Task | None = None - self._writer_task: asyncio.Task | None = None - self._logger = WebSocketLogAdapter(LOGGER, {"connid": id(self)}) - - async def disconnect(self) -> None: - """Disconnect client.""" - self._cancel() - if self._writer_task is not None: - await self._writer_task - - async def handle_client(self) -> web.WebSocketResponse: - """Handle a websocket response.""" - # ruff: noqa: PLR0915 - request = self.request - wsock = self.wsock - try: - async with asyncio.timeout(10): - await wsock.prepare(request) - except asyncio.TimeoutError: - self._logger.warning("Timeout preparing request from %s", request.remote) - return wsock - - self._logger.debug("Connection from %s", request.remote) - self._handle_task = asyncio.current_task() - self._writer_task = asyncio.create_task(self._writer()) - - # send server(version) info when client connects - self._send_message(self.mass.get_server_info()) - - # forward all events to clients - def handle_event(event: MassEvent) -> None: - self._send_message(event) - - unsub_callback = self.mass.subscribe(handle_event) - - disconnect_warn = None - - try: - while not wsock.closed: - msg = await wsock.receive() - - if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING): - break - - if msg.type != WSMsgType.TEXT: - disconnect_warn = "Received non-Text message." - break - - if DEBUG: - self._logger.debug("Received: %s", msg.data) - - try: - command_msg = CommandMessage.from_json(msg.data) - except ValueError: - disconnect_warn = f"Received invalid JSON: {msg.data}" - break - - self._handle_command(command_msg) - - except asyncio.CancelledError: - self._logger.debug("Connection closed by client") - - except Exception: # pylint: disable=broad-except - self._logger.exception("Unexpected error inside websocket API") - - finally: - # Handle connection shutting down. - unsub_callback() - self._logger.debug("Unsubscribed from events") - - try: - self._to_write.put_nowait(None) - # Make sure all error messages are written before closing - await self._writer_task - await wsock.close() - except asyncio.QueueFull: # can be raised by put_nowait - self._writer_task.cancel() - - finally: - if disconnect_warn is None: - self._logger.debug("Disconnected") - else: - self._logger.warning("Disconnected: %s", disconnect_warn) - - return wsock - - def _handle_command(self, msg: CommandMessage) -> None: - """Handle an incoming command from the client.""" - self._logger.debug("Handling command %s", msg.command) - - # work out handler for the given path/command - handler = self.mass.command_handlers.get(msg.command) - - if handler is None: - self._send_message( - ErrorResultMessage( - msg.message_id, - InvalidCommand.error_code, - f"Invalid command: {msg.command}", - ) - ) - self._logger.warning("Invalid command: %s", msg.command) - return - - # schedule task to handle the command - asyncio.create_task(self._run_handler(handler, msg)) - - async def _run_handler(self, handler: APICommandHandler, msg: CommandMessage) -> None: - try: - args = parse_arguments(handler.signature, handler.type_hints, msg.args) - result = handler.target(**args) - if inspect.isasyncgen(result): - # async generator = send chunked response - chunk_size = 100 - batch: list[Any] = [] - async for item in result: - batch.append(item) - if len(batch) == chunk_size: - self._send_message(ChunkedResultMessage(msg.message_id, batch)) - batch = [] - # send last chunk - self._send_message(ChunkedResultMessage(msg.message_id, batch, True)) - del batch - return - if asyncio.iscoroutine(result): - result = await result - self._send_message(SuccessResultMessage(msg.message_id, result)) - except Exception as err: # pylint: disable=broad-except - self._logger.exception("Error handling message: %s", msg) - self._send_message( - ErrorResultMessage(msg.message_id, getattr(err, "error_code", 999), str(err)) - ) - - async def _writer(self) -> None: - """Write outgoing messages.""" - # Exceptions if Socket disconnected or cancelled by connection handler - with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS): - while not self.wsock.closed: - if (process := await self._to_write.get()) is None: - break - - if not isinstance(process, str): - message: str = process() - else: - message = process - if DEBUG: - self._logger.debug("Writing: %s", message) - await self.wsock.send_str(message) - - def _send_message(self, message: MessageType) -> None: - """Send a message to the client. - - Closes connection if the client is not reading the messages. - - Async friendly. - """ - _message = message.to_json() - - try: - self._to_write.put_nowait(_message) - except asyncio.QueueFull: - self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG) - - self._cancel() - - def _cancel(self) -> None: - """Cancel the connection.""" - if self._handle_task is not None: - self._handle_task.cancel() - if self._writer_task is not None: - self._writer_task.cancel() diff --git a/music_assistant/server/providers/websocket_api/manifest.json b/music_assistant/server/providers/websocket_api/manifest.json deleted file mode 100644 index 9ef085c2f..000000000 --- a/music_assistant/server/providers/websocket_api/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "type": "plugin", - "domain": "websocket_api", - "name": "Websocket API", - "description": "The default Websocket API for interacting with Music Assistant which is also used by the Music Assistant frontend.", - "codeowners": ["@music-assistant"], - "requirements": [], - "documentation": "", - "multi_instance": false, - "builtin": true, - "hidden": true, - "load_by_default": true, - "icon": "md:webhook" -} diff --git a/music_assistant/server/providers/ytmusic/__init__.py b/music_assistant/server/providers/ytmusic/__init__.py index b579931f1..1ed408c3e 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -24,6 +24,7 @@ Album, AlbumType, Artist, + AudioFormat, ContentType, ImageType, ItemMapping, @@ -480,7 +481,9 @@ async def get_stream_details(self, item_id: str, retry=0) -> StreamDetails: stream_details = StreamDetails( provider=self.instance_id, item_id=item_id, - content_type=ContentType.try_parse(stream_format["mimeType"]), + audio_format=AudioFormat( + content_type=ContentType.try_parse(stream_format["mimeType"]), + ), direct=url, ) if ( @@ -491,9 +494,9 @@ async def get_stream_details(self, item_id: str, retry=0) -> StreamDetails: track_obj["streamingData"].get("expiresInSeconds") ) if stream_format.get("audioChannels") and str(stream_format.get("audioChannels")).isdigit(): - stream_details.channels = int(stream_format.get("audioChannels")) + stream_details.audio_format.channels = int(stream_format.get("audioChannels")) if stream_format.get("audioSampleRate") and stream_format.get("audioSampleRate").isdigit(): - stream_details.sample_rate = int(stream_format.get("audioSampleRate")) + stream_details.audio_format.sample_rate = int(stream_format.get("audioSampleRate")) return stream_details async def _post_data(self, endpoint: str, data: dict[str, str], **kwargs): # noqa: ARG002 diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 8972da829..af3a7dc56 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -12,7 +12,7 @@ from aiohttp import ClientSession, TCPConnector from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf -from music_assistant.common.helpers.util import get_ip, get_ip_pton +from music_assistant.common.helpers.util import get_ip_pton from music_assistant.common.models.api import ServerInfoMessage from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.enums import EventType, ProviderType @@ -66,28 +66,24 @@ class MusicAssistant: loop: asyncio.AbstractEventLoop http_session: ClientSession zeroconf: Zeroconf + config: ConfigController + webserver: WebserverController + cache: CacheController + metadata: MetaDataController + music: MusicController + players: PlayerController + streams: StreamsController def __init__(self, storage_path: str) -> None: """Initialize the MusicAssistant Server.""" self.storage_path = storage_path - self.base_ip = get_ip() # we dynamically register command handlers which can be consumed by the apis self.command_handlers: dict[str, APICommandHandler] = {} self._subscribers: set[EventSubscriptionType] = set() self._available_providers: dict[str, ProviderManifest] = {} self._providers: dict[str, ProviderInstanceType] = {} - # init core controllers - self.config = ConfigController(self) - self.webserver = WebserverController(self) - self.cache = CacheController(self) - self.metadata = MetaDataController(self) - self.music = MusicController(self) - self.players = PlayerController(self) - self.streams = StreamsController(self) self._tracked_tasks: dict[str, asyncio.Task] = {} self.closing = False - # register all api commands (methods with decorator) - self._register_api_commands() async def start(self) -> None: """Start running the Music Assistant server.""" @@ -105,13 +101,21 @@ async def start(self) -> None: ), ) # setup config controller first and fetch important config values + self.config = ConfigController(self) await self.config.setup() LOGGER.info( - "Starting Music Assistant Server (%s) - autodetected IP-address: %s", + "Starting Music Assistant Server (%s)", self.server_id, - self.base_ip, ) # setup other core controllers + self.cache = CacheController(self) + self.webserver = WebserverController(self) + self.metadata = MetaDataController(self) + self.music = MusicController(self) + self.players = PlayerController(self) + self.streams = StreamsController(self) + # register all api commands (methods with decorator) + self._register_api_commands() await self.cache.setup() await self.webserver.setup() await self.music.setup() @@ -321,7 +325,8 @@ def register_api_command( handler: Callable, ) -> None: """Dynamically register a command on the API.""" - assert command not in self.command_handlers, "Command already registered" + if command in self.command_handlers: + raise RuntimeError(f"Command {command} is already registered") self.command_handlers[command] = APICommandHandler.parse(command, handler) async def load_provider(self, conf: ProviderConfig) -> None: # noqa: C901 @@ -498,8 +503,8 @@ def _setup_discovery(self) -> None: info = ServiceInfo( zeroconf_type, name=f"{server_id}.{zeroconf_type}", - addresses=[get_ip_pton(self.base_ip)], - port=self.webserver.port, + addresses=[get_ip_pton(self.streams.publish_ip)], + port=self.streams.publish_port, properties=self.get_server_info().to_dict(), server="mass.local.", ) From 6b141b357fb5f8779e463472932374cc6254e763 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 5 Jul 2023 11:19:15 +0200 Subject: [PATCH 2/8] bugfixes and finishing touch --- music_assistant/common/helpers/util.py | 8 ++- music_assistant/common/models/media_items.py | 61 ++++++++++--------- music_assistant/server/controllers/cache.py | 12 ++-- .../server/controllers/metadata.py | 10 +-- music_assistant/server/controllers/music.py | 20 +++--- .../server/controllers/player_queues.py | 57 +++++++++++------ music_assistant/server/controllers/players.py | 14 ++--- music_assistant/server/controllers/streams.py | 47 +++++++------- .../server/controllers/webserver.py | 2 +- music_assistant/server/helpers/audio.py | 55 +++++++---------- .../server/models/core_controller.py | 5 +- .../server/models/player_provider.py | 16 +++++ .../server/providers/filesystem_local/base.py | 18 +++--- .../server/providers/plex/__init__.py | 4 +- .../server/providers/qobuz/__init__.py | 16 +++-- .../server/providers/slimproto/__init__.py | 50 ++++++++------- .../server/providers/soundcloud/__init__.py | 4 +- .../server/providers/spotify/__init__.py | 9 +-- .../server/providers/tidal/__init__.py | 12 ++-- .../server/providers/tunein/__init__.py | 6 +- .../server/providers/ugp/__init__.py | 42 ++++++------- .../server/providers/url/__init__.py | 10 +-- .../server/providers/ytmusic/__init__.py | 4 +- music_assistant/server/server.py | 11 ++-- 24 files changed, 275 insertions(+), 218 deletions(-) diff --git a/music_assistant/common/helpers/util.py b/music_assistant/common/helpers/util.py index e7ca291c9..55ad431ad 100755 --- a/music_assistant/common/helpers/util.py +++ b/music_assistant/common/helpers/util.py @@ -182,13 +182,15 @@ def _resolve(): return await asyncio.to_thread(_resolve) -def get_ip_pton(ip_string: str = get_ip()): +async def get_ip_pton(ip_string: str | None = None): """Return socket pton for local ip.""" + if ip_string is None: + ip_string = await get_ip() # pylint:disable=no-member try: - return socket.inet_pton(socket.AF_INET, ip_string) + return await asyncio.to_thread(socket.inet_pton, socket.AF_INET, ip_string) except OSError: - return socket.inet_pton(socket.AF_INET6, ip_string) + return await asyncio.to_thread(socket.inet_pton, socket.AF_INET6, ip_string) def get_folder_size(folderpath): diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py index 36446f553..8cb8b799e 100755 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -25,23 +25,21 @@ JOINED_KEYS = ("barcode", "isrc") -@dataclass(frozen=True) -class ProviderMapping(DataClassDictMixin): - """Model for a MediaItem's provider mapping details.""" +@dataclass +class AudioFormat(DataClassDictMixin): + """Model for AudioFormat details.""" - item_id: str - provider_domain: str - provider_instance: str - available: bool = True - # quality details (streamable content only) content_type: ContentType = ContentType.UNKNOWN sample_rate: int = 44100 bit_depth: int = 16 - bit_rate: int = 320 - # optional details to store provider specific details - details: str | None = None - # url = link to provider details page if exists - url: str | None = None + channels: int = 2 + output_format_str: str = "" + bit_rate: int = 320 # optional + + def __post_init__(self): + """Execute actions after init.""" + if not self.output_format_str: + self.output_format_str = self.content_type.value @property def quality(self) -> int: @@ -55,6 +53,27 @@ def quality(self) -> int: score += 1 return int(score) + +@dataclass(frozen=True) +class ProviderMapping(DataClassDictMixin): + """Model for a MediaItem's provider mapping details.""" + + item_id: str + provider_domain: str + provider_instance: str + available: bool = True + # quality/audio details (streamable content only) + audio_format: AudioFormat = field(default_factory=AudioFormat) + # optional details to store provider specific details + details: str | None = None + # url = link to provider details page if exists + url: str | None = None + + @property + def quality(self) -> int: + """Return quality score.""" + return self.audio_format.quality + def __hash__(self) -> int: """Return custom hash.""" return hash((self.provider_instance, self.item_id)) @@ -511,22 +530,6 @@ def media_from_dict(media_item: dict) -> MediaItemType: return MediaItem.from_dict(media_item) -@dataclass -class AudioFormat(DataClassDictMixin): - """Model for AudioFormat details.""" - - content_type: ContentType - sample_rate: int = 44100 - bit_depth: int = 16 - channels: int = 2 - output_format_str: str = "" - - def __post_init__(self): - """Execute actions after init.""" - if not self.output_format_str: - self.output_format_str = self.content_type.value - - @dataclass class StreamDetails(DataClassDictMixin): """Model for streamdetails.""" diff --git a/music_assistant/server/controllers/cache.py b/music_assistant/server/controllers/cache.py index a3d885102..fd9f2ed9b 100644 --- a/music_assistant/server/controllers/cache.py +++ b/music_assistant/server/controllers/cache.py @@ -21,7 +21,7 @@ from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: - from music_assistant.server import MusicAssistant + pass LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache") @@ -29,11 +29,13 @@ class CacheController(CoreController): """Basic cache controller using both memory and database.""" - database: DatabaseConnection | None = None + name: str = "cache" + friendly_name: str = "Cache controller" - def __init__(self, mass: MusicAssistant) -> None: - """Initialize our caching class.""" - self.mass = mass + def __init__(self, *args, **kwargs) -> None: + """Initialize core controller.""" + super().__init__(*args, **kwargs) + self.database: DatabaseConnection | None = None self._mem_cache = MemoryCache(500) async def setup(self) -> None: diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index 759f150a7..ae261a09c 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -32,7 +32,6 @@ from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: - from music_assistant.server import MusicAssistant from music_assistant.server.models.metadata_provider import MetadataProvider LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.metadata") @@ -41,10 +40,13 @@ class MetaDataController(CoreController): """Several helpers to search and store metadata for mediaitems.""" - def __init__(self, mass: MusicAssistant) -> None: + name: str = "metadata" + friendly_name: str = "Metadata controller" + + def __init__(self, *args, **kwargs) -> None: """Initialize class.""" - self.mass = mass - self.cache = mass.cache + super().__init__(*args, **kwargs) + self.cache = self.mass.cache self._pref_lang: str | None = None self.scan_busy: bool = False diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index 871c1ed3f..a6e710988 100755 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -39,7 +39,7 @@ from .media.tracks import TracksController if TYPE_CHECKING: - from music_assistant.server import MusicAssistant + pass LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.music") SYNC_INTERVAL = 3 * 3600 @@ -48,16 +48,20 @@ class MusicController(CoreController): """Several helpers around the musicproviders.""" + name: str = "music" + friendly_name: str = "Music library" + database: DatabaseConnection | None = None - def __init__(self, mass: MusicAssistant): + def __init__(self, *args, **kwargs) -> None: """Initialize class.""" - self.mass = mass - self.artists = ArtistsController(mass) - self.albums = AlbumsController(mass) - self.tracks = TracksController(mass) - self.radio = RadioController(mass) - self.playlists = PlaylistController(mass) + super().__init__(*args, **kwargs) + self.cache = self.mass.cache + self.artists = ArtistsController(self.mass) + self.albums = AlbumsController(self.mass) + self.tracks = TracksController(self.mass) + self.radio = RadioController(self.mass) + self.playlists = PlaylistController(self.mass) self.in_progress_syncs: list[SyncTask] = [] self._sync_lock = asyncio.Lock() diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 7be63c624..007032a5e 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -209,7 +209,10 @@ async def play_media( queue_items = [QueueItem.from_media_item(queue_id, x) for x in tracks if x and x.available] # load the items into the queue - cur_index = queue.index_in_buffer or 0 + if queue.state in (PlayerState.PLAYING, PlayerState.PAUSED): + cur_index = queue.index_in_buffer or 0 + else: + cur_index = queue.current_index or 0 shuffle = queue.shuffle_enabled and len(queue_items) >= 5 # handle replace: clear all items and replace with the new items @@ -487,25 +490,41 @@ async def play_index( # power on player if needed await self.mass.players.cmd_power(queue_id, True) # execute the play_media command on the player - player_prov = self.mass.players.get_player_provider(queue_id) - # resolve stream url - queue.flow_mode = await self.mass.config.get_player_config_value( - queue.queue_id, CONF_FLOW_MODE - ) - url = await self.mass.streams.resolve_stream_url( - queue_id=queue_id, - queue_item=queue_item, - seek_position=seek_position, - fade_in=fade_in, - flow_mode=queue.flow_mode, - ) - await player_prov.cmd_play_url( - player_id=queue_id, - url=url, - # set queue_item to None if we're sending a flow mode url - # as the metadata is rather useless then - queue_item=None if queue.flow_mode else queue_item, + queue_player = self.mass.players.get(queue_id) + need_multi_stream = ( + queue_player.provider in ("airplay", "ugp", "slimproto") + and len(queue_player.group_childs) > 1 ) + player_prov = self.mass.players.get_player_provider(queue_id) + if need_multi_stream: + # handle special multi client stream + queue.flow_mode = True + stream_job = await self.mass.streams.create_multi_client_stream_job( + queue_id=queue_id, + start_queue_item=queue_item, + seek_position=seek_position, + fade_in=fade_in, + ) + await player_prov.cmd_handle_stream_job(player_id=queue_id, stream_job=stream_job) + else: + # regular stream + queue.flow_mode = await self.mass.config.get_player_config_value( + queue.queue_id, CONF_FLOW_MODE + ) + url = await self.mass.streams.resolve_stream_url( + queue_id=queue_id, + queue_item=queue_item, + seek_position=seek_position, + fade_in=fade_in, + flow_mode=queue.flow_mode, + ) + await player_prov.cmd_play_url( + player_id=queue_id, + url=url, + # set queue_item to None if we're sending a flow mode url + # as the metadata is rather useless then + queue_item=None if queue.flow_mode else queue_item, + ) # Interaction with player diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index fdc114283..c66e4be29 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -4,7 +4,7 @@ import asyncio import logging from collections.abc import Iterator -from typing import TYPE_CHECKING, cast +from typing import cast from music_assistant.common.helpers.util import get_changed_values from music_assistant.common.models.enums import ( @@ -28,18 +28,18 @@ from .player_queues import PlayerQueuesController -if TYPE_CHECKING: - from music_assistant.server import MusicAssistant - LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.players") class PlayerController(CoreController): """Controller holding all logic to control registered players.""" - def __init__(self, mass: MusicAssistant) -> None: - """Initialize class.""" - self.mass = mass + name: str = "players" + friendly_name: str = "Players controller" + + def __init__(self, *args, **kwargs) -> None: + """Initialize core controller.""" + super().__init__(*args, **kwargs) self._players: dict[str, Player] = {} self._prev_states: dict[str, dict] = {} self.queues = PlayerQueuesController(self) diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 0e0ecedac..75c081d97 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -77,7 +77,7 @@ def __init__( stream_controller: StreamsController, queue_id: str, pcm_format: AudioFormat, - start_queue_item_id: str, + start_queue_item: QueueItem, seek_position: int = 0, fade_in: bool = False, ) -> None: @@ -87,7 +87,7 @@ def __init__( self.queue = self.stream_controller.mass.players.queues.get(queue_id) assert self.queue # just in case self.pcm_format = pcm_format - self.start_queue_item_id = start_queue_item_id + self.start_queue_item = start_queue_item self.seek_position = seek_position self.fade_in = fade_in self.job_id = shortuuid.uuid() @@ -146,7 +146,7 @@ async def resolve_stream_url( f";codec=pcm;rate={output_sample_rate};" f"bitrate={output_bit_depth};channels={channels}" ) - url = f"{self.stream_controller.webserver.base_url}/{self.queue_id}/multi/{child_player_id}/{self.start_queue_item_id}.{fmt}?job_id={self.job_id}" # noqa: E501 + url = f"{self.stream_controller._server.base_url}/{self.queue_id}/multi/{child_player_id}/{self.start_queue_item.queue_item_id}.{fmt}?job_id={self.job_id}" # noqa: E501 self.expected_players.add(child_player_id) return url @@ -202,13 +202,8 @@ async def _put_chunk(self, chunk: bytes, chunk_num: int, timeout: float = 120) - async def _stream_job_runner(self) -> None: """Feed audio chunks to StreamJob subscribers.""" chunk_num = 0 - start_queue_item = self.stream_controller.mass.players.queues.get_item( - self.queue_id, self.start_queue_item_id - ) - if not start_queue_item: - raise RuntimeError(reason=f"Unknown Queue item: {self.start_queue_item_id}") async for chunk in self.stream_controller.get_flow_stream( - self.queue, start_queue_item, self.pcm_format, self.seek_position, self.fade_in + self.queue, self.start_queue_item, self.pcm_format, self.seek_position, self.fade_in ): chunk_num += 1 # wait until all expected clients are connected @@ -381,8 +376,10 @@ async def resolve_stream_url( output_bit_depth = min(FLOW_MAX_BIT_DEPTH, player_max_bit_depth) else: streamdetails = await get_stream_details(self.mass, queue_item) - output_sample_rate = min(streamdetails.sample_rate, player.max_sample_rate) - output_bit_depth = min(streamdetails.bit_depth, player_max_bit_depth) + output_sample_rate = min( + streamdetails.audio_format.sample_rate, player.max_sample_rate + ) + output_bit_depth = min(streamdetails.audio_format.bit_depth, player_max_bit_depth) output_channels = await self.mass.config.get_player_config_value( queue_id, CONF_OUTPUT_CHANNELS ) @@ -413,7 +410,7 @@ def resolve_preview_url(self, provider_instance_id_or_domain: str, track_id: str async def create_multi_client_stream_job( self, queue_id: str, - start_queue_item_id: str, + start_queue_item: QueueItem, seek_position: int = 0, fade_in: bool = False, ) -> MultiClientStreamJob: @@ -438,7 +435,7 @@ async def create_multi_client_stream_job( bit_depth=24, channels=2, ), - start_queue_item_id=start_queue_item_id, + start_queue_item=start_queue_item, seek_position=seek_position, fade_in=fade_in, ) @@ -494,9 +491,14 @@ async def serve_queue_item_stream(self, request: web.Request) -> web.Response: ) # collect player specific ffmpeg args to re-encode the source PCM stream + pcm_format = AudioFormat( + content_type=ContentType.from_bit_depth(streamdetails.audio_format.bit_depth), + sample_rate=streamdetails.audio_format.sample_rate, + bit_depth=streamdetails.audio_format.bit_depth, + ) ffmpeg_args = await self._get_player_ffmpeg_args( queue_player, - input_format=streamdetails.audio_format, + input_format=pcm_format, output_format=output_format, ) @@ -507,6 +509,7 @@ async def read_audio(): async for chunk in get_media_stream( self.mass, streamdetails=streamdetails, + pcm_format=pcm_format, seek_position=seek_position, fade_in=fade_in, ): @@ -801,9 +804,9 @@ async def get_flow_stream( async for chunk in get_media_stream( self.mass, streamdetails, + pcm_format=pcm_format, seek_position=seek_position, fade_in=fade_in, - output_format=pcm_format, # only allow strip silence from begin if track is being crossfaded strip_silence_begin=last_fadeout_part != b"", ): @@ -897,9 +900,9 @@ async def _get_player_ffmpeg_args( # generic args generic_args = [ "ffmpeg", - "-hide_banner", - "-loglevel", - "warning" if self.logger.isEnabledFor(logging.DEBUG) else "quiet", + # "-hide_banner", + # "-loglevel", + # "warning" if self.logger.isEnabledFor(logging.DEBUG) else "quiet", "-ignore_unknown", ] # input args @@ -918,9 +921,9 @@ async def _get_player_ffmpeg_args( if output_format.content_type == ContentType.FLAC: output_args = ["-f", "flac", "-compression_level", "3"] elif output_format.content_type == ContentType.AAC: - output_args = ["-f", "adts", "-c:a", output_format.value, "-b:a", "320k"] + output_args = ["-f", "adts", "-c:a", "aac", "-b:a", "320k"] elif output_format.content_type == ContentType.MP3: - output_args = ["-f", "mp3", "-c:a", output_format.value, "-b:a", "320k"] + output_args = ["-f", "mp3", "-c:a", "mp3", "-b:a", "320k"] else: output_args = ["-f", output_format.content_type.value] @@ -989,7 +992,7 @@ async def _get_output_format( ) if content_type == ContentType.PCM: # resolve generic pcm type - output_format = ContentType.from_bit_depth(output_bit_depth) + content_type = ContentType.from_bit_depth(output_bit_depth) else: output_sample_rate = min(default_sample_rate, queue_player.max_sample_rate) @@ -1000,7 +1003,7 @@ async def _get_output_format( ) output_channels = 1 if output_channels_str != "stereo" else 2 return AudioFormat( - content_type=output_format, + content_type=content_type, sample_rate=output_sample_rate, bit_depth=output_bit_depth, channels=output_channels, diff --git a/music_assistant/server/controllers/webserver.py b/music_assistant/server/controllers/webserver.py index 9e2f40727..a461e37b3 100644 --- a/music_assistant/server/controllers/webserver.py +++ b/music_assistant/server/controllers/webserver.py @@ -155,7 +155,7 @@ async def _handle_server_info(self, request: web.Request) -> web.Response: # no return web.json_response(self.mass.get_server_info().to_dict()) async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse: - connection = WebsocketClientHandler(self.mass, request) + connection = WebsocketClientHandler(self, request) try: self.clients.add(connection) return await connection.handle_client() diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 3d9aff2c8..d86c22167 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -179,7 +179,7 @@ async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> N "-i", input_file, "-f", - streamdetails.content_type, + streamdetails.audio_format.content_type, "-af", "ebur128=framelog=verbose", "-f", @@ -268,7 +268,6 @@ async def get_stream_details(mass: MusicAssistant, queue_item: QueueItem) -> Str streamdetails: StreamDetails = await music_prov.get_stream_details( prov_media.item_id ) - streamdetails.content_type = ContentType(streamdetails.content_type) except MusicAssistantError as err: LOGGER.warning(str(err)) else: @@ -288,7 +287,7 @@ async def get_stream_details(mass: MusicAssistant, queue_item: QueueItem) -> Str streamdetails.duration = queue_item.duration # make sure that ffmpeg handles mpeg dash streams directly if ( - streamdetails.content_type == ContentType.MPEG_DASH + streamdetails.audio_format.content_type == ContentType.MPEG_DASH and streamdetails.data and streamdetails.data.startswith("http") ): @@ -382,9 +381,9 @@ def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration= async def get_media_stream( mass: MusicAssistant, streamdetails: StreamDetails, + pcm_format: AudioFormat, seek_position: int = 0, fade_in: bool = False, - output_format: AudioFormat | None = None, strip_silence_begin: bool = False, strip_silence_end: bool = True, ) -> AsyncGenerator[bytes, None]: @@ -399,11 +398,8 @@ async def get_media_stream( is_radio = streamdetails.media_type == MediaType.RADIO or not streamdetails.duration if is_radio or seek_position: strip_silence_begin = False - # use audio format from streamdetails if no override is given - if output_format is None: - output_format = streamdetails.audio_format # chunk size = 2 seconds of pcm audio - pcm_sample_size = int(output_format.sample_rate * (output_format.bit_depth / 8) * 2) + pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2) chunk_size = pcm_sample_size * (1 if is_radio else 2) expected_chunks = int((streamdetails.duration or 0) / 2) if expected_chunks < 60: @@ -413,8 +409,7 @@ async def get_media_stream( seek_pos = seek_position if (streamdetails.direct or not streamdetails.can_seek) else 0 args = await _get_ffmpeg_args( streamdetails=streamdetails, - sample_rate=output_format.sample_rate, - bit_depth=output_format.bit_depth, + pcm_output_format=pcm_format, # only use ffmpeg seeking if the provider stream does not support seeking seek_position=seek_pos, fade_in=fade_in, @@ -450,8 +445,8 @@ async def writer(): stripped_audio = await strip_silence( mass, prev_chunk + chunk, - sample_rate=output_format.sample_rate, - bit_depth=output_format.bit_depth, + sample_rate=pcm_format.sample_rate, + bit_depth=pcm_format.bit_depth, ) yield stripped_audio bytes_sent += len(stripped_audio) @@ -475,8 +470,8 @@ async def writer(): stripped_audio = await strip_silence( mass, prev_chunk, - sample_rate=output_format.sample_rate, - bit_depth=output_format.bit_depth, + sample_rate=pcm_format.sample_rate, + bit_depth=pcm_format.bit_depth, reverse=True, ) yield stripped_audio @@ -602,7 +597,7 @@ async def get_file_stream( if not streamdetails.size: stat = await asyncio.to_thread(os.stat, filename) streamdetails.size = stat.st_size - chunk_size = get_chunksize(streamdetails.content_type) + chunk_size = get_chunksize(streamdetails.audio_format.content_type) async with aiofiles.open(streamdetails.data, "rb") as _file: if seek_position: seek_pos = int((streamdetails.size / streamdetails.duration) * seek_position) @@ -654,8 +649,8 @@ async def get_preview_stream( input_args += ["-ss", "30", "-i", streamdetails.direct] else: # the input is received from pipe/stdin - if streamdetails.content_type != ContentType.UNKNOWN: - input_args += ["-f", streamdetails.content_type] + if streamdetails.audio_format.content_type != ContentType.UNKNOWN: + input_args += ["-f", streamdetails.audio_format.content_type] input_args += ["-i", "-"] output_args = ["-to", "30", "-f", "mp3", "-"] @@ -710,7 +705,7 @@ async def get_silence( "-t", str(duration), "-f", - output_fmt, + output_fmt.value, "-", ] async with AsyncProcess(args) as ffmpeg_proc: @@ -739,14 +734,11 @@ def get_chunksize( async def _get_ffmpeg_args( streamdetails: StreamDetails, - sample_rate: int, - bit_depth: int, + pcm_output_format: AudioFormat, seek_position: int = 0, fade_in: bool = False, ) -> list[str]: """Collect all args to send to the ffmpeg process.""" - input_format = streamdetails.content_type - ffmpeg_present, libsoxr_support, version = await check_audio_support() if not ffmpeg_present: @@ -792,24 +784,23 @@ async def _get_ffmpeg_args( "5xx", ] - input_args += ["-i", streamdetails.direct] + input_args += ["-ac", str(streamdetails.audio_format.channels), "-i", streamdetails.direct] else: # the input is received from pipe/stdin - if streamdetails.content_type != ContentType.UNKNOWN: - input_args += ["-f", input_format] - input_args += ["-i", "-"] + if streamdetails.audio_format.content_type != ContentType.UNKNOWN: + input_args += ["-f", streamdetails.audio_format.content_type.value] + input_args += ["-ac", str(streamdetails.audio_format.channels), "-i", "-"] - pcm_output_format = ContentType.from_bit_depth(bit_depth) # collect output args output_args = [ "-acodec", - pcm_output_format.name.lower(), + pcm_output_format.content_type.name.lower(), "-f", - pcm_output_format, + pcm_output_format.content_type.value, "-ac", - "2", # to simplify things, we always output 2 channels + str(pcm_output_format.channels), "-ar", - str(sample_rate), + str(pcm_output_format.sample_rate), "-", ] # collect extra and filter args @@ -818,7 +809,7 @@ async def _get_ffmpeg_args( if streamdetails.gain_correct is not None: filter_params.append(f"volume={streamdetails.gain_correct}dB") if ( - streamdetails.sample_rate != sample_rate + streamdetails.audio_format.sample_rate != pcm_output_format.sample_rate and libsoxr_support and streamdetails.media_type == MediaType.TRACK ): diff --git a/music_assistant/server/models/core_controller.py b/music_assistant/server/models/core_controller.py index edace3264..056f06ae5 100644 --- a/music_assistant/server/models/core_controller.py +++ b/music_assistant/server/models/core_controller.py @@ -27,10 +27,11 @@ def __init__(self, mass: MusicAssistant) -> None: async def get_config_entries( self, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, + action: str | None = None, # noqa: ARG002 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" + return tuple() async def setup(self) -> None: """Async initialize of module.""" diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index 8edd260b3..41aeae138 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from music_assistant.common.models.config_entries import ConfigEntry, PlayerConfig + from music_assistant.server.controllers.streams import MultiClientStreamJob # ruff: noqa: ARG001, ARG002 @@ -70,6 +71,21 @@ async def cmd_play_url( - queue_item: the QueueItem that is related to the URL (None when playing direct url). """ + async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None: + """Handle StreamJob play command on given player. + + This is called when the Queue wants the player to start playing media + to multiple subscribers at the same time using a MultiClientStreamJob. + The default implementation is that the URL to the stream is resolved for the player + and played like any regular play_url command, but implementation may override + this behavior for any more sophisticated handling (e.g. when syncing etc.) + + - player_id: player_id of the player to handle the command. + - stream_job: the MultiClientStreamJob that the player should start playing. + """ + url = await stream_job.resolve_stream_url(player_id) + await self.cmd_play_url(player_id=player_id, url=url) + async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player. diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index 6a2d6b92e..9c0f81bf2 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -561,16 +561,12 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: return StreamDetails( provider=self.instance_id, item_id=item_id, - audio_format=AudioFormat( - content_type=prov_mapping.content_type, - sample_rate=prov_mapping.sample_rate, - bit_depth=prov_mapping.bit_depth, - ), + audio_format=prov_mapping.audio_format, media_type=MediaType.TRACK, duration=db_item.duration, size=file_item.file_size, direct=file_item.local_path, - can_seek=prov_mapping.content_type in SEEKABLE_FILES, + can_seek=prov_mapping.audio_format.content_type in SEEKABLE_FILES, ) async def get_audio_stream( @@ -728,10 +724,12 @@ async def _parse_track(self, file_item: FileSystemItem) -> Track: item_id=file_item.path, provider_domain=self.domain, provider_instance=self.instance_id, - content_type=ContentType.try_parse(tags.format), - sample_rate=tags.sample_rate, - bit_depth=tags.bits_per_sample, - bit_rate=tags.bit_rate, + audio_format=AudioFormat( + content_type=ContentType.try_parse(tags.format), + sample_rate=tags.sample_rate, + bit_depth=tags.bits_per_sample, + bit_rate=tags.bit_rate, + ), ) ) return track diff --git a/music_assistant/server/providers/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index f4f30cb66..a90a04fab 100644 --- a/music_assistant/server/providers/plex/__init__.py +++ b/music_assistant/server/providers/plex/__init__.py @@ -401,7 +401,9 @@ async def _parse_track(self, plex_track: PlexTrack) -> Track: provider_domain=self.domain, provider_instance=self.instance_id, available=available, - content_type=ContentType.try_parse(content) if content else ContentType.UNKNOWN, + audio_format=AudioFormat( + content_type=ContentType.try_parse(content) if content else ContentType.UNKNOWN, + ), url=plex_track.getWebURL(), ) ) diff --git a/music_assistant/server/providers/qobuz/__init__.py b/music_assistant/server/providers/qobuz/__init__.py index 54f93c9f5..d6a27bfa2 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -460,9 +460,11 @@ async def _parse_album(self, album_obj: dict, artist_obj: dict = None): provider_domain=self.domain, provider_instance=self.instance_id, available=album_obj["streamable"] and album_obj["displayable"], - content_type=ContentType.FLAC, - sample_rate=album_obj["maximum_sampling_rate"] * 1000, - bit_depth=album_obj["maximum_bit_depth"], + audio_format=AudioFormat( + content_type=ContentType.FLAC, + sample_rate=album_obj["maximum_sampling_rate"] * 1000, + bit_depth=album_obj["maximum_bit_depth"], + ), url=album_obj.get("url", f'https://open.qobuz.com/album/{album_obj["id"]}'), ) ) @@ -559,9 +561,11 @@ async def _parse_track(self, track_obj: dict): provider_domain=self.domain, provider_instance=self.instance_id, available=track_obj["streamable"] and track_obj["displayable"], - content_type=ContentType.FLAC, - sample_rate=track_obj["maximum_sampling_rate"] * 1000, - bit_depth=track_obj["maximum_bit_depth"], + audio_format=AudioFormat( + content_type=ContentType.FLAC, + sample_rate=track_obj["maximum_sampling_rate"] * 1000, + bit_depth=track_obj["maximum_bit_depth"], + ), url=track_obj.get("url", f'https://open.qobuz.com/track/{track_obj["id"]}'), ) ) diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index a42b20f73..8a6b23f75 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -9,7 +9,6 @@ from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qsl, urlparse from aioslimproto.client import PlayerState as SlimPlayerState from aioslimproto.client import SlimClient @@ -43,6 +42,7 @@ from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant + from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType CACHE_KEY_PREV_STATE = "slimproto_prev_state" @@ -379,39 +379,37 @@ async def cmd_play_url( if player.synced_to: raise RuntimeError("A synced player cannot receive play commands directly") - stream_job = None + # forward command to player and any connected sync child's sync_clients = [x for x in self._get_sync_clients(player_id)] - if sync_clients > 1: - # we need to form/join a multiclient stream job - if player.active_source == player.player_id: - # this player is a sync leader of a regular sync group - # parse original params from the regular stream url - parsed_url = urlparse(url) - start_queue_item_id = parsed_url.path.rsplit("/")[-1].split(".")[0] - params = parse_qsl(parsed_url.query) - stream_job = await self.mass.streams.create_multi_client_stream_job( - queue_id=player.player_id, - start_queue_item_id=start_queue_item_id, - seek_position=int(params.get("seek_position", 0)), - fade_in=bool(params.get("fade_in", 0)), + async with asyncio.TaskGroup() as tg: + for client in sync_clients: + tg.create_task( + self._handle_play_url( + client, + url=url, + queue_item=queue_item, + send_flush=True, + auto_play=len(sync_clients) == 1, + ) ) - elif player.active_source in self.mass.streams.multi_client_jobs: - # join existing multiclient stream job (from universal group) - stream_job = self.mass.streams.multi_client_jobs[player.active_source] - else: - # edge case: regular url requested while synced - stream_job = None - # forward command to player and any connected sync child's + async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None: + """Handle StreamJob play command on given player.""" + # send stop first + await self.cmd_stop(player_id) + + player = self.mass.players.get(player_id) + if player.synced_to: + raise RuntimeError("A synced player cannot receive play commands directly") + sync_clients = [x for x in self._get_sync_clients(player_id)] async with asyncio.TaskGroup() as tg: for client in sync_clients: - if stream_job: - url = await stream_job.resolve_stream_url(client.player_id) + url = await stream_job.resolve_stream_url(client.player_id) tg.create_task( self._handle_play_url( client, url=url, - queue_item=queue_item, + queue_item=None, send_flush=True, auto_play=len(sync_clients) == 1, ) @@ -437,7 +435,7 @@ async def _handle_play_url( await client.play_url( url=url, - mime_type=f"audio/{url.split('.')[-1]}", + mime_type=f"audio/{url.split('.')[-1].split('?')[0]}", metadata={"item_id": queue_item.queue_item_id, "title": queue_item.name} if queue_item else None, diff --git a/music_assistant/server/providers/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index 151712bcb..ddc5f8761 100644 --- a/music_assistant/server/providers/soundcloud/__init__.py +++ b/music_assistant/server/providers/soundcloud/__init__.py @@ -375,7 +375,9 @@ async def _parse_track(self, track_obj: dict) -> Track: item_id=track_obj["id"], provider_domain=self.domain, provider_instance=self.instance_id, - content_type=ContentType.MP3, + audio_format=AudioFormat( + content_type=ContentType.MP3, + ), url=track_obj["permalink_url"], ) ) diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index 5ba4cf68e..8d3503997 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -451,8 +451,7 @@ async def _parse_album(self, album_obj: dict): item_id=album_obj["id"], provider_domain=self.domain, provider_instance=self.instance_id, - content_type=ContentType.OGG, - bit_rate=320, + audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320), url=album_obj["external_urls"]["spotify"], ) ) @@ -500,8 +499,10 @@ async def _parse_track(self, track_obj, artist=None): item_id=track_obj["id"], provider_domain=self.domain, provider_instance=self.instance_id, - content_type=ContentType.OGG, - bit_rate=320, + audio_format=AudioFormat( + content_type=ContentType.OGG, + bit_rate=320, + ), url=track_obj["external_urls"]["spotify"], available=not track_obj["is_local"] and track_obj["is_playable"], ) diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index 909270398..66077c6fc 100644 --- a/music_assistant/server/providers/tidal/__init__.py +++ b/music_assistant/server/providers/tidal/__init__.py @@ -517,7 +517,9 @@ async def _parse_album(self, album_obj: TidalAlbum, full_details: bool = False) item_id=album_id, provider_domain=self.domain, provider_instance=self.instance_id, - content_type=ContentType.FLAC, + audio_format=AudioFormat( + content_type=ContentType.FLAC, + ), url=f"http://www.tidal.com/album/{album_id}", available=available, ) @@ -569,9 +571,11 @@ async def _parse_track(self, track_obj: TidalTrack, full_details: bool = False) item_id=track_id, provider_domain=self.domain, provider_instance=self.instance_id, - content_type=ContentType.FLAC, - sample_rate=44100, - bit_depth=16, + audio_format=AudioFormat( + content_type=ContentType.FLAC, + sample_rate=44100, + bit_depth=16, + ), url=f"http://www.tidal.com/tracks/{track_id}", available=available, ) diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index 62e313883..8319b6f4e 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -173,8 +173,10 @@ async def _parse_radio( item_id=item_id, provider_domain=self.domain, provider_instance=self.instance_id, - content_type=content_type, - bit_rate=bit_rate, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=bit_rate, + ), details=url, ) ) diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index 1aa4f0c85..bacbeaada 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -8,7 +8,6 @@ import asyncio from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qsl, urlparse from music_assistant.common.models.config_entries import ( CONF_ENTRY_FLOW_MODE, @@ -34,6 +33,7 @@ from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant + from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -205,37 +205,35 @@ async def cmd_play_url( group_player = self.mass.players.get(player_id) group_player.extra_data["optimistic_state"] = PlayerState.PLAYING - # we need to form/join a multiclient stream job - if group_player.active_source == group_player.player_id: - # parse original params from the regular stream url - parsed_url = urlparse(url) - start_queue_item_id = parsed_url.path.split("/")[-1].split(".")[0] - params = parse_qsl(parsed_url.query) - stream_job = await self.mass.streams.create_multi_client_stream_job( - queue_id=group_player.player_id, - start_queue_item_id=start_queue_item_id, - seek_position=int(params.get("seek_position", 0)), - fade_in=bool(params.get("fade_in", 0)), - ) - elif group_player.active_source in self.mass.streams.multi_client_jobs: - # join existing multiclient stream job (from parent group) - stream_job = self.mass.streams.multi_client_jobs[group_player.active_source] - else: - # edge case: regular url requested not related to the queue ?! - stream_job = None - # forward command to all (powered) group child's async with asyncio.TaskGroup() as tg: for member in self._get_active_members( player_id, only_powered=True, skip_sync_childs=True ): player_prov = self.mass.players.get_player_provider(member.player_id) - if stream_job: - url = await stream_job.resolve_stream_url(member.player_id) tg.create_task( player_prov.cmd_play_url(member.player_id, url=url, queue_item=queue_item) ) + async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None: + """Handle StreamJob play command on given player.""" + # send stop first + await self.cmd_stop(player_id) + # power ON + await self.cmd_power(player_id, True) + group_player = self.mass.players.get(player_id) + group_player.extra_data["optimistic_state"] = PlayerState.PLAYING + # forward command to all (powered) group child's + async with asyncio.TaskGroup() as tg: + for member in self._get_active_members( + player_id, only_powered=True, skip_sync_childs=True + ): + player_prov = self.mass.players.get_player_provider(member.player_id) + # we forward the stream_job to child to allow for nested groups etc + tg.create_task( + player_prov.cmd_handle_stream_job(member.player_id, stream_job=stream_job) + ) + async def cmd_pause(self, player_id: str) -> None: """Send PAUSE command to given player.""" group_player = self.mass.players.get(player_id) diff --git a/music_assistant/server/providers/url/__init__.py b/music_assistant/server/providers/url/__init__.py index b7ad42ffc..77cbd38e7 100644 --- a/music_assistant/server/providers/url/__init__.py +++ b/music_assistant/server/providers/url/__init__.py @@ -126,10 +126,12 @@ async def parse_item( item_id=item_id, provider_domain=self.domain, provider_instance=self.instance_id, - content_type=ContentType.try_parse(media_info.format), - sample_rate=media_info.sample_rate, - bit_depth=media_info.bits_per_sample, - bit_rate=media_info.bit_rate, + audio_format=AudioFormat( + content_type=ContentType.try_parse(media_info.format), + sample_rate=media_info.sample_rate, + bit_depth=media_info.bits_per_sample, + bit_rate=media_info.bit_rate, + ), ) } if media_info.has_cover_image: diff --git a/music_assistant/server/providers/ytmusic/__init__.py b/music_assistant/server/providers/ytmusic/__init__.py index 1ed408c3e..aaf09c9d0 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -704,7 +704,9 @@ async def _parse_track(self, track_obj: dict) -> Track: provider_domain=self.domain, provider_instance=self.instance_id, available=available, - content_type=ContentType.M4A, + audio_format=AudioFormat( + content_type=ContentType.M4A, + ), ) ) return track diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index af3a7dc56..e2d91d702 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -122,9 +122,10 @@ async def start(self) -> None: await self.metadata.setup() await self.players.setup() await self.streams.setup() + # setup discovery + self.create_task(self._setup_discovery()) # load providers await self._load_providers() - self._setup_discovery() async def stop(self) -> None: """Stop running the music assistant server.""" @@ -495,7 +496,7 @@ async def __load_available_providers(self) -> None: exc_info=exc, ) - def _setup_discovery(self) -> None: + async def _setup_discovery(self) -> None: """Make this Music Assistant instance discoverable on the network.""" zeroconf_type = "_mass._tcp.local." server_id = self.server_id @@ -503,7 +504,7 @@ def _setup_discovery(self) -> None: info = ServiceInfo( zeroconf_type, name=f"{server_id}.{zeroconf_type}", - addresses=[get_ip_pton(self.streams.publish_ip)], + addresses=[await get_ip_pton(self.streams.publish_ip)], port=self.streams.publish_port, properties=self.get_server_info().to_dict(), server="mass.local.", @@ -512,9 +513,9 @@ def _setup_discovery(self) -> None: try: existing = getattr(self, "mass_zc_service_set", None) if existing: - self.zeroconf.update_service(info) + await self.zeroconf.async_update_service(info) else: - self.zeroconf.register_service(info) + await self.zeroconf.async_register_service(info) setattr(self, "mass_zc_service_set", True) except NonUniqueNameException: LOGGER.error( From c994bfba5889bb3ee0bcf6ea9467a7ed56a5cb56 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 5 Jul 2023 19:21:13 +0200 Subject: [PATCH 3/8] more fixes --- music_assistant/common/models/player.py | 6 +- .../server/controllers/player_queues.py | 17 +- music_assistant/server/controllers/players.py | 17 +- music_assistant/server/controllers/streams.py | 147 +++++++++++------- music_assistant/server/helpers/audio.py | 38 +++-- music_assistant/server/helpers/didl_lite.py | 27 ++-- .../server/models/player_provider.py | 2 +- .../server/providers/chromecast/__init__.py | 25 ++- .../server/providers/dlna/__init__.py | 21 +-- .../server/providers/slimproto/__init__.py | 15 +- .../server/providers/slimproto/cli.py | 2 +- .../server/providers/sonos/__init__.py | 54 ++----- .../server/providers/ugp/__init__.py | 2 - 13 files changed, 183 insertions(+), 190 deletions(-) diff --git a/music_assistant/common/models/player.py b/music_assistant/common/models/player.py index 86e8956e6..51c5515ee 100644 --- a/music_assistant/common/models/player.py +++ b/music_assistant/common/models/player.py @@ -35,7 +35,6 @@ class Player(DataClassDictMixin): elapsed_time: float = 0 elapsed_time_last_updated: float = time.time() current_url: str | None = None - current_item_id: str | None = None state: PlayerState = PlayerState.IDLE volume_level: int = 100 @@ -45,8 +44,9 @@ class Player(DataClassDictMixin): # - If this player is a dedicated group player, # returns all child id's of the players in the group. # - If this is a syncgroup of players from the same platform (e.g. sonos), - # this will return the id's of players synced to this player. - group_childs: list[str] = field(default_factory=list) + # this will return the id's of players synced to this player, + # and this may include the player's own id. + group_childs: set[str] = field(default_factory=set) # active_source: return player_id of the active queue for this player # if the player is grouped and a group is active, this will be set to the group's player_id diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 007032a5e..3df87bd2e 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -502,7 +502,7 @@ async def play_index( stream_job = await self.mass.streams.create_multi_client_stream_job( queue_id=queue_id, start_queue_item=queue_item, - seek_position=seek_position, + seek_position=int(seek_position), fade_in=fade_in, ) await player_prov.cmd_handle_stream_job(player_id=queue_id, stream_job=stream_job) @@ -514,7 +514,7 @@ async def play_index( url = await self.mass.streams.resolve_stream_url( queue_id=queue_id, queue_item=queue_item, - seek_position=seek_position, + seek_position=int(seek_position), fade_in=fade_in, flow_mode=queue.flow_mode, ) @@ -570,12 +570,8 @@ def on_player_update( queue.active = player.active_source == queue.queue_id if queue.active: queue.state = player.state - queue.flow_mode = player.current_url and "/flow/" in player.current_url # update current item from player report - player_item_index = self.index_by_id(queue_id, player.current_item_id) - if player_item_index is None: - # try grabbing the item id from the url - player_item_index = self._get_player_item_index(queue_id, player.current_url) + player_item_index = self._get_player_item_index(queue_id, player.current_url) if player_item_index is not None: if queue.flow_mode: # flow mode active, calculate current item @@ -652,7 +648,7 @@ def on_player_remove(self, player_id: str) -> None: self._queue_items.pop(player_id, None) async def preload_next_url( - self, queue_id: str, current_item_id: str + self, queue_id: str, current_item_id: str | None = None ) -> tuple[str, QueueItem, bool]: """Call when a player wants to load the next track/url into the buffer. @@ -661,7 +657,10 @@ async def preload_next_url( Raises QueueEmpty if there are no more tracks left. """ queue = self.get(queue_id) - cur_index = self.index_by_id(queue_id, current_item_id) + if current_item_id: + cur_index = self.index_by_id(queue_id, current_item_id) or 0 + else: + cur_index = queue.index_in_buffer or queue.current_index or 0 cur_item = self.get_item(queue_id, cur_index) idx = 0 while True: diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index c66e4be29..3e521d0ba 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -533,13 +533,13 @@ def _get_active_source(self, player: Player) -> str: if ":" in player.current_url: # extract source from uri/url return player.current_url.split(":")[0] - return player.current_item_id or player.current_url + return player.current_url # defaults to the player's own player id return player.player_id def _get_group_volume_level(self, player: Player) -> int: """Calculate a group volume from the grouped members.""" - if not player.group_childs: + if len(player.group_childs) <= 1: # player is not a group return player.volume_level # calculate group volume from all (turned on) players @@ -560,13 +560,14 @@ def _get_child_players( ) -> list[Player]: """Get (child) players attached to a grouped player.""" child_players: list[Player] = [] - if not player.group_childs: - # player is not a group - return child_players - if player.type != PlayerType.GROUP: + if player.type != PlayerType.GROUP: # noqa: SIM102 # if the player is not a dedicated player group, - # it is the master in a sync group and thus always present as child player - child_players.append(player) + # it is the master in a sync group and should be always present as child player + if player.player_id not in player.group_childs: + player.group_childs.add(player.player_id) + if len(player.group_childs) == 1: + # player is not synced + return child_players for child_id in player.group_childs: if child_player := self.get(child_id, False): if not (not only_powered or child_player.powered): diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 75c081d97..2d2ccad25 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -9,6 +9,7 @@ import asyncio import logging +import time import urllib.parse from collections.abc import AsyncGenerator from typing import TYPE_CHECKING @@ -41,6 +42,7 @@ crossfade_pcm_parts, get_media_stream, get_preview_stream, + get_silence, get_stream_details, ) from music_assistant.server.helpers.process import AsyncProcess @@ -61,6 +63,7 @@ } FLOW_MAX_SAMPLE_RATE = 96000 FLOW_MAX_BIT_DEPTH = 24 +WORKAROUND_PLAYERS_CACHE_KEY = "streams.workaround_players" class MultiClientStreamJob: @@ -94,8 +97,8 @@ def __init__( self.expected_players: set[str] = set() self.subscribed_players: dict[str, asyncio.Queue[bytes]] = {} self.start_chunk: dict[str, int] = {} - self._all_clients_connected = asyncio.Event() self.seen_players: set[str] = set() + self._all_clients_connected = asyncio.Event() # start running the audio task in the background self._audio_task = asyncio.create_task(self._stream_job_runner()) @@ -152,18 +155,10 @@ async def resolve_stream_url( async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: """Subscribe consumer and iterate incoming chunks on the queue.""" - self.start() - self.seen_players.add(player_id) try: - sub_queue = asyncio.Queue(1) + self.seen_players.add(player_id) + self.subscribed_players[player_id] = sub_queue = asyncio.Queue(1) - # some checks - assert player_id not in self.subscribed_players, "No duplicate subscriptions allowed" - assert player_id in self.expected_players, "Unexpected player id" - assert not self.finished, "Already finished" - assert not self.running, "Already running" - - self.subscribed_players[player_id] = sub_queue if len(self.subscribed_players) == len(self.expected_players): # we reached the number of expected subscribers, set event # so that chunks can be pushed @@ -177,12 +172,9 @@ async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: break yield chunk finally: - self.subscribed_players.pop(player_id) - # some delay here to handle misbehaving (reconnecting) players - if len(self.subscribed_players) == 0: - self._all_clients_connected.clear() - await asyncio.sleep(2) + self.subscribed_players.pop(player_id, None) # check if this was the last subscriber and we should cancel + await asyncio.sleep(2) if len(self.subscribed_players) == 0 and self._audio_task and not self.finished: self._audio_task.cancel() @@ -206,35 +198,37 @@ async def _stream_job_runner(self) -> None: self.queue, self.start_queue_item, self.pcm_format, self.seek_position, self.fade_in ): chunk_num += 1 - # wait until all expected clients are connected - try: - async with asyncio.timeout(10): - await self._all_clients_connected.wait() - except TimeoutError: - if len(self.subscribed_players) == 0: - self.stream_controller.logger.error( - "Abort multi client stream job for queue %s: " - "clients did not (re)connect within timeout", + if chunk_num == 1: + # wait until all expected clients are connected + try: + async with asyncio.timeout(10): + await self._all_clients_connected.wait() + except TimeoutError: + if len(self.subscribed_players) == 0: + self.stream_controller.logger.error( + "Abort multi client stream job for queue %s: " + "clients did not connect within timeout", + self.queue.display_name, + ) + break + # not all clients connected but timeout expired, set flasg and move on + # with all clients that did connect + self._all_clients_connected.set() + else: + self.stream_controller.logger.debug( + "Starting multi client stream job for queue %s " + "with %s out of %s connected clients", self.queue.display_name, + len(self.subscribed_players), + len(self.expected_players), ) - break - # not all clients connected but timeout expired, set flasg and move on - # with all clients that did connect - self._all_clients_connected.set() - - if chunk_num == 1: - self.stream_controller.logger.debug( - "Starting multi client stream job for queue %s " - "with %s out of %s connected clients", - self.queue.display_name, - len(self.subscribed_players), - len(self.expected_players), - ) - - await self._put_chunk(chunk) + while len(self.subscribed_players) == 0: + # just a guard in case of race conditions + await asyncio.sleep(0.2) + await self._put_chunk(chunk, chunk_num=chunk_num) # mark EOF with empty chunk - await self._put_chunk(b"") + await self._put_chunk(b"", chunk_num=chunk_num + 1) def parse_pcm_info(content_type: str) -> tuple[int, int, int]: @@ -261,6 +255,7 @@ def __init__(self, *args, **kwargs): self.multi_client_jobs: dict[str, MultiClientStreamJob] = {} self.register_dynamic_route = self._server.register_dynamic_route self.unregister_dynamic_route = self._server.unregister_dynamic_route + self.workaround_players: set[str] = set() @property def base_url(self) -> str: @@ -316,6 +311,9 @@ async def setup(self) -> None: version, "with libsoxr support" if libsoxr_support else "", ) + # restore known workaround players + if cache := await self.mass.cache.get(WORKAROUND_PLAYERS_CACHE_KEY): + self.workaround_players.update(cache) # start the webserver self.publish_port = bind_port = self.mass.config.get_raw_core_config_value( self.name, CONF_BIND_IP, self._default_port @@ -350,6 +348,7 @@ async def setup(self) -> None: async def close(self) -> None: """Cleanup on exit.""" await self._server.close() + await self.mass.cache.set(WORKAROUND_PLAYERS_CACHE_KEY, self.workaround_players) async def resolve_stream_url( self, @@ -431,7 +430,7 @@ async def create_multi_client_stream_job( # hardcoded pcm quality of 48/24 for now # TODO: change this to the highest quality supported by all child players ? content_type=ContentType.from_bit_depth(24), - sample_rate=4800, + sample_rate=48000, bit_depth=24, channels=2, ), @@ -686,6 +685,36 @@ async def serve_multi_subscriber_stream(self, request: web.Request) -> web.Respo if request.method == "HEAD": return resp + # some players (e.g. dlna, sonos) misbehave and do multiple GET requests + # to the stream in an attempt to get the audio details such as duration + # which is a bit pointless for our duration-less queue stream + # and it completely messes with the subscription logic + # we try to workaround this by catching these players and feed them + # with some silence at first connect so they get the full audio at the second connect + if ( + child_player_id in self.workaround_players + and child_player_id not in streamjob.seen_players + ): + # known workaround player 1st connect --> send silence + streamjob.seen_players.add(child_player_id) + self.logger.debug( + "Sending silent chunk to Player %s", + child_player_id, + ) + async for chunk in get_silence(10, streamjob.pcm_format): + await resp.write(chunk) + return resp + + if child_player_id in streamjob.subscribed_players: + # player not yet known as workaround player but it + # is requesting the same stream twice, mark it as workaround player + self.logger.warning( + "Player %s is making multiple requests " + "to the same stream, marking it as player that needs a workaround", + child_player_id, + ) + self.workaround_players.add(child_player_id) + # all checks passed, start streaming! self.logger.debug( "Start serving multi-subscriber Queue flow audio stream for queue %s to player %s", @@ -746,7 +775,6 @@ async def get_flow_stream( """Get a flow stream of all tracks in the queue.""" # ruff: noqa: PLR0915 assert pcm_format.content_type.is_pcm() - queue_player = self.mass.players.get(queue.queue_id) queue_track = None last_fadeout_part = b"" self.logger.info("Start Queue Flow stream for Queue %s", queue.display_name) @@ -764,9 +792,7 @@ async def get_flow_stream( _, queue_track, use_crossfade, - ) = await self.mass.players.queues.preload_next_url( - queue.queue_id, queue_track.queue_item_id - ) + ) = await self.mass.players.queues.preload_next_url(queue.queue_id) except QueueEmpty: break @@ -799,6 +825,7 @@ async def get_flow_stream( buffer = b"" bytes_written = 0 + time_started = time.time() chunk_num = 0 # handle incoming audio chunks async for chunk in get_media_stream( @@ -812,15 +839,6 @@ async def get_flow_stream( ): chunk_num += 1 - # slow down if the player buffers too aggressively - seconds_streamed = int(bytes_written / pcm_sample_size) - if ( - seconds_streamed > 10 - and queue_player.corrected_elapsed_time > 10 - and (seconds_streamed - queue_player.corrected_elapsed_time) > 10 - ): - await asyncio.sleep(1) - #### HANDLE FIRST PART OF TRACK # buffer full for crossfade @@ -848,6 +866,15 @@ async def get_flow_stream( buffer = b"" continue + # slow down if the player buffers too aggressively + while True: + seconds_streamed = int(bytes_written / pcm_sample_size) + time_passed = time.time() - time_started + seconds_buffered = seconds_streamed - time_passed + if seconds_buffered < 18: + break + await asyncio.sleep(1) + # enough data in buffer, feed to output if len(buffer) >= (buffer_size * 2): yield buffer[:buffer_size] @@ -900,9 +927,9 @@ async def _get_player_ffmpeg_args( # generic args generic_args = [ "ffmpeg", - # "-hide_banner", - # "-loglevel", - # "warning" if self.logger.isEnabledFor(logging.DEBUG) else "quiet", + "-hide_banner", + "-loglevel", + "warning" if self.logger.isEnabledFor(logging.DEBUG) else "quiet", "-ignore_unknown", ] # input args @@ -910,7 +937,9 @@ async def _get_player_ffmpeg_args( "-f", input_format.content_type.value, "-ac", - "2", + str(input_format.channels), + "-channel_layout", + "mono" if input_format.channels == 1 else "stereo", "-ar", str(input_format.sample_rate), "-i", diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index d86c22167..96c8d2506 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -675,23 +675,25 @@ async def writer(): async def get_silence( duration: int, - output_fmt: ContentType = ContentType.WAV, - sample_rate: int = 44100, - bit_depth: int = 16, + output_format: AudioFormat, ) -> AsyncGenerator[bytes, None]: """Create stream of silence, encoded to format of choice.""" - # wav silence = just zero's - if output_fmt == ContentType.WAV: + if output_format.content_type.is_pcm(): + # pcm = just zeros + for _ in range(0, duration): + yield b"\0" * int(output_format.sample_rate * (output_format.bit_depth / 8) * 2) + return + if output_format.content_type == ContentType.WAV: + # wav silence = wave header + zero's yield create_wave_header( - samplerate=sample_rate, + samplerate=output_format.sample_rate, channels=2, - bitspersample=bit_depth, + bitspersample=output_format.bit_depth, duration=duration, ) for _ in range(0, duration): - yield b"\0" * int(sample_rate * (bit_depth / 8) * 2) + yield b"\0" * int(output_format.sample_rate * (output_format.bit_depth / 8) * 2) return - # use ffmpeg for all other encodings args = [ "ffmpeg", @@ -701,11 +703,11 @@ async def get_silence( "-f", "lavfi", "-i", - f"anullsrc=r={sample_rate}:cl={'stereo'}", + f"anullsrc=r={output_format.sample_rate}:cl={'stereo'}", "-t", str(duration), "-f", - output_fmt.value, + output_format.output_fmt.value, "-", ] async with AsyncProcess(args) as ffmpeg_proc: @@ -760,7 +762,12 @@ async def _get_ffmpeg_args( "file,http,https,tcp,tls,crypto,pipe,fd", # support nested protocols (e.g. within playlist) ] # collect input args - input_args = [] + input_args = [ + "-ac", + str(streamdetails.audio_format.channels), + "-channel_layout", + "mono" if streamdetails.audio_format.channels == 1 else "stereo", + ] if seek_position: input_args += ["-ss", str(seek_position)] if streamdetails.direct: @@ -784,12 +791,15 @@ async def _get_ffmpeg_args( "5xx", ] - input_args += ["-ac", str(streamdetails.audio_format.channels), "-i", streamdetails.direct] + input_args += ["-i", streamdetails.direct] else: # the input is received from pipe/stdin if streamdetails.audio_format.content_type != ContentType.UNKNOWN: input_args += ["-f", streamdetails.audio_format.content_type.value] - input_args += ["-ac", str(streamdetails.audio_format.channels), "-i", "-"] + input_args += [ + "-i", + "-", + ] # collect output args output_args = [ diff --git a/music_assistant/server/helpers/didl_lite.py b/music_assistant/server/helpers/didl_lite.py index f08277d20..b537d4a40 100644 --- a/music_assistant/server/helpers/didl_lite.py +++ b/music_assistant/server/helpers/didl_lite.py @@ -18,15 +18,15 @@ def create_didl_metadata( mass: MusicAssistant, url: str, queue_item: QueueItem | None = None ) -> str: """Create DIDL metadata string from url and (optional) QueueItem.""" - ext = url.split(".")[-1] + ext = url.split(".")[-1].split("?")[0] if queue_item is None: return ( '' - f"Music Assistant" - f"{MASS_LOGO_ONLINE}" + "Music Assistant" + f"{escape_string(MASS_LOGO_ONLINE)}" "object.item.audioItem.audioBroadcast" f"audio/{ext}" - f'{url}' + f'{escape_string(url)}' "" "" ) @@ -37,22 +37,22 @@ def create_didl_metadata( return ( '' f'' - f"{_escape_str(queue_item.name)}" - f"{_escape_str(image_url)}" + f"{escape_string(queue_item.name)}" + f"{escape_string(image_url)}" f"{queue_item.queue_item_id}" "object.item.audioItem.audioBroadcast" f"audio/{ext}" - f'{url}' + f'{escape_string(url)}' "" "" ) - title = _escape_str(queue_item.media_item.name) + title = escape_string(queue_item.media_item.name) if queue_item.media_item.artists and queue_item.media_item.artists[0].name: - artist = _escape_str(queue_item.media_item.artists[0].name) + artist = escape_string(queue_item.media_item.artists[0].name) else: artist = "" if queue_item.media_item.album and queue_item.media_item.album.name: - album = _escape_str(queue_item.media_item.album.name) + album = escape_string(queue_item.media_item.album.name) else: album = "" item_class = "object.item.audioItem.musicTrack" @@ -67,18 +67,19 @@ def create_didl_metadata( f"{queue_item.duration}" "Music Assistant" f"{queue_item.queue_item_id}" - f"{_escape_str(image_url)}" + f"{escape_string(image_url)}" f"{item_class}" f"audio/{ext}" - f'{url}' + f'{escape_string(url)}' "" "" ) -def _escape_str(data: str) -> str: +def escape_string(data: str) -> str: """Create DIDL-safe string.""" data = data.replace("&", "&") + # data = data.replace("?", "?") data = data.replace(">", ">") data = data.replace("<", "<") return data diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index 41aeae138..258a26f6f 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -84,7 +84,7 @@ async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStr - stream_job: the MultiClientStreamJob that the player should start playing. """ url = await stream_job.resolve_stream_url(player_id) - await self.cmd_play_url(player_id=player_id, url=url) + await self.cmd_play_url(player_id=player_id, url=url, queue_item=None) async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player. diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index 8d930bcf6..3cd7f69b2 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -207,7 +207,7 @@ async def cmd_play_url( await asyncio.to_thread( castplayer.cc.play_media, url, - content_type=f"audio/{url.split('.')[-1]}", + content_type=f'audio/{url.split(".")[-1].split("?")[0]}', title="Music Assistant", thumb=MASS_LOGO_ONLINE, media_info={ @@ -401,13 +401,13 @@ def on_new_cast_status(self, castplayer: CastPlayer, status: CastStatus) -> None # handle stereo pairs if castplayer.cast_info.is_multichannel_group: castplayer.player.type = PlayerType.STEREO_PAIR - castplayer.player.group_childs = [] + castplayer.player.group_childs = set() # handle cast groups if castplayer.cast_info.is_audio_group and not castplayer.cast_info.is_multichannel_group: castplayer.player.type = PlayerType.GROUP - castplayer.player.group_childs = [ + castplayer.player.group_childs = { str(UUID(x)) for x in castplayer.mz_controller.members - ] + } castplayer.player.supported_features = ( PlayerFeature.POWER, PlayerFeature.VOLUME_SET, @@ -419,7 +419,6 @@ def on_new_cast_status(self, castplayer: CastPlayer, status: CastStatus) -> None def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus): """Handle updated MediaStatus.""" castplayer.logger.debug("Received media status update: %s", status.player_state) - prev_item_id = castplayer.player.current_item_id # player state if status.player_is_playing: castplayer.player.state = PlayerState.PLAYING @@ -436,20 +435,14 @@ def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus): castplayer.player.elapsed_time = status.current_time # current media - queue_item_id = status.media_custom_data.get("queue_item_id") - castplayer.player.current_item_id = queue_item_id castplayer.player.current_url = status.content_id self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) # enqueue next item if needed if castplayer.player.state == PlayerState.PLAYING and ( - prev_item_id != castplayer.player.current_item_id - or not castplayer.next_url - or castplayer.next_url == castplayer.player.current_url + not castplayer.next_url or castplayer.next_url == castplayer.player.current_url ): - asyncio.run_coroutine_threadsafe( - self._enqueue_next_track(castplayer, queue_item_id), self.mass.loop - ) + asyncio.run_coroutine_threadsafe(self._enqueue_next_track(castplayer), self.mass.loop) def on_new_connection_status(self, castplayer: CastPlayer, status: ConnectionStatus) -> None: """Handle updated ConnectionStatus.""" @@ -483,11 +476,11 @@ def on_new_connection_status(self, castplayer: CastPlayer, status: ConnectionSta ### Helpers / utils - async def _enqueue_next_track(self, castplayer: CastPlayer, current_queue_item_id: str) -> None: + async def _enqueue_next_track(self, castplayer: CastPlayer) -> None: """Enqueue the next track of the MA queue on the CC queue.""" try: next_url, next_item, _ = await self.mass.players.queues.preload_next_url( - castplayer.player_id, current_queue_item_id + castplayer.player_id ) except QueueEmpty: return @@ -501,7 +494,7 @@ async def _enqueue_next_track(self, castplayer: CastPlayer, current_queue_item_i await asyncio.to_thread( castplayer.cc.play_media, next_url, - content_type=f"audio/{next_url.split('.')[-1]}", + content_type=f'audio/{next_url.split(".")[-1].split("?")[0]}', title="Music Assistant", thumb=MASS_LOGO_ONLINE, enqueue=True, diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 115664aa2..9f44d4d81 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -120,6 +120,7 @@ class DLNAPlayer: bootid: int | None = None last_seen: float = field(default_factory=time.time) next_url: str | None = None + next_item: QueueItem | None = None supports_next_uri = True end_of_track_reached = False @@ -140,7 +141,6 @@ def update_attributes(self): self.player.elapsed_time_last_updated = ( self.device.media_position_updated_at.timestamp() ) - self.player.current_item_id = self.device._get_current_track_meta_data("queue_item_id") if self.device.media_duration and self.player.corrected_elapsed_time: self.end_of_track_reached = ( self.device.media_duration - self.player.corrected_elapsed_time @@ -531,24 +531,21 @@ def _handle_event( dlna_player.last_seen = time.time() self.mass.create_task(self._update_player(dlna_player)) - async def _enqueue_next_track( - self, dlna_player: DLNAPlayer, current_queue_item_id: str - ) -> None: + async def _enqueue_next_track(self, dlna_player: DLNAPlayer) -> None: """Enqueue the next track of the MA queue on the CC queue.""" try: ( next_url, next_item, _, - ) = await self.mass.players.queues.preload_next_url( - dlna_player.udn, current_queue_item_id - ) + ) = await self.mass.players.queues.preload_next_url(dlna_player.udn) except QueueEmpty: return if dlna_player.next_url == next_url: return # already set ?! dlna_player.next_url = next_url + dlna_player.next_item = next_item # no need to try setting the next url if we already know the player does not support it if not dlna_player.supports_next_uri: @@ -571,11 +568,9 @@ async def _enqueue_next_track( async def _update_player(self, dlna_player: DLNAPlayer) -> None: """Update DLNA Player.""" - prev_item_id = dlna_player.player.current_item_id prev_url = dlna_player.player.current_url prev_state = dlna_player.player.state dlna_player.update_attributes() - current_item_id = dlna_player.player.current_item_id current_url = dlna_player.player.current_url current_state = dlna_player.player.state @@ -588,11 +583,9 @@ async def _update_player(self, dlna_player: DLNAPlayer) -> None: # enqueue next item if needed if dlna_player.player.state == PlayerState.PLAYING and ( - prev_item_id != current_item_id - or not dlna_player.next_url - or dlna_player.next_url == current_url + not dlna_player.next_url or dlna_player.next_url == current_url ): - self.mass.create_task(self._enqueue_next_track(dlna_player, current_item_id)) + self.mass.create_task(self._enqueue_next_track(dlna_player)) # if player does not support next uri, manual play it if ( not dlna_player.supports_next_uri @@ -601,6 +594,6 @@ async def _update_player(self, dlna_player: DLNAPlayer) -> None: and dlna_player.next_url and dlna_player.end_of_track_reached ): - await self.cmd_play_url(dlna_player.udn, dlna_player.next_url) + await self.cmd_play_url(dlna_player.udn, dlna_player.next_url, dlna_player.next_item) dlna_player.end_of_track_reached = False dlna_player.next_url = None diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index 8a6b23f75..38fb93c34 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -493,7 +493,7 @@ async def cmd_sync(self, player_id: str, target_player: str) -> None: assert child_player parent_player = self.mass.players.get(target_player) assert parent_player - parent_player.group_childs.append(child_player.player_id) + parent_player.group_childs.add(child_player.player_id) child_player.synced_to = parent_player.player_id self.mass.players.update(child_player.player_id) self.mass.players.update(parent_player.player_id) @@ -511,7 +511,7 @@ async def cmd_unsync(self, player_id: str) -> None: if child_player.state == PlayerState.PLAYING: await self.cmd_stop(child_player.player_id) child_player.synced_to = None - with suppress(ValueError): + with suppress(KeyError): parent_player.group_childs.remove(child_player.player_id) self.mass.players.update(child_player.player_id) self.mass.players.update(parent_player.player_id) @@ -563,6 +563,8 @@ def _handle_player_update(self, client: SlimClient) -> None: ), max_sample_rate=int(client.max_sample_rate), ) + # always add player itself to group child's + player.group_childs.add(player_id) if virtual_provider_info: # if this player is part of a virtual provider run the callback virtual_provider_info[0](player) @@ -571,9 +573,6 @@ def _handle_player_update(self, client: SlimClient) -> None: # update player state on player events player.available = True player.current_url = client.current_url - player.current_item_id = ( - client.current_metadata["item_id"] if client.current_metadata else None - ) player.name = client.name player.powered = client.powered player.state = STATE_MAP[client.state] @@ -689,7 +688,7 @@ async def _handle_decoder_ready(self, client: SlimClient) -> None: return try: next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( - client.player_id, client.current_metadata["item_id"] + client.player_id ) async with asyncio.TaskGroup() as tg: for client in self._get_sync_clients(client.player_id): @@ -712,7 +711,7 @@ async def _handle_buffer_ready(self, client: SlimClient) -> None: if player.synced_to: # unpause of sync child is handled by sync master return - if not player.group_childs: + if len(player.group_childs) <= 1: # not a sync group, continue await client.play() return @@ -788,7 +787,7 @@ async def _skip_over(self, client_id: str, millis: int) -> None: def _get_sync_clients(self, player_id: str) -> Generator[SlimClient]: """Get all sync clients for a player.""" player = self.mass.players.get(player_id) - for child_id in [player.player_id] + player.group_childs: + for child_id in player.group_childs: if client := self._socket_clients.get(child_id): yield client diff --git a/music_assistant/server/providers/slimproto/cli.py b/music_assistant/server/providers/slimproto/cli.py index c585c169a..2e80f1a59 100644 --- a/music_assistant/server/providers/slimproto/cli.py +++ b/music_assistant/server/providers/slimproto/cli.py @@ -644,7 +644,7 @@ async def _handle_status( "sleep": 0, "will_sleep_in": 0, "sync_master": player.synced_to, - "sync_slaves": ",".join(player.group_childs), + "sync_slaves": ",".join(x for x in player.group_childs if x != player_id), "mixer volume": player.volume_level, "playlist repeat": REPEATMODE_MAP[queue.repeat_mode], "playlist shuffle": int(queue.shuffle_enabled), diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 4570d3ae5..42704228d 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -4,7 +4,6 @@ import asyncio import logging import time -import xml.etree.ElementTree as ET # noqa: N817 from contextlib import suppress from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -15,7 +14,7 @@ from soco.groups import ZoneGroup from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType -from music_assistant.common.models.enums import MediaType, PlayerFeature, PlayerState, PlayerType +from music_assistant.common.models.enums import PlayerFeature, PlayerState, PlayerType from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty from music_assistant.common.models.player import DeviceInfo, Player from music_assistant.common.models.queue_item import QueueItem @@ -74,7 +73,6 @@ class SonosPlayer: is_stereo_pair: bool = False next_url: str | None = None elapsed_time: int = 0 - current_item_id: str | None = None radio_mode_started: float | None = None subscriptions: list[SubscriptionBase] = field(default_factory=list) @@ -112,19 +110,6 @@ def update_info( self.track_info_updated = time.time() self.elapsed_time = _timespan_secs(self.track_info["position"]) or 0 - current_item_id = None - if track_metadata := self.track_info.get("metadata"): - # extract queue_item_id from metadata xml - try: - xml_root = ET.XML(track_metadata) - for match in xml_root.iter("{http://purl.org/dc/elements/1.1/}queueItemId"): - item_id = match.text - current_item_id = item_id - break - except (ET.ParseError, AttributeError): - pass - self.current_item_id = current_item_id - # speaker info if update_speaker_info: self.speaker_info = self.soco.get_speaker_info() @@ -153,7 +138,6 @@ def update_attributes(self): # media info (track info) self.player.current_url = self.track_info["uri"] - self.player.current_item_id = self.current_item_id if self.radio_mode_started is not None: # sonos reports bullshit elapsed time while playing radio, @@ -170,9 +154,7 @@ def update_attributes(self): if self.group_info and self.group_info.coordinator.uid == self.player_id: # this player is the sync leader self.player.synced_to = None - self.player.group_childs = { - x.uid for x in self.group_info.members if x.uid != self.player_id and x.is_visible - } + self.player.group_childs = {x.uid for x in self.group_info.members if x.is_visible} if not self.player.group_childs: self.player.type = PlayerType.STEREO_PAIR elif self.group_info and self.group_info.coordinator: @@ -288,14 +270,13 @@ async def cmd_play_url( await asyncio.to_thread(sonos_player.soco.stop) await asyncio.to_thread(sonos_player.soco.clear_queue) - radio_mode = queue_item is not None and ( - not queue_item.duration or queue_item.media_type == MediaType.RADIO - ) - if radio_mode: + if queue_item is None: + # enforce mp3 radio mode for flow stream + url = url.replace(".flac", ".mp3").replace(".wav", ".mp3") sonos_player.radio_mode_started = time.time() - url = url.replace("http", "x-rincon-mp3radio") - metadata = create_didl_metadata(self.mass, url, queue_item) - await asyncio.to_thread(sonos_player.soco.play_uri, url, meta=metadata) + await asyncio.to_thread( + sonos_player.soco.play_uri, url, title="Music Assistant", force_radio=True + ) else: sonos_player.radio_mode_started = None await self._enqueue_item(sonos_player, url=url, queue_item=queue_item) @@ -515,17 +496,11 @@ def _handle_zone_group_topology_event( sonos_player.group_info_updated = time.time() asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop) - async def _enqueue_next_track( - self, sonos_player: SonosPlayer, current_queue_item_id: str - ) -> None: + async def _enqueue_next_track(self, sonos_player: SonosPlayer) -> None: """Enqueue the next track of the MA queue on the CC queue.""" - if not current_queue_item_id: - return # guard - if not self.mass.players.queues.get_item(sonos_player.player_id, current_queue_item_id): - return # guard try: next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( - sonos_player.player_id, current_queue_item_id + sonos_player.player_id ) except QueueEmpty: return @@ -573,7 +548,6 @@ async def _enqueue_item( async def _update_player(self, sonos_player: SonosPlayer, signal_update: bool = True) -> None: """Update Sonos Player.""" - prev_item_id = sonos_player.current_item_id prev_url = sonos_player.player.current_url prev_state = sonos_player.player.state sonos_player.update_attributes() @@ -599,13 +573,9 @@ async def _update_player(self, sonos_player: SonosPlayer, signal_update: bool = # enqueue next item if needed if sonos_player.player.state == PlayerState.PLAYING and ( - prev_item_id != sonos_player.current_item_id - or not sonos_player.next_url - or sonos_player.next_url == sonos_player.player.current_url + sonos_player.next_url or sonos_player.next_url == sonos_player.player.current_url ): - self.mass.create_task( - self._enqueue_next_track(sonos_player, sonos_player.current_item_id) - ) + self.mass.create_task(self._enqueue_next_track(sonos_player)) def _convert_state(sonos_state: str) -> PlayerState: diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index bacbeaada..5b9119a37 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -302,7 +302,6 @@ def update_attributes(self, player_id: str) -> None: continue if member.state not in (PlayerState.PLAYING, PlayerState.PAUSED): continue - group_player.current_item_id = member.current_item_id group_player.current_url = member.current_url group_player.elapsed_time = member.elapsed_time group_player.elapsed_time_last_updated = member.elapsed_time_last_updated @@ -310,7 +309,6 @@ def update_attributes(self, player_id: str) -> None: break else: group_player.state = PlayerState.IDLE - group_player.current_item_id = None group_player.current_url = None def on_child_state( From c06c7a87248f445d01dcdbc90b47c5d04da49f2d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 6 Jul 2023 00:03:08 +0200 Subject: [PATCH 4/8] fix group child ids --- music_assistant/server/controllers/players.py | 12 ++---------- music_assistant/server/providers/sonos/__init__.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 3e521d0ba..11632e933 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -539,8 +539,8 @@ def _get_active_source(self, player: Player) -> str: def _get_group_volume_level(self, player: Player) -> int: """Calculate a group volume from the grouped members.""" - if len(player.group_childs) <= 1: - # player is not a group + if len(player.group_childs) == 0: + # player is not a group or syncgroup return player.volume_level # calculate group volume from all (turned on) players group_volume = 0 @@ -560,14 +560,6 @@ def _get_child_players( ) -> list[Player]: """Get (child) players attached to a grouped player.""" child_players: list[Player] = [] - if player.type != PlayerType.GROUP: # noqa: SIM102 - # if the player is not a dedicated player group, - # it is the master in a sync group and should be always present as child player - if player.player_id not in player.group_childs: - player.group_childs.add(player.player_id) - if len(player.group_childs) == 1: - # player is not synced - return child_players for child_id in player.group_childs: if child_player := self.get(child_id, False): if not (not only_powered or child_player.powered): diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 42704228d..883a21ae9 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -154,12 +154,21 @@ def update_attributes(self): if self.group_info and self.group_info.coordinator.uid == self.player_id: # this player is the sync leader self.player.synced_to = None - self.player.group_childs = {x.uid for x in self.group_info.members if x.is_visible} - if not self.player.group_childs: + group_members = {x.uid for x in self.group_info.members if x.is_visible} + if not group_members: + # not sure about this ?! self.player.type = PlayerType.STEREO_PAIR + elif group_members == {self.player_id}: + self.player.group_childs = set() + else: + self.player.group_childs = group_members elif self.group_info and self.group_info.coordinator: # player is synced to + self.player.group_childs = set() self.player.synced_to = self.group_info.coordinator.uid + else: + # unsure + self.player.group_childs = set() async def check_poll(self) -> None: """Check if any of the endpoints needs to be polled for info.""" From e8f26aca1eb73f5483bcd0b0fddc6d5c504268fe Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 6 Jul 2023 08:44:55 +0200 Subject: [PATCH 5/8] some small optimizations --- music_assistant/server/controllers/streams.py | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 2d2ccad25..8bf78c629 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -96,8 +96,9 @@ def __init__( self.job_id = shortuuid.uuid() self.expected_players: set[str] = set() self.subscribed_players: dict[str, asyncio.Queue[bytes]] = {} - self.start_chunk: dict[str, int] = {} self.seen_players: set[str] = set() + self.bytes_streamed: int = 0 + self.client_seconds_skipped: dict[str, int] = {} self._all_clients_connected = asyncio.Event() # start running the audio task in the background self._audio_task = asyncio.create_task(self._stream_job_runner()) @@ -159,7 +160,15 @@ async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: self.seen_players.add(player_id) self.subscribed_players[player_id] = sub_queue = asyncio.Queue(1) - if len(self.subscribed_players) == len(self.expected_players): + if self._all_clients_connected.is_set(): + # client subscribes while we're already started + # calculate how many seconds the client missed so far + pcm_sample_size = int( + self.pcm_format.sample_rate * (self.pcm_format.bit_depth / 8) * 2 + ) + self.client_seconds_skipped[player_id] = self.bytes_streamed / pcm_sample_size + + elif len(self.subscribed_players) == len(self.expected_players): # we reached the number of expected subscribers, set event # so that chunks can be pushed self._all_clients_connected.set() @@ -178,18 +187,17 @@ async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: if len(self.subscribed_players) == 0 and self._audio_task and not self.finished: self._audio_task.cancel() - async def _put_chunk(self, chunk: bytes, chunk_num: int, timeout: float = 120) -> None: + async def _put_chunk(self, chunk: bytes) -> None: """Put chunk of data to all subscribers.""" - async with asyncio.timeout(timeout): - async with asyncio.TaskGroup() as tg: - for player_id in self.subscribed_players: - try: - sub_queue = self.subscribed_players[player_id] - tg.create_task(sub_queue.put(chunk)) - if player_id not in self.start_chunk: - self.start_chunk[player_id] = chunk_num - except KeyError: - pass # race condition + async with asyncio.TaskGroup() as tg: + for player_id in self.subscribed_players: + try: + sub_queue = self.subscribed_players[player_id] + # put this chunk on the player's subqueue + tg.create_task(sub_queue.put(chunk)) + except KeyError: + pass # race condition + self.bytes_streamed += len(chunk) async def _stream_job_runner(self) -> None: """Feed audio chunks to StreamJob subscribers.""" @@ -197,8 +205,7 @@ async def _stream_job_runner(self) -> None: async for chunk in self.stream_controller.get_flow_stream( self.queue, self.start_queue_item, self.pcm_format, self.seek_position, self.fade_in ): - chunk_num += 1 - if chunk_num == 1: + if chunk_num == 0: # wait until all expected clients are connected try: async with asyncio.timeout(10): @@ -225,10 +232,11 @@ async def _stream_job_runner(self) -> None: while len(self.subscribed_players) == 0: # just a guard in case of race conditions await asyncio.sleep(0.2) - await self._put_chunk(chunk, chunk_num=chunk_num) + await self._put_chunk(chunk) + chunk_num += 1 # mark EOF with empty chunk - await self._put_chunk(b"", chunk_num=chunk_num + 1) + await self._put_chunk(b"") def parse_pcm_info(content_type: str) -> tuple[int, int, int]: @@ -710,10 +718,12 @@ async def serve_multi_subscriber_stream(self, request: web.Request) -> web.Respo # is requesting the same stream twice, mark it as workaround player self.logger.warning( "Player %s is making multiple requests " - "to the same stream, marking it as player that needs a workaround", + "to the same stream, playback may be disturbed", child_player_id, ) - self.workaround_players.add(child_player_id) + if child_player.provider in ("sonos", "dlna"): + # these platforms are known for this ugly behavior + self.workaround_players.add(child_player_id) # all checks passed, start streaming! self.logger.debug( @@ -777,6 +787,8 @@ async def get_flow_stream( assert pcm_format.content_type.is_pcm() queue_track = None last_fadeout_part = b"" + total_bytes_written = 0 + time_started = time.time() self.logger.info("Start Queue Flow stream for Queue %s", queue.display_name) while True: @@ -825,7 +837,6 @@ async def get_flow_stream( buffer = b"" bytes_written = 0 - time_started = time.time() chunk_num = 0 # handle incoming audio chunks async for chunk in get_media_stream( @@ -868,10 +879,10 @@ async def get_flow_stream( # slow down if the player buffers too aggressively while True: - seconds_streamed = int(bytes_written / pcm_sample_size) + seconds_streamed = (total_bytes_written + bytes_written) / pcm_sample_size time_passed = time.time() - time_started seconds_buffered = seconds_streamed - time_passed - if seconds_buffered < 18: + if seconds_buffered < 16: break await asyncio.sleep(1) @@ -907,6 +918,7 @@ async def get_flow_stream( # end of the track reached - store accurate duration queue_track.streamdetails.seconds_streamed = bytes_written / pcm_sample_size + total_bytes_written += bytes_written self.logger.debug( "Finished Streaming queue track: %s (%s) on queue %s", queue_track.streamdetails.uri, From 3adca49c63259cef02bcd87f93c36eab9f9a0ceb Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 7 Jul 2023 00:34:50 +0200 Subject: [PATCH 6/8] park the sync stuff --- music_assistant/common/models/media_items.py | 5 + music_assistant/server/controllers/cache.py | 6 +- .../server/controllers/player_queues.py | 44 ++--- music_assistant/server/controllers/players.py | 4 +- music_assistant/server/controllers/streams.py | 92 ++++------ music_assistant/server/helpers/process.py | 11 +- .../server/providers/airplay/__init__.py | 7 + .../server/providers/slimproto/__init__.py | 157 ++++++++++++------ music_assistant/server/server.py | 5 +- 9 files changed, 182 insertions(+), 149 deletions(-) diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py index 8cb8b799e..b8e7a489d 100755 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -53,6 +53,11 @@ def quality(self) -> int: score += 1 return int(score) + @property + def pcm_sample_size(self) -> int: + """Return the PCM sample size.""" + return int(self.sample_rate * (self.bit_depth / 8) * self.channels) + @dataclass(frozen=True) class ProviderMapping(DataClassDictMixin): diff --git a/music_assistant/server/controllers/cache.py b/music_assistant/server/controllers/cache.py index fd9f2ed9b..0da7001f7 100644 --- a/music_assistant/server/controllers/cache.py +++ b/music_assistant/server/controllers/cache.py @@ -3,7 +3,6 @@ import asyncio import functools -import json import logging import os import time @@ -11,6 +10,7 @@ from collections.abc import Iterator, MutableMapping from typing import TYPE_CHECKING, Any +from music_assistant.common.helpers.json import json_dumps, json_loads from music_assistant.constants import ( DB_TABLE_CACHE, DB_TABLE_SETTINGS, @@ -69,7 +69,7 @@ async def get(self, cache_key: str, checksum: str | None = None, default=None): not checksum or db_row["checksum"] == checksum and db_row["expires"] >= cur_time ): try: - data = await asyncio.to_thread(json.loads, db_row["data"]) + data = await asyncio.to_thread(json_loads, db_row["data"]) except Exception as exc: # pylint: disable=broad-except LOGGER.exception("Error parsing cache data for %s", cache_key, exc_info=exc) else: @@ -93,7 +93,7 @@ async def set(self, cache_key, data, checksum="", expiration=(86400 * 30)): if (expires - time.time()) < 3600 * 4: # do not cache items in db with short expiration return - data = await asyncio.to_thread(json.dumps, data) + data = await asyncio.to_thread(json_dumps, data) await self.database.insert( DB_TABLE_CACHE, {"key": cache_key, "expires": expires, "checksum": checksum, "data": data}, diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 3df87bd2e..18d467370 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -433,7 +433,7 @@ async def seek(self, queue_id: str, position: int = 10) -> None: await self.play_index(queue_id, queue.current_index, position) @api_command("players/queue/resume") - async def resume(self, queue_id: str) -> None: + async def resume(self, queue_id: str, fade_in: bool | None = None) -> None: """Handle RESUME command for given queue. - queue_id: queue_id of the queue to handle the command. @@ -462,7 +462,7 @@ async def resume(self, queue_id: str) -> None: if resume_item is not None: resume_pos = resume_pos if resume_pos > 10 else 0 - fade_in = resume_pos > 0 + fade_in = fade_in if fade_in is not None else resume_pos > 0 await self.play_index(queue_id, resume_item.queue_item_id, resume_pos, fade_in) else: raise QueueEmpty(f"Resume queue requested but queue {queue_id} is empty") @@ -489,6 +489,8 @@ async def play_index( queue.index_in_buffer = index # power on player if needed await self.mass.players.cmd_power(queue_id, True) + # always send stop command first + # await self.mass.players.cmd_stop(queue_id) # execute the play_media command on the player queue_player = self.mass.players.get(queue_id) need_multi_stream = ( @@ -506,25 +508,25 @@ async def play_index( fade_in=fade_in, ) await player_prov.cmd_handle_stream_job(player_id=queue_id, stream_job=stream_job) - else: - # regular stream - queue.flow_mode = await self.mass.config.get_player_config_value( - queue.queue_id, CONF_FLOW_MODE - ) - url = await self.mass.streams.resolve_stream_url( - queue_id=queue_id, - queue_item=queue_item, - seek_position=int(seek_position), - fade_in=fade_in, - flow_mode=queue.flow_mode, - ) - await player_prov.cmd_play_url( - player_id=queue_id, - url=url, - # set queue_item to None if we're sending a flow mode url - # as the metadata is rather useless then - queue_item=None if queue.flow_mode else queue_item, - ) + return + # regular stream + queue.flow_mode = await self.mass.config.get_player_config_value( + queue.queue_id, CONF_FLOW_MODE + ) + url = await self.mass.streams.resolve_stream_url( + queue_id=queue_id, + queue_item=queue_item, + seek_position=int(seek_position), + fade_in=fade_in, + flow_mode=queue.flow_mode, + ) + await player_prov.cmd_play_url( + player_id=queue_id, + url=url, + # set queue_item to None if we're sending a flow mode url + # as the metadata is rather useless then + queue_item=None if queue.flow_mode else queue_item, + ) # Interaction with player diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 11632e933..cbccfd674 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -254,8 +254,8 @@ async def cmd_stop(self, player_id: str) -> None: - player_id: player_id of the player to handle the command. """ player_id = self._check_redirect(player_id) - player_provider = self.get_player_provider(player_id) - await player_provider.cmd_stop(player_id) + if player_provider := self.get_player_provider(player_id): + await player_provider.cmd_stop(player_id) @api_command("players/cmd/play") async def cmd_play(self, player_id: str) -> None: diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 8bf78c629..4571d548c 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -9,9 +9,9 @@ import asyncio import logging -import time import urllib.parse from collections.abc import AsyncGenerator +from contextlib import suppress from typing import TYPE_CHECKING import shortuuid @@ -42,7 +42,6 @@ crossfade_pcm_parts, get_media_stream, get_preview_stream, - get_silence, get_stream_details, ) from music_assistant.server.helpers.process import AsyncProcess @@ -96,17 +95,18 @@ def __init__( self.job_id = shortuuid.uuid() self.expected_players: set[str] = set() self.subscribed_players: dict[str, asyncio.Queue[bytes]] = {} - self.seen_players: set[str] = set() self.bytes_streamed: int = 0 self.client_seconds_skipped: dict[str, int] = {} self._all_clients_connected = asyncio.Event() # start running the audio task in the background self._audio_task = asyncio.create_task(self._stream_job_runner()) + self.logger = stream_controller.logger.getChild(f"streamjob_{self.job_id}") + self._finished: bool = False @property def finished(self) -> bool: """Return if this StreamJob is finished.""" - return self._audio_task.cancelled() or self._audio_task.done() + return self._finished or self._audio_task.done() @property def pending(self) -> bool: @@ -118,12 +118,15 @@ def running(self) -> bool: """Return if this Job is running.""" return not self.finished and not self.pending - async def stop(self) -> None: + def stop(self) -> None: """Stop running this job.""" - if not self.running: + self._finished = True + if self._audio_task.done(): return self._audio_task.cancel() - await self._audio_task + for sub_queue in self.subscribed_players.values(): + with suppress(asyncio.QueueFull): + sub_queue.put_nowait(b"") async def resolve_stream_url( self, @@ -150,25 +153,28 @@ async def resolve_stream_url( f";codec=pcm;rate={output_sample_rate};" f"bitrate={output_bit_depth};channels={channels}" ) - url = f"{self.stream_controller._server.base_url}/{self.queue_id}/multi/{child_player_id}/{self.start_queue_item.queue_item_id}.{fmt}?job_id={self.job_id}" # noqa: E501 + url = f"{self.stream_controller._server.base_url}/{self.queue_id}/multi/{self.job_id}/{child_player_id}/{self.start_queue_item.queue_item_id}.{fmt}" # noqa: E501 self.expected_players.add(child_player_id) return url async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: """Subscribe consumer and iterate incoming chunks on the queue.""" try: - self.seen_players.add(player_id) self.subscribed_players[player_id] = sub_queue = asyncio.Queue(1) if self._all_clients_connected.is_set(): # client subscribes while we're already started + self.logger.debug( + "Client %s is joining while the stream is already started", player_id + ) # calculate how many seconds the client missed so far - pcm_sample_size = int( - self.pcm_format.sample_rate * (self.pcm_format.bit_depth / 8) * 2 + self.client_seconds_skipped[player_id] = ( + self.bytes_streamed / self.pcm_format.pcm_sample_size ) - self.client_seconds_skipped[player_id] = self.bytes_streamed / pcm_sample_size + else: + self.logger.debug("Subscribed client %s", player_id) - elif len(self.subscribed_players) == len(self.expected_players): + if len(self.subscribed_players) == len(self.expected_players): # we reached the number of expected subscribers, set event # so that chunks can be pushed self._all_clients_connected.set() @@ -182,21 +188,19 @@ async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]: yield chunk finally: self.subscribed_players.pop(player_id, None) + self.logger.debug("Unsubscribed client %s", player_id) # check if this was the last subscriber and we should cancel await asyncio.sleep(2) if len(self.subscribed_players) == 0 and self._audio_task and not self.finished: + self.logger.debug("Cleaning up, all clients disappeared...") self._audio_task.cancel() async def _put_chunk(self, chunk: bytes) -> None: """Put chunk of data to all subscribers.""" async with asyncio.TaskGroup() as tg: - for player_id in self.subscribed_players: - try: - sub_queue = self.subscribed_players[player_id] - # put this chunk on the player's subqueue - tg.create_task(sub_queue.put(chunk)) - except KeyError: - pass # race condition + for sub_queue in list(self.subscribed_players.values()): + # put this chunk on the player's subqueue + tg.create_task(sub_queue.put(chunk)) self.bytes_streamed += len(chunk) async def _stream_job_runner(self) -> None: @@ -218,7 +222,7 @@ async def _stream_job_runner(self) -> None: self.queue.display_name, ) break - # not all clients connected but timeout expired, set flasg and move on + # not all clients connected but timeout expired, set flag and move on # with all clients that did connect self._all_clients_connected.set() else: @@ -229,9 +233,6 @@ async def _stream_job_runner(self) -> None: len(self.subscribed_players), len(self.expected_players), ) - while len(self.subscribed_players) == 0: - # just a guard in case of race conditions - await asyncio.sleep(0.2) await self._put_chunk(chunk) chunk_num += 1 @@ -337,7 +338,7 @@ async def setup(self) -> None: ("GET", "/preview", self.serve_preview_stream), ( "GET", - "/{queue_id}/multi/{player_id}/{queue_item_id}.{fmt}", + "/{queue_id}/multi/{job_id}/{player_id}/{queue_item_id}.{fmt}", self.serve_multi_subscriber_stream, ), ( @@ -429,7 +430,7 @@ async def create_multi_client_stream_job( if existing_job := self.multi_client_jobs.pop(queue_id, None): # noqa: SIM102 # cleanup existing job first if not existing_job.finished: - await existing_job.stop() + existing_job.stop() self.multi_client_jobs[queue_id] = stream_job = MultiClientStreamJob( self, @@ -663,8 +664,8 @@ async def serve_multi_subscriber_stream(self, request: web.Request) -> web.Respo streamjob = self.multi_client_jobs.get(queue_id) if not streamjob: raise web.HTTPNotFound(reason=f"Unknown StreamJob for queue: {queue_id}") - job_id = request.query.get("job_id") - if job_id and job_id != streamjob.job_id: + job_id = request.match_info["job_id"] + if job_id != streamjob.job_id: raise web.HTTPNotFound(reason=f"StreamJob ID {job_id} mismatch for queue: {queue_id}") child_player_id = request.match_info["player_id"] child_player = self.mass.players.get(child_player_id) @@ -697,33 +698,12 @@ async def serve_multi_subscriber_stream(self, request: web.Request) -> web.Respo # to the stream in an attempt to get the audio details such as duration # which is a bit pointless for our duration-less queue stream # and it completely messes with the subscription logic - # we try to workaround this by catching these players and feed them - # with some silence at first connect so they get the full audio at the second connect - if ( - child_player_id in self.workaround_players - and child_player_id not in streamjob.seen_players - ): - # known workaround player 1st connect --> send silence - streamjob.seen_players.add(child_player_id) - self.logger.debug( - "Sending silent chunk to Player %s", - child_player_id, - ) - async for chunk in get_silence(10, streamjob.pcm_format): - await resp.write(chunk) - return resp - if child_player_id in streamjob.subscribed_players: - # player not yet known as workaround player but it - # is requesting the same stream twice, mark it as workaround player self.logger.warning( "Player %s is making multiple requests " - "to the same stream, playback may be disturbed", + "to the same stream, playback may be disturbed!", child_player_id, ) - if child_player.provider in ("sonos", "dlna"): - # these platforms are known for this ugly behavior - self.workaround_players.add(child_player_id) # all checks passed, start streaming! self.logger.debug( @@ -788,7 +768,6 @@ async def get_flow_stream( queue_track = None last_fadeout_part = b"" total_bytes_written = 0 - time_started = time.time() self.logger.info("Start Queue Flow stream for Queue %s", queue.display_name) while True: @@ -830,7 +809,7 @@ async def get_flow_stream( # set some basic vars pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2) - crossfade_duration = 10 + crossfade_duration = 10 # TODO: grab from config crossfade_size = int(pcm_sample_size * crossfade_duration) queue_track.streamdetails.seconds_skipped = seek_position buffer_size = crossfade_size if use_crossfade else int(pcm_sample_size * 2) @@ -877,15 +856,6 @@ async def get_flow_stream( buffer = b"" continue - # slow down if the player buffers too aggressively - while True: - seconds_streamed = (total_bytes_written + bytes_written) / pcm_sample_size - time_passed = time.time() - time_started - seconds_buffered = seconds_streamed - time_passed - if seconds_buffered < 16: - break - await asyncio.sleep(1) - # enough data in buffer, feed to output if len(buffer) >= (buffer_size * 2): yield buffer[:buffer_size] diff --git a/music_assistant/server/helpers/process.py b/music_assistant/server/helpers/process.py index 6d8476fda..9e5763139 100644 --- a/music_assistant/server/helpers/process.py +++ b/music_assistant/server/helpers/process.py @@ -13,7 +13,6 @@ LOGGER = logging.getLogger(__name__) DEFAULT_CHUNKSIZE = 128000 -DEFAULT_TIMEOUT = 60 # pylint: disable=invalid-name @@ -91,23 +90,21 @@ async def iter_any(self, n: int = DEFAULT_CHUNKSIZE) -> AsyncGenerator[bytes, No break yield chunk - async def readexactly(self, n: int, timeout: int = DEFAULT_TIMEOUT) -> bytes: + async def readexactly(self, n: int) -> bytes: """Read exactly n bytes from the process stdout (or less if eof).""" try: - async with asyncio.timeout(timeout): - return await self._proc.stdout.readexactly(n) + return await self._proc.stdout.readexactly(n) except asyncio.IncompleteReadError as err: return err.partial - async def read(self, n: int, timeout: int = DEFAULT_TIMEOUT) -> bytes: + async def read(self, n: int) -> bytes: """Read up to n bytes from the stdout stream. If n is positive, this function try to read n bytes, and may return less or equal bytes than requested, but at least one byte. If EOF was received before any byte is read, this function returns empty byte object. """ - async with asyncio.timeout(timeout): - return await self._proc.stdout.read(n) + return await self._proc.stdout.read(n) async def write(self, data: bytes) -> None: """Write data to process stdin.""" diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 18bb4e7b5..6e92d7ac4 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -29,6 +29,7 @@ from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant + from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType from music_assistant.server.providers.slimproto import SlimprotoProvider @@ -195,6 +196,12 @@ async def cmd_play_url( queue_item=queue_item, ) + async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None: + """Handle StreamJob play command on given player.""" + # simply forward to underlying slimproto player + slimproto_prov = self.mass.get_provider("slimproto") + await slimproto_prov.cmd_handle_stream_job(player_id=player_id, stream_job=stream_job) + async def cmd_pause(self, player_id: str) -> None: """Send PAUSE command to given player.""" # simply forward to underlying slimproto player diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index 38fb93c34..0e7a09cf2 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any from aioslimproto.client import PlayerState as SlimPlayerState -from aioslimproto.client import SlimClient +from aioslimproto.client import SlimClient as SlimClientOrg from aioslimproto.client import TransitionType as SlimTransition from aioslimproto.const import EventType as SlimEventType from aioslimproto.discovery import start_discovery @@ -49,9 +49,8 @@ # sync constants MIN_DEVIATION_ADJUST = 10 # 10 milliseconds -MAX_DEVIATION_ADJUST = 20000 # 10 seconds -MIN_REQ_PLAYPOINTS = 2 # we need at least 2 measurements -MIN_REQ_MILLISECONDS = 500 +MIN_REQ_PLAYPOINTS = 4 # we need at least 4 measurements +ENABLE_EXPERIMENTAL_SYNC_JOIN = False # WIP # TODO: Implement display support @@ -69,7 +68,7 @@ class SyncPlayPoint: """Simple structure to describe a Sync Playpoint.""" timestamp: float - item_id: str + sync_job_id: str diff: int @@ -172,6 +171,7 @@ class SlimprotoProvider(PlayerProvider): _socket_clients: dict[str, SlimClient] _sync_playpoints: dict[str, deque[SyncPlayPoint]] _virtual_providers: dict[str, tuple[Callable, Callable]] + _do_not_resync_before: dict[str, float] _cli: LmsCli port: int = DEFAULT_SLIMPROTO_PORT @@ -180,6 +180,7 @@ async def handle_setup(self) -> None: self._socket_clients = {} self._sync_playpoints = {} self._virtual_providers = {} + self._do_not_resync_before = {} self.port = self.config.get_value(CONF_PORT) # start slimproto socket server try: @@ -235,7 +236,7 @@ async def _create_client( self.logger.debug("Socket client connected: %s", addr) def client_callback( - event_type: SlimEventType, client: SlimClient, data: Any = None # noqa: ARG001 + event_type: SlimEventType | str, client: SlimClient, data: Any = None # noqa: ARG001 ): if event_type == SlimEventType.PLAYER_DISCONNECTED: self.mass.create_task(self._handle_disconnected(client)) @@ -253,6 +254,11 @@ def client_callback( self.mass.create_task(self._handle_buffer_ready(client)) return + if event_type == "output_underrun": + # player ran out of buffer + self.mass.create_task(self._handle_output_underrun(client)) + return + if event_type == SlimEventType.PLAYER_HEARTBEAT: self._handle_player_heartbeat(client) return @@ -321,7 +327,7 @@ async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: type=ConfigEntryType.INTEGER, range=(0, 1500), default_value=0, - label="Correct synchronization delay", + label="Audio synchronization delay correction", description="If this player is playing audio synced with other players " "and you always hear the audio too late on this player, " "you can shift the audio a bit.", @@ -465,9 +471,6 @@ async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player.""" if client := self._socket_clients.get(player_id): await client.power(powered) - # if player := self.mass.players.get(player_id, raise_unavailable=False): - # player.powered = powered - # self.mass.players.update(player_id) # store last state in cache await self.mass.cache.set( f"{CACHE_KEY_PREV_STATE}.{player_id}", (powered, client.volume_level) @@ -490,29 +493,47 @@ async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: async def cmd_sync(self, player_id: str, target_player: str) -> None: """Handle SYNC command for given player.""" child_player = self.mass.players.get(player_id) - assert child_player + assert child_player # guard parent_player = self.mass.players.get(target_player) - assert parent_player + assert parent_player # guard + # always make sure that the parent player is part of the sync group + parent_player.group_childs.add(parent_player.player_id) parent_player.group_childs.add(child_player.player_id) child_player.synced_to = parent_player.player_id - self.mass.players.update(child_player.player_id) - self.mass.players.update(parent_player.player_id) - if parent_player.state in (PlayerState.PLAYING, PlayerState.PAUSED): - # playback needs to be restarted to get all players in sync - # TODO: If there is any need, we could make this smarter where the new - # sync child waits for the next track (or pcm chunk even). - active_queue = self.mass.players.queues.get_active_queue(parent_player.player_id) - await self.mass.players.queues.resume(active_queue.queue_id) + # check if we should (re)start or join a stream session + active_queue = self.mass.players.queues.get_active_queue(parent_player.player_id) + if ( + ENABLE_EXPERIMENTAL_SYNC_JOIN + and (stream_job := self.mass.streams.multi_client_jobs.get(active_queue.queue_id)) + and (stream_job.pending or stream_job.running) + ): + # this is a brave attempt to get players to to just join an existing stream + # session without having to resume playback + # it does not work reliable so far so consider this a WIP + # for someone to pickup with a lot of patience and too much time + url = await stream_job.resolve_stream_url(player_id) + client = self._socket_clients[player_id] + await self._handle_play_url(client, url, None, auto_play=True) + elif parent_player.state == PlayerState.PLAYING: + # playback needs to be restarted to form a new multi client stream session + await self.mass.players.queues.resume(active_queue.queue_id, fade_in=False) + else: + # make sure that the player manager gets an update + self.mass.players.update(child_player.player_id) + self.mass.players.update(parent_player.player_id) async def cmd_unsync(self, player_id: str) -> None: """Handle UNSYNC command for given player.""" child_player = self.mass.players.get(player_id) parent_player = self.mass.players.get(child_player.synced_to) - if child_player.state == PlayerState.PLAYING: - await self.cmd_stop(child_player.player_id) + # make sure to send stop to the player + await self.cmd_stop(child_player.player_id) child_player.synced_to = None with suppress(KeyError): parent_player.group_childs.remove(child_player.player_id) + if parent_player.group_childs == {parent_player.player_id}: + # last child vanished; the sync group is dissolved + parent_player.group_childs.remove(parent_player.player_id) self.mass.players.update(child_player.player_id) self.mass.players.update(parent_player.player_id) @@ -563,8 +584,6 @@ def _handle_player_update(self, client: SlimClient) -> None: ), max_sample_rate=int(client.max_sample_rate), ) - # always add player itself to group child's - player.group_childs.add(player_id) if virtual_provider_info: # if this player is part of a virtual provider run the callback virtual_provider_info[0](player) @@ -594,15 +613,30 @@ def _handle_player_heartbeat(self, client: SlimClient) -> None: # ignore server heartbeats when stopped return - player = self.mass.players.get(client.player_id) - sync_master_id = player.synced_to - # elapsed time change on the player will be auto picked up # by the player manager. + player = self.mass.players.get(client.player_id) player.elapsed_time = client.elapsed_seconds player.elapsed_time_last_updated = time.time() # handle sync + if player.synced_to: + self._handle_client_sync(client) + + async def _handle_output_underrun(self, client: SlimClient) -> None: + """Process SlimClient Output Underrun Event.""" + player = self.mass.players.get(client.player_id) + self.logger.error("Player %s ran out of buffer", player.display_name) + if player.synced_to: + # if player is synced, resync it + await self.cmd_sync(player.player_id, player.synced_to) + else: + await self.cmd_stop(client.player_id) + + def _handle_client_sync(self, client: SlimClient) -> None: + """Synchronize audio of a sync client.""" + player = self.mass.players.get(client.player_id) + sync_master_id = player.synced_to if not sync_master_id: # we only correct sync child's, not the sync master itself return @@ -616,28 +650,29 @@ def _handle_player_heartbeat(self, client: SlimClient) -> None: if client.state != SlimPlayerState.PLAYING: return + if backoff_time := self._do_not_resync_before.get(client.player_id): # noqa: SIM102 + # player has set a timestamp we should backoff from syncing it + if time.time() < backoff_time: + return + # we collect a few playpoints of the player to determine # average lag/drift so we can adjust accordingly - sync_playpoints = self._sync_playpoints.setdefault(client.player_id, deque(maxlen=5)) - - # make sure client has loaded the same track as sync master - client_item_id = client.current_metadata["item_id"] if client.current_metadata else None - master_item_id = ( - sync_master.current_metadata["item_id"] if sync_master.current_metadata else None + sync_playpoints = self._sync_playpoints.setdefault( + client.player_id, deque(maxlen=MIN_REQ_PLAYPOINTS) ) - if client_item_id != master_item_id: - return - # ignore sync when player is transitioning to a new track (next metadata is loaded) - next_item_id = client._next_metadata["item_id"] if client._next_metadata else None - if next_item_id and client_item_id != next_item_id: + + active_queue = self.mass.players.queues.get_active_queue(client.player_id) + stream_job = self.mass.streams.multi_client_jobs.get(active_queue.queue_id) + if not stream_job: + # should not happen, but just in case return last_playpoint = sync_playpoints[-1] if sync_playpoints else None if last_playpoint and (time.time() - last_playpoint.timestamp) > 10: # last playpoint is too old, invalidate sync_playpoints.clear() - if last_playpoint and last_playpoint.item_id != client_item_id: - # item has changed, invalidate + if last_playpoint and last_playpoint.sync_job_id != stream_job.job_id: + # streamjob has changed, invalidate sync_playpoints.clear() diff = int( @@ -645,13 +680,8 @@ def _handle_player_heartbeat(self, client: SlimClient) -> None: - self._get_corrected_elapsed_milliseconds(client) ) - if abs(diff) > MAX_DEVIATION_ADJUST: - # safety guard when player is transitioning or something is just plain wrong - sync_playpoints.clear() - return - # we can now append the current playpoint to our list - sync_playpoints.append(SyncPlayPoint(time.time(), client_item_id, diff)) + sync_playpoints.append(SyncPlayPoint(time.time(), stream_job.job_id, diff)) if len(sync_playpoints) < MIN_REQ_PLAYPOINTS: return @@ -663,15 +693,17 @@ def _handle_player_heartbeat(self, client: SlimClient) -> None: if delta < MIN_DEVIATION_ADJUST: return - # handle player lagging behind, fix with skip_ahead + # resync the player by skipping ahead or pause for x amount of (milli)seconds + sync_playpoints.clear() if avg_diff > 0: + # handle player lagging behind, fix with skip_ahead self.logger.debug("%s resync: skipAhead %sms", player.display_name, delta) - sync_playpoints.clear() + self._do_not_resync_before[client.player_id] = time.time() + 2 asyncio.create_task(self._skip_over(client.player_id, delta)) else: # handle player is drifting too far ahead, use pause_for to adjust self.logger.debug("%s resync: pauseFor %sms", player.display_name, delta) - sync_playpoints.clear() + self._do_not_resync_before[client.player_id] = time.time() + (delta / 1000) + 2 asyncio.create_task(self._pause_for(client.player_id, delta)) async def _handle_decoder_ready(self, client: SlimClient) -> None: @@ -711,7 +743,7 @@ async def _handle_buffer_ready(self, client: SlimClient) -> None: if player.synced_to: # unpause of sync child is handled by sync master return - if len(player.group_childs) <= 1: + if not player.group_childs: # not a sync group, continue await client.play() return @@ -729,7 +761,8 @@ async def _handle_buffer_ready(self, client: SlimClient) -> None: # all child's ready (or timeout) - start play async with asyncio.TaskGroup() as tg: for client in self._get_sync_clients(player.player_id): - timestamp = client.jiffies + 100 + timestamp = client.jiffies + 20 + self._do_not_resync_before[client.player_id] = time.time() + 1 tg.create_task(client.send_strm(b"u", replay_gain=int(timestamp))) async def _handle_connected(self, client: SlimClient) -> None: @@ -787,15 +820,31 @@ async def _skip_over(self, client_id: str, millis: int) -> None: def _get_sync_clients(self, player_id: str) -> Generator[SlimClient]: """Get all sync clients for a player.""" player = self.mass.players.get(player_id) - for child_id in player.group_childs: + # we need to return the player itself too + group_child_ids = {player_id} + group_child_ids.update(player.group_childs) + for child_id in group_child_ids: if client := self._socket_clients.get(child_id): yield client def _get_corrected_elapsed_milliseconds(self, client: SlimClient) -> int: """Return corrected elapsed milliseconds.""" + skipped_millis = 0 + active_queue = self.mass.players.queues.get_active_queue(client.player_id) + if stream_job := self.mass.streams.multi_client_jobs.get(active_queue.queue_id): + skipped_millis = stream_job.client_seconds_skipped.get(client.player_id, 0) * 1000 sync_delay = self.mass.config.get_raw_player_config_value( client.player_id, CONF_SYNC_ADJUST, 0 ) + current_millis = int(skipped_millis + client.elapsed_milliseconds) if sync_delay != 0: - return client.elapsed_milliseconds - sync_delay - return client.elapsed_milliseconds + return current_millis - sync_delay + return current_millis + + +class SlimClient(SlimClientOrg): + """Patched SLIMProto socket client.""" + + def _process_stat_stmo(self, data: bytes) -> None: # noqa: ARG002 + """Process incoming stat STMo message: Output Underrun.""" + self.callback("output_underrun", self) diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index e2d91d702..61f94080d 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -281,6 +281,8 @@ def create_task( Tasks created by this helper will be properly cancelled on stop. """ + if target is None: + raise RuntimeError("Target is missing") if existing := self._tracked_tasks.get(task_id): # prevent duplicate tasks if task_id is given and already present return existing @@ -301,8 +303,9 @@ def task_done_callback(_task: asyncio.Future | asyncio.Task): # noqa: ARG001 if LOGGER.isEnabledFor(logging.DEBUG) and not _task.cancelled() and _task.exception(): task_name = _task.get_name() if hasattr(_task, "get_name") else _task LOGGER.exception( - "Exception in task %s", + "Exception in task %s - target: %s", task_name, + str(target), exc_info=task.exception(), ) From 1f8d34ddafa734c77fda7f7fe8e52afe6a15a3c0 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 7 Jul 2023 01:12:07 +0200 Subject: [PATCH 7/8] some lint errors --- .../server/providers/fanarttv/__init__.py | 45 ++++++++++--------- .../server/providers/theaudiodb/__init__.py | 45 ++++++++++--------- .../server/providers/tunein/__init__.py | 17 +++---- 3 files changed, 55 insertions(+), 52 deletions(-) diff --git a/music_assistant/server/providers/fanarttv/__init__.py b/music_assistant/server/providers/fanarttv/__init__.py index 52c2fe8e7..09631a84d 100644 --- a/music_assistant/server/providers/fanarttv/__init__.py +++ b/music_assistant/server/providers/fanarttv/__init__.py @@ -119,25 +119,26 @@ async def _get_data(self, endpoint, **kwargs) -> dict | None: """Get data from api.""" url = f"http://webservice.fanart.tv/v3/{endpoint}" kwargs["api_key"] = app_var(4) - async with self.throttler: - async with self.mass.http_session.get(url, params=kwargs, ssl=False) as response: - try: - result = await response.json() - except ( - aiohttp.client_exceptions.ContentTypeError, - JSONDecodeError, - ): - self.logger.error("Failed to retrieve %s", endpoint) - text_result = await response.text() - self.logger.debug(text_result) - return None - except ( - aiohttp.client_exceptions.ClientConnectorError, - aiohttp.client_exceptions.ServerDisconnectedError, - ): - self.logger.warning("Failed to retrieve %s", endpoint) - return None - if "error" in result and "limit" in result["error"]: - self.logger.warning(result["error"]) - return None - return result + async with self.throttler, self.mass.http_session.get( + url, params=kwargs, ssl=False + ) as response: + try: + result = await response.json() + except ( + aiohttp.client_exceptions.ContentTypeError, + JSONDecodeError, + ): + self.logger.error("Failed to retrieve %s", endpoint) + text_result = await response.text() + self.logger.debug(text_result) + return None + except ( + aiohttp.client_exceptions.ClientConnectorError, + aiohttp.client_exceptions.ServerDisconnectedError, + ): + self.logger.warning("Failed to retrieve %s", endpoint) + return None + if "error" in result and "limit" in result["error"]: + self.logger.warning(result["error"]) + return None + return result diff --git a/music_assistant/server/providers/theaudiodb/__init__.py b/music_assistant/server/providers/theaudiodb/__init__.py index 3d08f2c5a..21a159c1d 100644 --- a/music_assistant/server/providers/theaudiodb/__init__.py +++ b/music_assistant/server/providers/theaudiodb/__init__.py @@ -311,25 +311,26 @@ def __parse_track(self, track_obj: dict[str, Any]) -> MediaItemMetadata: async def _get_data(self, endpoint, **kwargs) -> dict | None: """Get data from api.""" url = f"https://theaudiodb.com/api/v1/json/{app_var(3)}/{endpoint}" - async with self.throttler: - async with self.mass.http_session.get(url, params=kwargs, ssl=False) as response: - try: - result = await response.json() - except ( - aiohttp.client_exceptions.ContentTypeError, - JSONDecodeError, - ): - self.logger.error("Failed to retrieve %s", endpoint) - text_result = await response.text() - self.logger.debug(text_result) - return None - except ( - aiohttp.client_exceptions.ClientConnectorError, - aiohttp.client_exceptions.ServerDisconnectedError, - ): - self.logger.warning("Failed to retrieve %s", endpoint) - return None - if "error" in result and "limit" in result["error"]: - self.logger.warning(result["error"]) - return None - return result + async with self.throttler, self.mass.http_session.get( + url, params=kwargs, ssl=False + ) as response: + try: + result = await response.json() + except ( + aiohttp.client_exceptions.ContentTypeError, + JSONDecodeError, + ): + self.logger.error("Failed to retrieve %s", endpoint) + text_result = await response.text() + self.logger.debug(text_result) + return None + except ( + aiohttp.client_exceptions.ClientConnectorError, + aiohttp.client_exceptions.ServerDisconnectedError, + ): + self.logger.warning("Failed to retrieve %s", endpoint) + return None + if "error" in result and "limit" in result["error"]: + self.logger.warning(result["error"]) + return None + return result diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index 8319b6f4e..b0f3ecbc2 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -254,11 +254,12 @@ async def __get_data(self, endpoint: str, **kwargs): kwargs["username"] = self.config.get_value(CONF_USERNAME) kwargs["partnerId"] = "1" kwargs["render"] = "json" - async with self._throttler: - async with self.mass.http_session.get(url, params=kwargs, ssl=False) as response: - result = await response.json() - if not result or "error" in result: - self.logger.error(url) - self.logger.error(kwargs) - result = None - return result + async with self._throttler, self.mass.http_session.get( + url, params=kwargs, ssl=False + ) as response: + result = await response.json() + if not result or "error" in result: + self.logger.error(url) + self.logger.error(kwargs) + result = None + return result From 85280bab3f7343496b5d6f7853485328b258361b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 7 Jul 2023 01:20:25 +0200 Subject: [PATCH 8/8] more linting --- .../server/providers/musicbrainz/__init__.py | 27 ++++++------ .../server/providers/qobuz/__init__.py | 43 +++++++++---------- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/music_assistant/server/providers/musicbrainz/__init__.py b/music_assistant/server/providers/musicbrainz/__init__.py index d49f6cbfb..494394f5f 100644 --- a/music_assistant/server/providers/musicbrainz/__init__.py +++ b/music_assistant/server/providers/musicbrainz/__init__.py @@ -194,17 +194,16 @@ async def get_data(self, endpoint: str, **kwargs: dict[str, Any]) -> Any: url = f"http://musicbrainz.org/ws/2/{endpoint}" headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/music-assistant"} kwargs["fmt"] = "json" # type: ignore[assignment] - async with self.throttler: - async with self.mass.http_session.get( - url, headers=headers, params=kwargs, ssl=False - ) as response: - try: - result = await response.json() - except ( - aiohttp.client_exceptions.ContentTypeError, - JSONDecodeError, - ) as exc: - msg = await response.text() - self.logger.warning("%s - %s", str(exc), msg) - result = None - return result + async with self.throttler, self.mass.http_session.get( + url, headers=headers, params=kwargs, ssl=False + ) as response: + try: + result = await response.json() + except ( + aiohttp.client_exceptions.ContentTypeError, + JSONDecodeError, + ) as exc: + msg = await response.text() + self.logger.warning("%s - %s", str(exc), msg) + result = None + return result diff --git a/music_assistant/server/providers/qobuz/__init__.py b/music_assistant/server/providers/qobuz/__init__.py index d6a27bfa2..feff3ab39 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -662,29 +662,26 @@ async def _get_data(self, endpoint, sign_request=False, **kwargs): kwargs["request_sig"] = request_sig kwargs["app_id"] = app_var(0) kwargs["user_auth_token"] = await self._auth_token() - async with self._throttler: - async with self.mass.http_session.get( - url, headers=headers, params=kwargs, ssl=False - ) as response: - try: - result = await response.json() - # check for error in json - if error := result.get("error"): - raise ValueError(error) - if result.get("status") and "error" in result["status"]: - raise ValueError(result["status"]) - except ( - aiohttp.ContentTypeError, - JSONDecodeError, - AssertionError, - ValueError, - ) as err: - text = await response.text() - self.logger.exception( - "Error while processing %s: %s", endpoint, text, exc_info=err - ) - return None - return result + async with self._throttler, self.mass.http_session.get( + url, headers=headers, params=kwargs, ssl=False + ) as response: + try: + result = await response.json() + # check for error in json + if error := result.get("error"): + raise ValueError(error) + if result.get("status") and "error" in result["status"]: + raise ValueError(result["status"]) + except ( + aiohttp.ContentTypeError, + JSONDecodeError, + AssertionError, + ValueError, + ) as err: + text = await response.text() + self.logger.exception("Error while processing %s: %s", endpoint, text, exc_info=err) + return None + return result async def _post_data(self, endpoint, params=None, data=None): """Post data to api."""