From: Marcel van der Veldt Date: Thu, 6 Jul 2023 23:30:24 +0000 (+0200) Subject: Improve audio streaming (#740) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=eedffccb72b7e83dcce4b8dda687862af6f40512;p=music-assistant-server.git Improve audio streaming (#740) * refactor core controllers and stream engine part 1 * bugfixes and finishing touch * more fixes * fix group child ids * some small optimizations * park the sync stuff * some lint errors * more linting --- diff --git a/music_assistant/common/helpers/util.py b/music_assistant/common/helpers/util.py index de082794..55ad431a 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): @@ -178,13 +182,15 @@ async def get_ip_from_host(dns_name: str) -> str | None: 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/config_entries.py b/music_assistant/common/models/config_entries.py index 5ce07c4e..f53a8b43 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 @@ CONF_ENTRY_LOG_LEVEL = ConfigEntry( ) 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 @@ CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry( 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 55f1b193..b8e7a489 100755 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -25,23 +25,21 @@ JSON_KEYS = ("artists", "artist", "albums", "metadata", "provider_mappings") 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,32 @@ class ProviderMapping(DataClassDictMixin): 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): + """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)) @@ -523,11 +547,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/common/models/player.py b/music_assistant/common/models/player.py index 86e8956e..51c5515e 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/constants.py b/music_assistant/constants.py index f1e2a2a9..9a086f3e 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -32,6 +32,7 @@ CONF_IP_ADDRESS: Final[str] = "ip_address" 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_HIDE_GROUP_CHILDS: Final[str] = "hide_group_childs" 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 14afdff6..0da7001f 100644 --- a/music_assistant/server/controllers/cache.py +++ b/music_assistant/server/controllers/cache.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import functools -import json import logging import os import time @@ -11,6 +10,7 @@ from collections import OrderedDict 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, @@ -18,21 +18,24 @@ from music_assistant.constants import ( 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 + pass 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 + 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: @@ -66,7 +69,7 @@ class CacheController: 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: @@ -90,7 +93,7 @@ class CacheController: 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/config.py b/music_assistant/server/controllers/config.py index 34411034..e2d47c38 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -16,21 +16,30 @@ from cryptography.fernet import Fernet, InvalidToken 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 @@ class ConfigController: 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 @@ class ConfigController: 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 @@ class ConfigController: 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 fa435bcc..ae261a09 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -29,27 +29,30 @@ from music_assistant.common.models.media_items import ( ) 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 from music_assistant.server.models.metadata_provider import MetadataProvider 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: + 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 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 +315,7 @@ class MetaDataController: # 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 1f459af5..a6e71098 100755 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -29,6 +29,7 @@ from music_assistant.constants import ( ) 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 @@ -38,25 +39,29 @@ from .media.radio import RadioController 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 -class MusicController: +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 20377c41..18d46737 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -209,7 +209,10 @@ class PlayerQueuesController: 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 @@ -430,7 +433,7 @@ class PlayerQueuesController: 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. @@ -459,7 +462,7 @@ class PlayerQueuesController: 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") @@ -486,16 +489,43 @@ class PlayerQueuesController: 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) + # 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 = ( + queue_player.provider in ("airplay", "ugp", "slimproto") + and len(queue_player.group_childs) > 1 + ) 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, + 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=int(seek_position), + fade_in=fade_in, + ) + await player_prov.cmd_handle_stream_job(player_id=queue_id, stream_job=stream_job) + 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=seek_position, + seek_position=int(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 @@ -543,10 +573,7 @@ class PlayerQueuesController: if queue.active: queue.state = player.state # 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 @@ -622,25 +649,25 @@ class PlayerQueuesController: 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 | None = None + ) -> 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) + 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: - 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 +694,11 @@ class PlayerQueuesController: # 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 +878,9 @@ class PlayerQueuesController: 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 defd6a5e..cbccfd67 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -4,7 +4,7 @@ from __future__ import annotations 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 ( @@ -23,22 +23,23 @@ from music_assistant.common.models.errors import ( 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 -if TYPE_CHECKING: - from music_assistant.server import MusicAssistant - 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: - """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) @@ -253,8 +254,8 @@ class PlayerController: - 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: @@ -527,19 +528,19 @@ class PlayerController: 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 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: - # 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 @@ -559,13 +560,6 @@ class PlayerController: ) -> 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 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) 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 eaf86f47..4571d548 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -1,26 +1,41 @@ -"""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 contextlib import suppress +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,87 +45,139 @@ from music_assistant.server.helpers.audio import ( 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 +WORKAROUND_PLAYERS_CACHE_KEY = "streams.workaround_players" -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: QueueItem, + 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 = start_queue_item + 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.bytes_streamed: int = 0 + self.client_seconds_skipped: 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()) + 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.""" - 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() + return self._finished 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 + def stop(self) -> None: + """Stop running this job.""" + self._finished = True + if self._audio_task.done(): + return + self._audio_task.cancel() + for sub_queue in self.subscribed_players.values(): + with suppress(asyncio.QueueFull): + sub_queue.put_nowait(b"") + + 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._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.""" - self.start() - self.seen_players.add(player_id) try: - sub_queue = asyncio.Queue(1) + self.subscribed_players[player_id] = sub_queue = asyncio.Queue(1) - # some checks - assert player_id not in self.subscribers, "No duplicate subscriptions allowed" - assert not self.finished, "Already finished" - assert not self.running, "Already running" + 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 + self.client_seconds_skipped[player_id] = ( + self.bytes_streamed / self.pcm_format.pcm_sample_size + ) + else: + self.logger.debug("Subscribed client %s", player_id) - self.subscribers[player_id] = sub_queue - if len(self.subscribers) == len(self.expected_consumers): + 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 +187,386 @@ class StreamJob: break yield chunk finally: - empty_queue(sub_queue) - self.subscribers.pop(player_id) - # some delay here to detect misbehaving (reconnecting) players - await asyncio.sleep(2) + 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 - if len(self.subscribers) == 0 and self._audio_task and not self.finished: + 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_data(self, data: Any, timeout: float = 120) -> None: + async def _put_chunk(self, chunk: bytes) -> 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)) + async with asyncio.TaskGroup() as tg: + 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: """Feed audio chunks to StreamJob subscribers.""" chunk_num = 0 - async for chunk in self.audio_source: - chunk_num += 1 - if chunk_num == 1: + 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 + ): + if chunk_num == 0: # 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 + 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 flag and move on + # with all clients that did connect self._all_clients_connected.set() - LOGGER.warning( - "Starting stream job %s but not all clients connected within 10 seconds." + 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), ) - - await self._put_data(chunk) + await self._put_chunk(chunk) + chunk_num += 1 # 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 + 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 self.workaround_players: set[str] = set() + @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 "", ) + # 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 + ) + 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/{job_id}/{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() + await self.mass.cache.set(WORKAROUND_PLAYERS_CACHE_KEY, self.workaround_players) 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). + """Resolve the (regular, single player) stream URL for the given QueueItem. + + This is called just-in-time by the Queue controller to get the URL to the audio. """ - # 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 + output_codec = ContentType( + await self.mass.config.get_player_config_value(queue_id, CONF_OUTPUT_CODEC) + ) + fmt = output_codec.value + # handle raw pcm + if output_codec.is_pcm(): + player = self.mass.players.get(queue_id) + player_max_bit_depth = 32 if player.supports_24bit else 16 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 - ) + 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: - # 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, + output_sample_rate = min( + streamdetails.audio_format.sample_rate, player.max_sample_rate ) - - stream_job.expected_consumers.add(player_id) - self.stream_jobs[stream_job.stream_id] = stream_job - if auto_start_runner: - stream_job.start() - - # 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) - ) - 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 - 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_max_bit_depth = 32 if player.supports_24bit else 16 - output_bit_depth = min(stream_job.pcm_bit_depth, player_max_bit_depth) + output_bit_depth = min(streamdetails.audio_format.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: QueueItem, + 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: + 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=48000, + bit_depth=24, + channels=2, + ), + start_queue_item=start_queue_item, + 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 + 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=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 get_media_stream( + self.mass, + streamdetails=streamdetails, + pcm_format=pcm_format, + 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 +582,33 @@ class StreamsController: 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 +657,149 @@ class StreamsController: 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.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) + 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 + + # 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 + if child_player_id in streamjob.subscribed_players: + self.logger.warning( + "Player %s is making multiple requests " + "to the same stream, playback may be disturbed!", + 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", + 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_track = None last_fadeout_part = b"" - - LOGGER.info("Start Queue Flow stream for Queue %s", queue.display_name) + total_bytes_written = 0 + 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) 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,10 +808,8 @@ class StreamsController: ) # 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) - crossfade_duration = 10 + pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2) + 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) @@ -545,24 +821,14 @@ class StreamsController: async for chunk in get_media_stream( self.mass, streamdetails, + pcm_format=pcm_format, seek_position=seek_position, fade_in=fade_in, - sample_rate=sample_rate, - bit_depth=bit_depth, # 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) - 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 @@ -574,8 +840,8 @@ class StreamsController: 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 +871,7 @@ class StreamsController: 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 +888,63 @@ class StreamsController: # end of the track reached - store accurate duration queue_track.streamdetails.seconds_streamed = bytes_written / pcm_sample_size - LOGGER.debug( + total_bytes_written += bytes_written + 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", + str(input_format.channels), + "-channel_layout", + "mono" if input_format.channels == 1 else "stereo", "-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: - output_args = ["-f", "adts", "-c:a", output_format.value, "-b:a", "320k"] - elif output_format == ContentType.MP3: - output_args = ["-f", "mp3", "-c:a", output_format.value, "-b:a", "320k"] + elif output_format.content_type == ContentType.AAC: + output_args = ["-f", "adts", "-c:a", "aac", "-b:a", "320k"] + elif output_format.content_type == ContentType.MP3: + output_args = ["-f", "mp3", "-c:a", "mp3", "-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 +964,7 @@ class StreamsController: 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 +975,48 @@ class StreamsController: 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 + content_type = 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=content_type, + 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 da6cd28a..a461e37b 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, 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 b2d42c8c..96c8d250 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -15,7 +15,12 @@ import aiofiles 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, @@ -174,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", @@ -263,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: @@ -283,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") ): @@ -377,14 +381,14 @@ 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, - sample_rate: int | None = None, - bit_depth: int | 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 +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 - - sample_rate = sample_rate or streamdetails.sample_rate - bit_depth = bit_depth or streamdetails.bit_depth # chunk size = 2 seconds of pcm audio - pcm_sample_size = int(sample_rate * (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: @@ -408,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=sample_rate, - bit_depth=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, @@ -445,8 +445,8 @@ async def get_media_stream( stripped_audio = await strip_silence( mass, prev_chunk + chunk, - sample_rate=sample_rate, - bit_depth=bit_depth, + sample_rate=pcm_format.sample_rate, + bit_depth=pcm_format.bit_depth, ) yield stripped_audio bytes_sent += len(stripped_audio) @@ -470,8 +470,8 @@ async def get_media_stream( stripped_audio = await strip_silence( mass, prev_chunk, - sample_rate=sample_rate, - bit_depth=bit_depth, + sample_rate=pcm_format.sample_rate, + bit_depth=pcm_format.bit_depth, reverse=True, ) yield stripped_audio @@ -597,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) @@ -649,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", "-"] @@ -675,23 +675,25 @@ async def get_preview_stream( 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, + output_format.output_fmt.value, "-", ] async with AsyncProcess(args) as ffmpeg_proc: @@ -734,14 +736,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: @@ -763,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: @@ -790,21 +794,23 @@ async def _get_ffmpeg_args( input_args += ["-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 += [ + "-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 @@ -813,7 +819,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/helpers/auth.py b/music_assistant/server/helpers/auth.py index 197b136b..aaf1ff70 100644 --- a/music_assistant/server/helpers/auth.py +++ b/music_assistant/server/helpers/auth.py @@ -30,18 +30,18 @@ class AuthenticationHelper: @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 744e39f6..b537d4a4 100644 --- a/music_assistant/server/helpers/didl_lite.py +++ b/music_assistant/server/helpers/didl_lite.py @@ -15,48 +15,44 @@ if TYPE_CHECKING: 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.""" - 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: + """Create DIDL metadata string from url and (optional) QueueItem.""" + ext = url.split(".")[-1].split("?")[0] + if queue_item is None: return ( '' - f'' - f"Music Assistant" - f"{MASS_LOGO_ONLINE}" - f"{queue_item.queue_item_id}" + "Music Assistant" + f"{escape_string(MASS_LOGO_ONLINE)}" "object.item.audioItem.audioBroadcast" f"audio/{ext}" - f'{url}' + f'{escape_string(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 ( '' 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" @@ -71,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/helpers/process.py b/music_assistant/server/helpers/process.py index 6d8476fd..9e576313 100644 --- a/music_assistant/server/helpers/process.py +++ b/music_assistant/server/helpers/process.py @@ -13,7 +13,6 @@ from contextlib import suppress LOGGER = logging.getLogger(__name__) DEFAULT_CHUNKSIZE = 128000 -DEFAULT_TIMEOUT = 60 # pylint: disable=invalid-name @@ -91,23 +90,21 @@ class AsyncProcess: 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/helpers/webserver.py b/music_assistant/server/helpers/webserver.py new file mode 100644 index 00000000..b41663f1 --- /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 00000000..056f06ae --- /dev/null +++ b/music_assistant/server/models/core_controller.py @@ -0,0 +1,49 @@ +"""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, # 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.""" + + 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 6127da97..258a26f6 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -11,6 +11,7 @@ from .provider import Provider 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 @@ -53,27 +54,38 @@ class PlayerProvider(Provider): """ @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_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, 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/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 53ff7e04..6e92d7ac 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: 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 @@ -171,25 +172,36 @@ class AirplayProvider(PlayerProvider): 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_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/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index d6f04db9..3cd7f69b 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,36 +184,35 @@ class ChromecastProvider(PlayerProvider): 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, - 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={ "customData": { - "queue_item_id": queue_item.queue_item_id, + "queue_item_id": "flow", } }, ) @@ -232,7 +230,7 @@ class ChromecastProvider(PlayerProvider): # 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) @@ -403,13 +401,13 @@ class ChromecastProvider(PlayerProvider): # 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, @@ -421,7 +419,6 @@ class ChromecastProvider(PlayerProvider): 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 @@ -438,20 +435,14 @@ class ChromecastProvider(PlayerProvider): 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_item - or castplayer.next_item == castplayer.player.current_item_id + 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.""" @@ -485,37 +476,36 @@ class ChromecastProvider(PlayerProvider): ### 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.""" - 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( - castplayer.player_id, current_queue_item_id + next_url, next_item, _ = await self.mass.players.queues.preload_next_url( + castplayer.player_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].split("?")[0]}', + 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 bd1da036..c8536299 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.errors import LoginFailed from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, BrowseFolder, ItemMapping, MediaItemImage, @@ -378,7 +379,9 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 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 13950dbe..9f44d4d8 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -119,7 +119,8 @@ 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 + next_item: QueueItem | None = None supports_next_uri = True end_of_track_reached = False @@ -140,7 +141,6 @@ class DLNAPlayer: 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 @@ -224,7 +224,7 @@ class DLNAPlayerProvider(PlayerProvider): 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 @@ class DLNAPlayerProvider(PlayerProvider): """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 @@ class DLNAPlayerProvider(PlayerProvider): 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) @@ -531,58 +531,46 @@ class DLNAPlayerProvider(PlayerProvider): 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.""" - 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( - dlna_player.udn, current_queue_item_id - ) + ( + next_url, + next_item, + _, + ) = await self.mass.players.queues.preload_next_url(dlna_player.udn) 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 + 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: 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, ) 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 @@ -595,19 +583,17 @@ class DLNAPlayerProvider(PlayerProvider): # 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 + 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 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.next_item) 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 bc3b9114..0cbe74b9 100644 --- a/music_assistant/server/providers/dlna/helpers.py +++ b/music_assistant/server/providers/dlna/helpers.py @@ -23,7 +23,7 @@ class DLNANotifyServer(UpnpNotifyServer): """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 @@ class DLNANotifyServer(UpnpNotifyServer): @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/fanarttv/__init__.py b/music_assistant/server/providers/fanarttv/__init__.py index 52c2fe8e..09631a84 100644 --- a/music_assistant/server/providers/fanarttv/__init__.py +++ b/music_assistant/server/providers/fanarttv/__init__.py @@ -119,25 +119,26 @@ class FanartTvMetadataProvider(MetadataProvider): """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/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index e4235149..9c0f81bf 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.errors import ( from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, BrowseFolder, ContentType, ImageType, @@ -560,14 +561,12 @@ class FileSystemProviderBase(MusicProvider): return StreamDetails( provider=self.instance_id, item_id=item_id, - content_type=prov_mapping.content_type, + audio_format=prov_mapping.audio_format, 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, + can_seek=prov_mapping.audio_format.content_type in SEEKABLE_FILES, ) async def get_audio_stream( @@ -725,10 +724,12 @@ class FileSystemProviderBase(MusicProvider): 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/musicbrainz/__init__.py b/music_assistant/server/providers/musicbrainz/__init__.py index d49f6cbf..494394f5 100644 --- a/music_assistant/server/providers/musicbrainz/__init__.py +++ b/music_assistant/server/providers/musicbrainz/__init__.py @@ -194,17 +194,16 @@ class MusicbrainzProvider(MetadataProvider): 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/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index 33fed19a..82bcf72b 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.errors import InvalidDataError, LoginFailed, from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, ItemMapping, MediaItem, MediaItemChapter, @@ -409,7 +410,9 @@ class PlexProvider(MusicProvider): 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(), ) ) @@ -585,9 +588,11 @@ class PlexProvider(MusicProvider): 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, ) @@ -597,18 +602,18 @@ class PlexProvider(MusicProvider): 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 3d843b5c..feff3ab3 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -19,6 +19,7 @@ from music_assistant.common.models.media_items import ( Album, AlbumType, Artist, + AudioFormat, ContentType, ImageType, MediaItemImage, @@ -372,10 +373,12 @@ class QobuzProvider(MusicProvider): 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"], @@ -457,9 +460,11 @@ class QobuzProvider(MusicProvider): 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"]}'), ) ) @@ -556,9 +561,11 @@ class QobuzProvider(MusicProvider): 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"]}'), ) ) @@ -655,29 +662,26 @@ class QobuzProvider(MusicProvider): 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.""" diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index 36efa06f..de325151 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -10,6 +10,7 @@ from radios import FilterBy, Order, RadioBrowser, RadioBrowserError 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 @@ class RadioBrowserProvider(MusicProvider): 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 a7cddde1..0e7a09cf 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -11,7 +11,7 @@ from dataclasses import dataclass 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 @@ -42,15 +42,15 @@ 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.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType CACHE_KEY_PREV_STATE = "slimproto_prev_state" # 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_MILLISECONDS = 500 +MIN_REQ_PLAYPOINTS = 4 # we need at least 4 measurements +ENABLE_EXPERIMENTAL_SYNC_JOIN = False # WIP # TODO: Implement display support @@ -68,7 +68,7 @@ class SyncPlayPoint: """Simple structure to describe a Sync Playpoint.""" timestamp: float - item_id: str + sync_job_id: str diff: int @@ -171,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 @@ -179,6 +180,7 @@ class SlimprotoProvider(PlayerProvider): 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: @@ -202,10 +204,10 @@ class SlimprotoProvider(PlayerProvider): 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, ) @@ -234,7 +236,7 @@ class SlimprotoProvider(PlayerProvider): 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)) @@ -252,6 +254,11 @@ class SlimprotoProvider(PlayerProvider): 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 @@ -320,7 +327,7 @@ class SlimprotoProvider(PlayerProvider): 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.", @@ -355,71 +362,76 @@ class SlimprotoProvider(PlayerProvider): 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 sync_clients = [x for x in self._get_sync_clients(player_id)] async with asyncio.TaskGroup() as tg: for client in sync_clients: 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 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: + url = await stream_job.resolve_stream_url(client.player_id) + tg.create_task( + self._handle_play_url( + client, + url=url, + queue_item=None, + send_flush=True, + auto_play=len(sync_clients) == 1, + ) + ) + + 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 @@ -429,8 +441,10 @@ class SlimprotoProvider(PlayerProvider): 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}, + 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, send_flush=send_flush, transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE, transition_duration=transition_duration, @@ -457,9 +471,6 @@ class SlimprotoProvider(PlayerProvider): """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) @@ -482,29 +493,47 @@ class SlimprotoProvider(PlayerProvider): 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 - parent_player.group_childs.append(child_player.player_id) + 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(ValueError): + 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,9 +592,6 @@ class SlimprotoProvider(PlayerProvider): # 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] @@ -587,15 +613,30 @@ class SlimprotoProvider(PlayerProvider): # 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 @@ -609,28 +650,29 @@ class SlimprotoProvider(PlayerProvider): 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( @@ -638,13 +680,8 @@ class SlimprotoProvider(PlayerProvider): - 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 @@ -656,15 +693,17 @@ class SlimprotoProvider(PlayerProvider): 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: @@ -677,15 +716,18 @@ class SlimprotoProvider(PlayerProvider): 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( - client.player_id, client.current_metadata["item_id"] + next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( + client.player_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, @@ -719,7 +761,8 @@ class SlimprotoProvider(PlayerProvider): # 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: @@ -777,15 +820,31 @@ class SlimprotoProvider(PlayerProvider): 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: + # 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/providers/slimproto/cli.py b/music_assistant/server/providers/slimproto/cli.py index d758dd95..2e80f1a5 100644 --- a/music_assistant/server/providers/slimproto/cli.py +++ b/music_assistant/server/providers/slimproto/cli.py @@ -145,8 +145,8 @@ class LmsCli: """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 @@ class LmsCli: 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 @@ -644,7 +644,7 @@ class LmsCli: "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), @@ -743,8 +743,8 @@ class LmsCli: 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 ccd3e57c..883a21ae 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations 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,13 +14,7 @@ from soco.events_base import SubscriptionBase 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 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,9 +71,8 @@ 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 subscriptions: list[SubscriptionBase] = field(default_factory=list) @@ -118,19 +110,6 @@ class SonosPlayer: 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() @@ -159,7 +138,6 @@ class SonosPlayer: # 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, @@ -176,14 +154,21 @@ class SonosPlayer: 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 - } - 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.""" @@ -266,15 +251,22 @@ class SonosPlayerProvider(PlayerProvider): 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 +275,20 @@ class SonosPlayerProvider(PlayerProvider): ) 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, - ) - 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, 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) - 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, 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: @@ -527,24 +505,18 @@ class SonosPlayerProvider(PlayerProvider): 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_item, crossfade = await self.mass.players.queues.player_ready_for_next_track( - sonos_player.player_id, current_queue_item_id + next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( + sonos_player.player_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 +528,16 @@ class SonosPlayerProvider(PlayerProvider): 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,17 +549,14 @@ class SonosPlayerProvider(PlayerProvider): ], 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, ) 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() @@ -622,13 +582,9 @@ class SonosPlayerProvider(PlayerProvider): # 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 + 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/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index d9acbd4c..ddc5f876 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.enums import ConfigEntryType, ProviderFeature 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 @@ class SoundcloudMusicProvider(MusicProvider): 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, ) @@ -371,7 +375,9 @@ class SoundcloudMusicProvider(MusicProvider): 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 908c27e4..8d350399 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -23,6 +23,7 @@ from music_assistant.common.models.media_items import ( Album, AlbumType, Artist, + AudioFormat, ContentType, ImageType, MediaItemImage, @@ -355,7 +356,9 @@ class SpotifyProvider(MusicProvider): 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, ) @@ -448,8 +451,7 @@ class SpotifyProvider(MusicProvider): 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"], ) ) @@ -497,8 +499,10 @@ class SpotifyProvider(MusicProvider): 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/theaudiodb/__init__.py b/music_assistant/server/providers/theaudiodb/__init__.py index 3d08f2c5..21a159c1 100644 --- a/music_assistant/server/providers/theaudiodb/__init__.py +++ b/music_assistant/server/providers/theaudiodb/__init__.py @@ -311,25 +311,26 @@ class AudioDbMetadataProvider(MetadataProvider): 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/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index 92791a09..66077c6f 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.errors import LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( Album, Artist, + AudioFormat, ContentType, ItemMapping, MediaItemImage, @@ -364,9 +365,11 @@ class TidalProvider(MusicProvider): 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, ) @@ -514,7 +517,9 @@ class TidalProvider(MusicProvider): 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, ) @@ -566,9 +571,11 @@ class TidalProvider(MusicProvider): 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 8f22a453..b0f3ecbc 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.config_entries import ConfigEntry, ConfigValu 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, @@ -172,8 +173,10 @@ class TuneInProvider(MusicProvider): 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, ) ) @@ -201,6 +204,9 @@ class TuneInProvider(MusicProvider): 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 +227,9 @@ class TuneInProvider(MusicProvider): 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, @@ -246,11 +254,12 @@ class TuneInProvider(MusicProvider): 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 diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index 46792a9f..5b9119a3 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -33,6 +33,7 @@ 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.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -181,24 +182,21 @@ class UniversalGroupProvider(PlayerProvider): ): 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,6 +204,7 @@ class UniversalGroupProvider(PlayerProvider): 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( @@ -213,13 +212,26 @@ class UniversalGroupProvider(PlayerProvider): ): player_prov = self.mass.players.get_player_provider(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_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: @@ -290,7 +302,6 @@ class UniversalGroupProvider(PlayerProvider): 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 @@ -298,7 +309,6 @@ class UniversalGroupProvider(PlayerProvider): break else: group_player.state = PlayerState.IDLE - group_player.current_item_id = None group_player.current_url = None def on_child_state( diff --git a/music_assistant/server/providers/url/__init__.py b/music_assistant/server/providers/url/__init__.py index 1788a2cb..77cbd38e 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.config_entries import ConfigEntry, ConfigValu from music_assistant.common.models.enums import ContentType, ImageType, MediaType from music_assistant.common.models.media_items import ( Artist, + AudioFormat, MediaItemImage, MediaItemType, ProviderMapping, @@ -125,10 +126,12 @@ class URLProvider(MusicProvider): 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: @@ -181,10 +184,12 @@ class URLProvider(MusicProvider): 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 f55b7d71..00000000 --- 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 9ef085c2..00000000 --- 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 bf40c3f1..274993ab 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -24,6 +24,7 @@ from music_assistant.common.models.media_items import ( Album, AlbumType, Artist, + AudioFormat, ContentType, ImageType, ItemMapping, @@ -513,7 +514,9 @@ class YoutubeMusicProvider(MusicProvider): 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 ( @@ -524,9 +527,9 @@ class YoutubeMusicProvider(MusicProvider): 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 @@ -736,7 +739,9 @@ class YoutubeMusicProvider(MusicProvider): 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 8972da82..61f94080 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -12,7 +12,7 @@ from uuid import uuid4 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,22 +101,31 @@ class MusicAssistant: ), ) # 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() 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.""" @@ -276,6 +281,8 @@ class MusicAssistant: 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 @@ -296,8 +303,9 @@ class MusicAssistant: 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(), ) @@ -321,7 +329,8 @@ class MusicAssistant: 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 @@ -490,7 +499,7 @@ class MusicAssistant: 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 @@ -498,8 +507,8 @@ class MusicAssistant: info = ServiceInfo( zeroconf_type, name=f"{server_id}.{zeroconf_type}", - addresses=[get_ip_pton(self.base_ip)], - port=self.webserver.port, + addresses=[await get_ip_pton(self.streams.publish_ip)], + port=self.streams.publish_port, properties=self.get_server_info().to_dict(), server="mass.local.", ) @@ -507,9 +516,9 @@ class MusicAssistant: 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(