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):
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):
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,
)
DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
+DEFAULT_CORE_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
# some reusable player config entries
advanced=True,
)
+
CONF_ENTRY_GROUPED_POWER_ON = ConfigEntry(
key=CONF_GROUPED_POWER_ON,
type=ConfigEntryType.BOOLEAN,
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:
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))
# 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
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
# - 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
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"
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"
import asyncio
import functools
-import json
import logging
import os
import time
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,
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:
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:
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},
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__)
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
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)
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 = {}
)
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."""
# 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(
)
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
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()
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
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.
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")
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
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
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:
# 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
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
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 (
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)
- 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:
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
) -> 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):
-"""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,
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:
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)
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:
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,
)
# 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)
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
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
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
# 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
"-",
]
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":
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,
+ )
-"""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()
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,
"-i",
input_file,
"-f",
- streamdetails.content_type,
+ streamdetails.audio_format.content_type,
"-af",
"ebur128=framelog=verbose",
"-f",
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:
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")
):
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.
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:
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,
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)
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
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)
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", "-"]
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",
"-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:
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:
"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:
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
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
):
@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."""
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 (
'<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
- f'<item id="{queue_item.queue_item_id}" parentID="0" restricted="1">'
- f"<dc:title>Music Assistant</dc:title>"
- f"<upnp:albumArtURI>{MASS_LOGO_ONLINE}</upnp:albumArtURI>"
- f"<dc:queueItemId>{queue_item.queue_item_id}</dc:queueItemId>"
+ "<dc:title>Music Assistant</dc:title>"
+ f"<upnp:albumArtURI>{escape_string(MASS_LOGO_ONLINE)}</upnp:albumArtURI>"
"<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
- f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{url}</res>'
+ f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
"</item>"
"</DIDL-Lite>"
)
-
+ 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 (
'<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
f'<item id="{queue_item.queue_item_id}" parentID="0" restricted="1">'
- f"<dc:title>{_escape_str(queue_item.name)}</dc:title>"
- f"<upnp:albumArtURI>{_escape_str(image_url)}</upnp:albumArtURI>"
+ f"<dc:title>{escape_string(queue_item.name)}</dc:title>"
+ f"<upnp:albumArtURI>{escape_string(image_url)}</upnp:albumArtURI>"
f"<dc:queueItemId>{queue_item.queue_item_id}</dc:queueItemId>"
"<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
- f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{url}</res>'
+ f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
"</item>"
"</DIDL-Lite>"
)
- 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"
f"<upnp:duration>{queue_item.duration}</upnp:duration>"
"<upnp:playlistTitle>Music Assistant</upnp:playlistTitle>"
f"<dc:queueItemId>{queue_item.queue_item_id}</dc:queueItemId>"
- f"<upnp:albumArtURI>{_escape_str(image_url)}</upnp:albumArtURI>"
+ f"<upnp:albumArtURI>{escape_string(image_url)}</upnp:albumArtURI>"
f"<upnp:class>{item_class}</upnp:class>"
f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
- f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{url}</res>'
+ f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
"</item>"
"</DIDL-Lite>"
)
-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
LOGGER = logging.getLogger(__name__)
DEFAULT_CHUNKSIZE = 128000
-DEFAULT_TIMEOUT = 60
# pylint: disable=invalid-name
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."""
--- /dev/null
+"""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)
--- /dev/null
+"""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()
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
"""
@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.
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
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
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
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",
}
},
)
# 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)
# 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,
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
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."""
### 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",
from music_assistant.common.models.media_items import (
Album,
Artist,
+ AudioFormat,
BrowseFolder,
ItemMapping,
MediaItemImage,
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"],
# 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
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
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))
"""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()
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)
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
# 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
"""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."""
@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"
"""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
from music_assistant.common.models.media_items import (
Album,
Artist,
+ AudioFormat,
BrowseFolder,
ContentType,
ImageType,
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(
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
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
from music_assistant.common.models.media_items import (
Album,
Artist,
+ AudioFormat,
ItemMapping,
MediaItem,
MediaItemChapter,
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(),
)
)
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,
)
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
Album,
AlbumType,
Artist,
+ AudioFormat,
ContentType,
ImageType,
MediaItemImage,
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"],
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"]}'),
)
)
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"]}'),
)
)
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."""
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,
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,
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
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
"""Simple structure to describe a Sync Playpoint."""
timestamp: float
- item_id: str
+ sync_job_id: str
diff: int
_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
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:
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,
)
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))
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
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.",
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
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,
"""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)
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)
# 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]
# 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
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(
- 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
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:
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,
# 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:
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)
"""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),
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
"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),
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 ?
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
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
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)
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()
# 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,
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."""
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(
)
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:
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:
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,
[
],
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()
# 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:
from music_assistant.common.models.errors import InvalidDataError, LoginFailed
from music_assistant.common.models.media_items import (
Artist,
+ AudioFormat,
ContentType,
ImageType,
MediaItemImage,
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,
)
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"],
)
)
Album,
AlbumType,
Artist,
+ AudioFormat,
ContentType,
ImageType,
MediaItemImage,
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,
)
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"],
)
)
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"],
)
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
from music_assistant.common.models.media_items import (
Album,
Artist,
+ AudioFormat,
ContentType,
ItemMapping,
MediaItemImage,
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,
)
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,
)
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,
)
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,
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,
)
)
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,
)
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,
kwargs["username"] = self.config.get_value(CONF_USERNAME)
kwargs["partnerId"] = "1"
kwargs["render"] = "json"
- async with self._throttler:
- async with self.mass.http_session.get(url, params=kwargs, ssl=False) as response:
- result = await response.json()
- if not result or "error" in result:
- self.logger.error(url)
- self.logger.error(kwargs)
- result = None
- return result
+ async with self._throttler, self.mass.http_session.get(
+ url, params=kwargs, ssl=False
+ ) as response:
+ result = await response.json()
+ if not result or "error" in result:
+ self.logger.error(url)
+ self.logger.error(kwargs)
+ result = None
+ return result
from 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
):
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)
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_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:
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
break
else:
group_player.state = PlayerState.IDLE
- group_player.current_item_id = None
group_player.current_url = None
def on_child_state(
from music_assistant.common.models.enums import ContentType, ImageType, MediaType
from music_assistant.common.models.media_items import (
Artist,
+ AudioFormat,
MediaItemImage,
MediaItemType,
ProviderMapping,
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:
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,
)
+++ /dev/null
-"""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()
+++ /dev/null
-{
- "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"
-}
Album,
AlbumType,
Artist,
+ AudioFormat,
ContentType,
ImageType,
ItemMapping,
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 (
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
provider_domain=self.domain,
provider_instance=self.instance_id,
available=available,
- content_type=ContentType.M4A,
+ audio_format=AudioFormat(
+ content_type=ContentType.M4A,
+ ),
)
)
return track
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
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."""
),
)
# 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."""
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
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(),
)
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
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
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.",
)
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(