Improve audio streaming (#740)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 6 Jul 2023 23:30:24 +0000 (01:30 +0200)
committerGitHub <noreply@github.com>
Thu, 6 Jul 2023 23:30:24 +0000 (01:30 +0200)
* refactor core controllers and stream engine part 1

* bugfixes and finishing touch

* more fixes

* fix group child ids

* some small optimizations

* park the sync stuff

* some lint errors

* more linting

45 files changed:
music_assistant/common/helpers/util.py
music_assistant/common/models/config_entries.py
music_assistant/common/models/media_items.py
music_assistant/common/models/player.py
music_assistant/constants.py
music_assistant/server/controllers/cache.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/music.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/streams.py
music_assistant/server/controllers/webserver.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/auth.py
music_assistant/server/helpers/didl_lite.py
music_assistant/server/helpers/process.py
music_assistant/server/helpers/webserver.py [new file with mode: 0644]
music_assistant/server/models/core_controller.py [new file with mode: 0644]
music_assistant/server/models/player_provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/deezer/__init__.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/dlna/helpers.py
music_assistant/server/providers/fanarttv/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/musicbrainz/__init__.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/slimproto/cli.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/theaudiodb/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/ugp/__init__.py
music_assistant/server/providers/url/__init__.py
music_assistant/server/providers/websocket_api/__init__.py [deleted file]
music_assistant/server/providers/websocket_api/manifest.json [deleted file]
music_assistant/server/providers/ytmusic/__init__.py
music_assistant/server/server.py

index de082794b2c193fab485aea455ec03887cfd7059..55ad431add0cb4ec1c635def2fe4e4b51e197a14 100755 (executable)
@@ -129,33 +129,37 @@ def get_version_substitute(version_str: str):
     return version_str.strip()
 
 
-def get_ip():
+async def get_ip():
     """Get primary IP-address for this host."""
-    # pylint: disable=broad-except,no-member
-    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-    try:
-        # doesn't even have to be reachable
-        sock.connect(("10.255.255.255", 1))
-        _ip = sock.getsockname()[0]
-    except Exception:
-        _ip = "127.0.0.1"
-    finally:
-        sock.close()
-    return _ip
-
-
-def is_port_in_use(port: int) -> bool:
-    """Check if port is in use."""
-    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock:
+
+    def _get_ip():
+        """Get primary IP-address for this host."""
+        # pylint: disable=broad-except,no-member
+        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
         try:
-            _sock.bind(("127.0.0.1", port))
-        except OSError:
-            return True
+            # doesn't even have to be reachable
+            sock.connect(("10.255.255.255", 1))
+            _ip = sock.getsockname()[0]
+        except Exception:
+            _ip = "127.0.0.1"
+        finally:
+            sock.close()
+        return _ip
+
+    return await asyncio.to_thread(_get_ip)
 
 
 async def select_free_port(range_start: int, range_end: int) -> int:
     """Automatically find available port within range."""
 
+    def is_port_in_use(port: int) -> bool:
+        """Check if port is in use."""
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock:
+            try:
+                _sock.bind(("127.0.0.1", port))
+            except OSError:
+                return True
+
     def _select_free_port():
         for port in range(range_start, range_end):
             if not is_port_in_use(port):
@@ -178,13 +182,15 @@ async def get_ip_from_host(dns_name: str) -> str | None:
     return await asyncio.to_thread(_resolve)
 
 
-def get_ip_pton(ip_string: str = get_ip()):
+async def get_ip_pton(ip_string: str | None = None):
     """Return socket pton for local ip."""
+    if ip_string is None:
+        ip_string = await get_ip()
     # pylint:disable=no-member
     try:
-        return socket.inet_pton(socket.AF_INET, ip_string)
+        return await asyncio.to_thread(socket.inet_pton, socket.AF_INET, ip_string)
     except OSError:
-        return socket.inet_pton(socket.AF_INET6, ip_string)
+        return await asyncio.to_thread(socket.inet_pton, socket.AF_INET6, ip_string)
 
 
 def get_folder_size(folderpath):
index 5ce07c4e245d65eb31d6b04c162fba877dbb35ab..f53a8b436746e68872c445e7bb1092a81b2b4c8b 100644 (file)
@@ -265,6 +265,15 @@ class PlayerConfig(Config):
     default_name: str | None = None
 
 
+@dataclass
+class CoreConfig(Config):
+    """CoreController Configuration."""
+
+    module: str  # name of the core module
+    friendly_name: str  # friendly name of the core module
+    last_error: str | None = None
+
+
 CONF_ENTRY_LOG_LEVEL = ConfigEntry(
     key=CONF_LOG_LEVEL,
     type=ConfigEntryType.STRING,
@@ -282,6 +291,7 @@ CONF_ENTRY_LOG_LEVEL = ConfigEntry(
 )
 
 DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
+DEFAULT_CORE_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
 
 # some reusable player config entries
 
@@ -406,6 +416,7 @@ CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry(
     advanced=True,
 )
 
+
 CONF_ENTRY_GROUPED_POWER_ON = ConfigEntry(
     key=CONF_GROUPED_POWER_ON,
     type=ConfigEntryType.BOOLEAN,
index 55f1b1933becb45e47fdaf90201ba688921d66c5..b8e7a489d55ef8c219d5b7f3f073bd156214d620 100755 (executable)
@@ -25,23 +25,21 @@ JSON_KEYS = ("artists", "artist", "albums", "metadata", "provider_mappings")
 JOINED_KEYS = ("barcode", "isrc")
 
 
-@dataclass(frozen=True)
-class ProviderMapping(DataClassDictMixin):
-    """Model for a MediaItem's provider mapping details."""
+@dataclass
+class AudioFormat(DataClassDictMixin):
+    """Model for AudioFormat details."""
 
-    item_id: str
-    provider_domain: str
-    provider_instance: str
-    available: bool = True
-    # quality details (streamable content only)
     content_type: ContentType = ContentType.UNKNOWN
     sample_rate: int = 44100
     bit_depth: int = 16
-    bit_rate: int = 320
-    # optional details to store provider specific details
-    details: str | None = None
-    # url = link to provider details page if exists
-    url: str | None = None
+    channels: int = 2
+    output_format_str: str = ""
+    bit_rate: int = 320  # optional
+
+    def __post_init__(self):
+        """Execute actions after init."""
+        if not self.output_format_str:
+            self.output_format_str = self.content_type.value
 
     @property
     def quality(self) -> int:
@@ -55,6 +53,32 @@ class ProviderMapping(DataClassDictMixin):
             score += 1
         return int(score)
 
+    @property
+    def pcm_sample_size(self) -> int:
+        """Return the PCM sample size."""
+        return int(self.sample_rate * (self.bit_depth / 8) * self.channels)
+
+
+@dataclass(frozen=True)
+class ProviderMapping(DataClassDictMixin):
+    """Model for a MediaItem's provider mapping details."""
+
+    item_id: str
+    provider_domain: str
+    provider_instance: str
+    available: bool = True
+    # quality/audio details (streamable content only)
+    audio_format: AudioFormat = field(default_factory=AudioFormat)
+    # optional details to store provider specific details
+    details: str | None = None
+    # url = link to provider details page if exists
+    url: str | None = None
+
+    @property
+    def quality(self) -> int:
+        """Return quality score."""
+        return self.audio_format.quality
+
     def __hash__(self) -> int:
         """Return custom hash."""
         return hash((self.provider_instance, self.item_id))
@@ -523,11 +547,9 @@ class StreamDetails(DataClassDictMixin):
     # mandatory fields
     provider: str
     item_id: str
-    content_type: ContentType
+    audio_format: AudioFormat
     media_type: MediaType = MediaType.TRACK
-    sample_rate: int = 44100
-    bit_depth: int = 16
-    channels: int = 2
+
     # stream_title: radio streams can optionally set this field
     stream_title: str | None = None
     # duration of the item to stream, copied from media_item if omitted
index 86e8956e6e229614c273f1d25cff65c5dc76c8bf..51c5515ee24781aa500a514e8c3945e3d9785223 100644 (file)
@@ -35,7 +35,6 @@ class Player(DataClassDictMixin):
     elapsed_time: float = 0
     elapsed_time_last_updated: float = time.time()
     current_url: str | None = None
-    current_item_id: str | None = None
     state: PlayerState = PlayerState.IDLE
 
     volume_level: int = 100
@@ -45,8 +44,9 @@ class Player(DataClassDictMixin):
     # - If this player is a dedicated group player,
     #   returns all child id's of the players in the group.
     # - If this is a syncgroup of players from the same platform (e.g. sonos),
-    #   this will return the id's of players synced to this player.
-    group_childs: list[str] = field(default_factory=list)
+    #   this will return the id's of players synced to this player,
+    #   and this may include the player's own id.
+    group_childs: set[str] = field(default_factory=set)
 
     # active_source: return player_id of the active queue for this player
     # if the player is grouped and a group is active, this will be set to the group's player_id
index f1e2a2a946e39f55875f6f654f67edd79da66183..9a086f3e6ed7426fe6869feff48d504c533b07fd 100755 (executable)
@@ -32,6 +32,7 @@ CONF_IP_ADDRESS: Final[str] = "ip_address"
 CONF_PORT: Final[str] = "port"
 CONF_PROVIDERS: Final[str] = "providers"
 CONF_PLAYERS: Final[str] = "players"
+CONF_CORE: Final[str] = "core"
 CONF_PATH: Final[str] = "path"
 CONF_USERNAME: Final[str] = "username"
 CONF_PASSWORD: Final[str] = "password"
@@ -48,6 +49,9 @@ CONF_HIDE_GROUP_CHILDS: Final[str] = "hide_group_childs"
 CONF_OUTPUT_CODEC: Final[str] = "output_codec"
 CONF_GROUPED_POWER_ON: Final[str] = "grouped_power_on"
 CONF_CROSSFADE_DURATION: Final[str] = "crossfade_duration"
+CONF_BIND_IP: Final[str] = "bind_ip"
+CONF_BIND_PORT: Final[str] = "bind_port"
+CONF_PUBLISH_IP: Final[str] = "publish_ip"
 
 # config default values
 DEFAULT_HOST: Final[str] = "0.0.0.0"
index 14afdff6bf18a19ecb11447c3f1b952f995f636f..0da7001f7ebb9c5906afbf7d925d2af3a00e5138 100644 (file)
@@ -3,7 +3,6 @@ from __future__ import annotations
 
 import asyncio
 import functools
-import json
 import logging
 import os
 import time
@@ -11,6 +10,7 @@ from collections import OrderedDict
 from collections.abc import Iterator, MutableMapping
 from typing import TYPE_CHECKING, Any
 
+from music_assistant.common.helpers.json import json_dumps, json_loads
 from music_assistant.constants import (
     DB_TABLE_CACHE,
     DB_TABLE_SETTINGS,
@@ -18,21 +18,24 @@ from music_assistant.constants import (
     SCHEMA_VERSION,
 )
 from music_assistant.server.helpers.database import DatabaseConnection
+from music_assistant.server.models.core_controller import CoreController
 
 if TYPE_CHECKING:
-    from music_assistant.server import MusicAssistant
+    pass
 
 LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache")
 
 
-class CacheController:
+class CacheController(CoreController):
     """Basic cache controller using both memory and database."""
 
-    database: DatabaseConnection | None = None
+    name: str = "cache"
+    friendly_name: str = "Cache controller"
 
-    def __init__(self, mass: MusicAssistant) -> None:
-        """Initialize our caching class."""
-        self.mass = mass
+    def __init__(self, *args, **kwargs) -> None:
+        """Initialize core controller."""
+        super().__init__(*args, **kwargs)
+        self.database: DatabaseConnection | None = None
         self._mem_cache = MemoryCache(500)
 
     async def setup(self) -> None:
@@ -66,7 +69,7 @@ class CacheController:
             not checksum or db_row["checksum"] == checksum and db_row["expires"] >= cur_time
         ):
             try:
-                data = await asyncio.to_thread(json.loads, db_row["data"])
+                data = await asyncio.to_thread(json_loads, db_row["data"])
             except Exception as exc:  # pylint: disable=broad-except
                 LOGGER.exception("Error parsing cache data for %s", cache_key, exc_info=exc)
             else:
@@ -90,7 +93,7 @@ class CacheController:
         if (expires - time.time()) < 3600 * 4:
             # do not cache items in db with short expiration
             return
-        data = await asyncio.to_thread(json.dumps, data)
+        data = await asyncio.to_thread(json_dumps, data)
         await self.database.insert(
             DB_TABLE_CACHE,
             {"key": cache_key, "expires": expires, "checksum": checksum, "data": data},
index 34411034a499191612ae6ad24ca998a1d90c64db..e2d47c381b20bd507f5663fd0803b88b967f673e 100644 (file)
@@ -16,21 +16,30 @@ from cryptography.fernet import Fernet, InvalidToken
 from music_assistant.common.helpers.json import JSON_DECODE_EXCEPTIONS, json_dumps, json_loads
 from music_assistant.common.models import config_entries
 from music_assistant.common.models.config_entries import (
+    DEFAULT_CORE_CONFIG_ENTRIES,
     DEFAULT_PLAYER_CONFIG_ENTRIES,
     DEFAULT_PROVIDER_CONFIG_ENTRIES,
     ConfigEntry,
     ConfigValueType,
+    CoreConfig,
     PlayerConfig,
     ProviderConfig,
 )
 from music_assistant.common.models.enums import EventType, ProviderType
 from music_assistant.common.models.errors import InvalidDataError, PlayerUnavailableError
-from music_assistant.constants import CONF_PLAYERS, CONF_PROVIDERS, CONF_SERVER_ID, ENCRYPT_SUFFIX
+from music_assistant.constants import (
+    CONF_CORE,
+    CONF_PLAYERS,
+    CONF_PROVIDERS,
+    CONF_SERVER_ID,
+    ENCRYPT_SUFFIX,
+)
 from music_assistant.server.helpers.api import api_command
 from music_assistant.server.helpers.util import get_provider_module
 from music_assistant.server.models.player_provider import PlayerProvider
 
 if TYPE_CHECKING:
+    from music_assistant.server.models.core_controller import CoreController
     from music_assistant.server.server import MusicAssistant
 
 LOGGER = logging.getLogger(__name__)
@@ -174,12 +183,12 @@ class ConfigController:
         raise KeyError(f"No config found for provider id {instance_id}")
 
     @api_command("config/providers/get_value")
-    def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType:
+    async def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType:
         """Return single configentry value for a provider."""
         cache_key = f"prov_conf_value_{instance_id}.{key}"
         if cached_value := self._value_cache.get(cache_key) is not None:
             return cached_value
-        conf = self.get_provider_config(instance_id)
+        conf = await self.get_provider_config(instance_id)
         val = (
             conf.values[key].value
             if conf.values[key].value is not None
@@ -234,7 +243,6 @@ class ConfigController:
         provider_domain: (mandatory) domain of the provider.
         values: the raw values for config entries that need to be stored/updated.
         instance_id: id of an existing provider instance (None for new instance setup).
-        action: [optional] action key called from config entries UI.
         """
         if instance_id is not None:
             config = await self._update_provider_config(instance_id, values)
@@ -440,6 +448,79 @@ class ConfigController:
         conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}"
         self.set(conf_key, default_config.to_raw())
 
+    @api_command("config/core")
+    async def get_core_configs(
+        self,
+    ) -> list[CoreConfig]:
+        """Return all core controllers config options."""
+        return [
+            await self.get_core_config(core_controller)
+            for core_controller in ("streams", "webserver")
+        ]
+
+    @api_command("config/core/get")
+    async def get_core_config(self, core_controller: str) -> CoreConfig:
+        """Return configuration for a single core controller."""
+        raw_conf = self.get(f"{CONF_CORE}/{core_controller}", {})
+        config_entries = await self.get_core_config_entries(core_controller)
+        return CoreConfig.parse(config_entries, raw_conf)
+
+    @api_command("config/core/get_entries")
+    async def get_core_config_entries(
+        self,
+        core_controller: str,
+        action: str | None = None,
+        values: dict[str, ConfigValueType] | None = None,
+    ) -> tuple[ConfigEntry, ...]:
+        """
+        Return Config entries to configure a core controller.
+
+        core_controller: name of the core controller
+        action: [optional] action key called from config entries UI.
+        values: the (intermediate) raw values for config entries sent with the action.
+        """
+        if values is None:
+            values = self.get(f"{CONF_CORE}/{core_controller}/values", {})
+        controller: CoreController = getattr(self.mass, core_controller)
+        return (
+            await controller.get_config_entries(action=action, values=values)
+            + DEFAULT_CORE_CONFIG_ENTRIES
+        )
+
+    @api_command("config/core/save")
+    async def save_core_config(
+        self,
+        core_controller: str,
+        values: dict[str, ConfigValueType],
+    ) -> CoreConfig:
+        """Save CoreController Config values."""
+        config = await self.get_core_config(core_controller)
+        changed_keys = config.update(values)
+        # validate the new config
+        config.validate()
+        if not changed_keys:
+            # no changes
+            return config
+        # try to load the provider first to catch errors before we save it.
+        controller: CoreController = getattr(self.mass, core_controller)
+        await controller.reload()
+        # reload succeeded, save new config
+        config.last_error = None
+        conf_key = f"{CONF_CORE}/{core_controller}"
+        self.set(conf_key, config.to_raw())
+        # return full config, just in case
+        return await self.get_core_config(core_controller)
+
+    def get_raw_core_config_value(
+        self, core_module: str, key: str, default: ConfigValueType = None
+    ) -> ConfigValueType:
+        """
+        Return (raw) single configentry value for a core controller.
+
+        Note that this only returns the stored value without any validation or default.
+        """
+        return self.get(f"{CONF_CORE}/{core_module}/{key}", default)
+
     def save(self, immediate: bool = False) -> None:
         """Schedule save of data to disk."""
         self._value_cache = {}
index fa435bcc86cf9c19d260a131fb510aab173af1f1..ae261a09c6cd818109411894d67aed7396b9605c 100755 (executable)
@@ -29,27 +29,30 @@ from music_assistant.common.models.media_items import (
 )
 from music_assistant.constants import ROOT_LOGGER_NAME
 from music_assistant.server.helpers.images import create_collage, get_image_thumb
+from music_assistant.server.models.core_controller import CoreController
 
 if TYPE_CHECKING:
-    from music_assistant.server import MusicAssistant
     from music_assistant.server.models.metadata_provider import MetadataProvider
 
 LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.metadata")
 
 
-class MetaDataController:
+class MetaDataController(CoreController):
     """Several helpers to search and store metadata for mediaitems."""
 
-    def __init__(self, mass: MusicAssistant) -> None:
+    name: str = "metadata"
+    friendly_name: str = "Metadata controller"
+
+    def __init__(self, *args, **kwargs) -> None:
         """Initialize class."""
-        self.mass = mass
-        self.cache = mass.cache
+        super().__init__(*args, **kwargs)
+        self.cache = self.mass.cache
         self._pref_lang: str | None = None
         self.scan_busy: bool = False
 
     async def setup(self) -> None:
         """Async initialize of module."""
-        self.mass.webserver.register_route("/imageproxy", self._handle_imageproxy)
+        self.mass.streams.register_dynamic_route("/imageproxy", self._handle_imageproxy)
 
     async def close(self) -> None:
         """Handle logic on server stop."""
@@ -312,7 +315,7 @@ class MetaDataController:
             # return imageproxy url for images that need to be resolved
             # the original path is double encoded
             encoded_url = urllib.parse.quote(urllib.parse.quote(image.path))
-            return f"{self.mass.webserver.base_url}/imageproxy?path={encoded_url}&provider={image.provider}&size={size}"  # noqa: E501
+            return f"{self.mass.streams.base_url}/imageproxy?path={encoded_url}&provider={image.provider}&size={size}"  # noqa: E501
         return image.path
 
     async def get_thumbnail(
index 1f459af57c6225826c358b5f16d496cf39367366..a6e710988fd7a479ab21d1ccc5ac41aee288fd04 100755 (executable)
@@ -29,6 +29,7 @@ from music_assistant.constants import (
 )
 from music_assistant.server.helpers.api import api_command
 from music_assistant.server.helpers.database import DatabaseConnection
+from music_assistant.server.models.core_controller import CoreController
 from music_assistant.server.models.music_provider import MusicProvider
 
 from .media.albums import AlbumsController
@@ -38,25 +39,29 @@ from .media.radio import RadioController
 from .media.tracks import TracksController
 
 if TYPE_CHECKING:
-    from music_assistant.server import MusicAssistant
+    pass
 
 LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.music")
 SYNC_INTERVAL = 3 * 3600
 
 
-class MusicController:
+class MusicController(CoreController):
     """Several helpers around the musicproviders."""
 
+    name: str = "music"
+    friendly_name: str = "Music library"
+
     database: DatabaseConnection | None = None
 
-    def __init__(self, mass: MusicAssistant):
+    def __init__(self, *args, **kwargs) -> None:
         """Initialize class."""
-        self.mass = mass
-        self.artists = ArtistsController(mass)
-        self.albums = AlbumsController(mass)
-        self.tracks = TracksController(mass)
-        self.radio = RadioController(mass)
-        self.playlists = PlaylistController(mass)
+        super().__init__(*args, **kwargs)
+        self.cache = self.mass.cache
+        self.artists = ArtistsController(self.mass)
+        self.albums = AlbumsController(self.mass)
+        self.tracks = TracksController(self.mass)
+        self.radio = RadioController(self.mass)
+        self.playlists = PlaylistController(self.mass)
         self.in_progress_syncs: list[SyncTask] = []
         self._sync_lock = asyncio.Lock()
 
index 20377c41660181dc6e76e7cb7cf5aa3cf12475a2..18d4673704e61e2c171f5b195d24c95aec2fefd6 100755 (executable)
@@ -209,7 +209,10 @@ class PlayerQueuesController:
         queue_items = [QueueItem.from_media_item(queue_id, x) for x in tracks if x and x.available]
 
         # load the items into the queue
-        cur_index = queue.index_in_buffer or 0
+        if queue.state in (PlayerState.PLAYING, PlayerState.PAUSED):
+            cur_index = queue.index_in_buffer or 0
+        else:
+            cur_index = queue.current_index or 0
         shuffle = queue.shuffle_enabled and len(queue_items) >= 5
 
         # handle replace: clear all items and replace with the new items
@@ -430,7 +433,7 @@ class PlayerQueuesController:
         await self.play_index(queue_id, queue.current_index, position)
 
     @api_command("players/queue/resume")
-    async def resume(self, queue_id: str) -> None:
+    async def resume(self, queue_id: str, fade_in: bool | None = None) -> None:
         """Handle RESUME command for given queue.
 
         - queue_id: queue_id of the queue to handle the command.
@@ -459,7 +462,7 @@ class PlayerQueuesController:
 
         if resume_item is not None:
             resume_pos = resume_pos if resume_pos > 10 else 0
-            fade_in = resume_pos > 0
+            fade_in = fade_in if fade_in is not None else resume_pos > 0
             await self.play_index(queue_id, resume_item.queue_item_id, resume_pos, fade_in)
         else:
             raise QueueEmpty(f"Resume queue requested but queue {queue_id} is empty")
@@ -486,16 +489,43 @@ class PlayerQueuesController:
         queue.index_in_buffer = index
         # power on player if needed
         await self.mass.players.cmd_power(queue_id, True)
-        # execute the play_media command on the player(s)
+        # always send stop command first
+        # await self.mass.players.cmd_stop(queue_id)
+        # execute the play_media command on the player
+        queue_player = self.mass.players.get(queue_id)
+        need_multi_stream = (
+            queue_player.provider in ("airplay", "ugp", "slimproto")
+            and len(queue_player.group_childs) > 1
+        )
         player_prov = self.mass.players.get_player_provider(queue_id)
-        flow_mode = await self.mass.config.get_player_config_value(queue.queue_id, CONF_FLOW_MODE)
-        queue.flow_mode = flow_mode
-        await player_prov.cmd_play_media(
-            queue_id,
+        if need_multi_stream:
+            # handle special multi client stream
+            queue.flow_mode = True
+            stream_job = await self.mass.streams.create_multi_client_stream_job(
+                queue_id=queue_id,
+                start_queue_item=queue_item,
+                seek_position=int(seek_position),
+                fade_in=fade_in,
+            )
+            await player_prov.cmd_handle_stream_job(player_id=queue_id, stream_job=stream_job)
+            return
+        # regular stream
+        queue.flow_mode = await self.mass.config.get_player_config_value(
+            queue.queue_id, CONF_FLOW_MODE
+        )
+        url = await self.mass.streams.resolve_stream_url(
+            queue_id=queue_id,
             queue_item=queue_item,
-            seek_position=seek_position,
+            seek_position=int(seek_position),
             fade_in=fade_in,
-            flow_mode=flow_mode,
+            flow_mode=queue.flow_mode,
+        )
+        await player_prov.cmd_play_url(
+            player_id=queue_id,
+            url=url,
+            # set queue_item to None if we're sending a flow mode url
+            # as the metadata is rather useless then
+            queue_item=None if queue.flow_mode else queue_item,
         )
 
     # Interaction with player
@@ -543,10 +573,7 @@ class PlayerQueuesController:
         if queue.active:
             queue.state = player.state
             # update current item from player report
-            player_item_index = self.index_by_id(queue_id, player.current_item_id)
-            if player_item_index is None:
-                # try grabbing the item id from the url
-                player_item_index = self._get_player_item_index(queue_id, player.current_url)
+            player_item_index = self._get_player_item_index(queue_id, player.current_url)
             if player_item_index is not None:
                 if queue.flow_mode:
                     # flow mode active, calculate current item
@@ -622,25 +649,25 @@ class PlayerQueuesController:
         self._queues.pop(player_id, None)
         self._queue_items.pop(player_id, None)
 
-    async def player_ready_for_next_track(
-        self, queue_or_player_id: str, current_item_id: str
-    ) -> tuple[QueueItem, bool]:
-        """Call when a player is ready to load the next track into the buffer.
+    async def preload_next_url(
+        self, queue_id: str, current_item_id: str | None = None
+    ) -> tuple[str, QueueItem, bool]:
+        """Call when a player wants to load the next track/url into the buffer.
 
-        The result is a tuple of the next QueueItem to Play,
+        The result is a tuple of the next url + QueueItem to Play,
         and a bool if the player should crossfade (if supported).
         Raises QueueEmpty if there are no more tracks left.
-
-        NOTE: The player(s) should resolve the stream URL for the QueueItem,
-        just like with the play_media call.
         """
-        queue = self.get_active_queue(queue_or_player_id)
-        cur_index = self.index_by_id(queue.queue_id, current_item_id)
-        cur_item = self.get_item(queue.queue_id, cur_index)
+        queue = self.get(queue_id)
+        if current_item_id:
+            cur_index = self.index_by_id(queue_id, current_item_id) or 0
+        else:
+            cur_index = queue.index_in_buffer or queue.current_index or 0
+        cur_item = self.get_item(queue_id, cur_index)
         idx = 0
         while True:
-            next_index = self.get_next_index(queue.queue_id, cur_index + idx)
-            next_item = self.get_item(queue.queue_id, next_index)
+            next_index = self.get_next_index(queue_id, cur_index + idx)
+            next_item = self.get_item(queue_id, next_index)
             if not cur_item or not next_item:
                 raise QueueEmpty("No more tracks left in the queue.")
             try:
@@ -667,7 +694,11 @@ class PlayerQueuesController:
             # disable crossfade if playing tracks from same album
             # TODO: make this a bit more intelligent.
             crossfade = False
-        return (next_item, crossfade)
+        url = await self.mass.streams.resolve_stream_url(
+            queue_id=queue_id,
+            queue_item=next_item,
+        )
+        return (url, next_item, crossfade)
 
     # Main queue manipulation methods
 
@@ -847,9 +878,9 @@ class PlayerQueuesController:
         return queue_index, track_time
 
     def _get_player_item_index(self, queue_id: str, url: str) -> str | None:
-        """Parse QueueItem ID from Player's current url."""
-        if url and self.mass.webserver.base_url in url and "/stream/" in url:
+        """Parse (start) QueueItem ID from Player's current url."""
+        if url and self.mass.streams.base_url in url and queue_id in url:
             # try to extract the item id from the uri
-            current_item_id = url.rsplit("/")[-2]
+            current_item_id = url.rsplit("/")[-1].split(".")[0]
             return self.index_by_id(queue_id, current_item_id)
         return None
index defd6a5e02820cf758ae5d89ec743d02665cd917..cbccfd6745601ba81538a155ff1908310035cfa6 100755 (executable)
@@ -4,7 +4,7 @@ from __future__ import annotations
 import asyncio
 import logging
 from collections.abc import Iterator
-from typing import TYPE_CHECKING, cast
+from typing import cast
 
 from music_assistant.common.helpers.util import get_changed_values
 from music_assistant.common.models.enums import (
@@ -23,22 +23,23 @@ from music_assistant.common.models.errors import (
 from music_assistant.common.models.player import Player
 from music_assistant.constants import CONF_HIDE_GROUP_CHILDS, CONF_PLAYERS, ROOT_LOGGER_NAME
 from music_assistant.server.helpers.api import api_command
+from music_assistant.server.models.core_controller import CoreController
 from music_assistant.server.models.player_provider import PlayerProvider
 
 from .player_queues import PlayerQueuesController
 
-if TYPE_CHECKING:
-    from music_assistant.server import MusicAssistant
-
 LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.players")
 
 
-class PlayerController:
+class PlayerController(CoreController):
     """Controller holding all logic to control registered players."""
 
-    def __init__(self, mass: MusicAssistant) -> None:
-        """Initialize class."""
-        self.mass = mass
+    name: str = "players"
+    friendly_name: str = "Players controller"
+
+    def __init__(self, *args, **kwargs) -> None:
+        """Initialize core controller."""
+        super().__init__(*args, **kwargs)
         self._players: dict[str, Player] = {}
         self._prev_states: dict[str, dict] = {}
         self.queues = PlayerQueuesController(self)
@@ -253,8 +254,8 @@ class PlayerController:
         - player_id: player_id of the player to handle the command.
         """
         player_id = self._check_redirect(player_id)
-        player_provider = self.get_player_provider(player_id)
-        await player_provider.cmd_stop(player_id)
+        if player_provider := self.get_player_provider(player_id):
+            await player_provider.cmd_stop(player_id)
 
     @api_command("players/cmd/play")
     async def cmd_play(self, player_id: str) -> None:
@@ -527,19 +528,19 @@ class PlayerController:
                     return group_player.player_id
         # guess source from player's current url
         if player.current_url and player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
-            if self.mass.webserver.base_url in player.current_url:
+            if self.mass.streams.base_url in player.current_url:
                 return player.player_id
             if ":" in player.current_url:
                 # extract source from uri/url
                 return player.current_url.split(":")[0]
-            return player.current_item_id or player.current_url
+            return player.current_url
         # defaults to the player's own player id
         return player.player_id
 
     def _get_group_volume_level(self, player: Player) -> int:
         """Calculate a group volume from the grouped members."""
-        if not player.group_childs:
-            # player is not a group
+        if len(player.group_childs) == 0:
+            # player is not a group or syncgroup
             return player.volume_level
         # calculate group volume from all (turned on) players
         group_volume = 0
@@ -559,13 +560,6 @@ class PlayerController:
     ) -> list[Player]:
         """Get (child) players attached to a grouped player."""
         child_players: list[Player] = []
-        if not player.group_childs:
-            # player is not a group
-            return child_players
-        if player.type != PlayerType.GROUP:
-            # if the player is not a dedicated player group,
-            # it is the master in a sync group and thus always present as child player
-            child_players.append(player)
         for child_id in player.group_childs:
             if child_player := self.get(child_id, False):
                 if not (not only_powered or child_player.powered):
index eaf86f47a8f950df97fed680f03cb35e36e6e435..4571d548cc104a10a62680320282c8d44bfa34d6 100644 (file)
@@ -1,26 +1,41 @@
-"""Controller to stream audio to players."""
+"""
+Controller to stream audio to players.
+
+The streams controller hosts a basic, unprotected HTTP-only webserver
+purely to stream audio packets to players and some control endpoints such as
+the upnp callbacks and json rpc api for slimproto clients.
+"""
 from __future__ import annotations
 
 import asyncio
 import logging
 import urllib.parse
 from collections.abc import AsyncGenerator
-from typing import TYPE_CHECKING, Any
+from contextlib import suppress
+from typing import TYPE_CHECKING
 
 import shortuuid
 from aiohttp import web
 
-from music_assistant.common.helpers.util import empty_queue
-from music_assistant.common.models.enums import ContentType, PlayerState
+from music_assistant.common.helpers.util import get_ip, select_free_port
+from music_assistant.common.models.config_entries import (
+    DEFAULT_CORE_CONFIG_ENTRIES,
+    ConfigEntry,
+    ConfigValueType,
+)
+from music_assistant.common.models.enums import ConfigEntryType, ContentType
 from music_assistant.common.models.errors import MediaNotFoundError, QueueEmpty
+from music_assistant.common.models.media_items import AudioFormat
+from music_assistant.common.models.player_queue import PlayerQueue
 from music_assistant.common.models.queue_item import QueueItem
 from music_assistant.constants import (
+    CONF_BIND_IP,
+    CONF_BIND_PORT,
     CONF_EQ_BASS,
     CONF_EQ_MID,
     CONF_EQ_TREBLE,
     CONF_OUTPUT_CHANNELS,
     CONF_OUTPUT_CODEC,
-    ROOT_LOGGER_NAME,
 )
 from music_assistant.server.helpers.audio import (
     check_audio_support,
@@ -30,87 +45,139 @@ from music_assistant.server.helpers.audio import (
     get_stream_details,
 )
 from music_assistant.server.helpers.process import AsyncProcess
+from music_assistant.server.helpers.webserver import Webserver
+from music_assistant.server.models.core_controller import CoreController
 
 if TYPE_CHECKING:
     from music_assistant.common.models.player import Player
-    from music_assistant.server import MusicAssistant
 
-LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.streams")
 
+DEFAULT_STREAM_HEADERS = {
+    "transferMode.dlna.org": "Streaming",
+    "contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000",  # noqa: E501
+    "Cache-Control": "no-cache",
+    "Connection": "close",
+    "icy-name": "Music Assistant",
+    "icy-pub": "0",
+}
+FLOW_MAX_SAMPLE_RATE = 96000
+FLOW_MAX_BIT_DEPTH = 24
+WORKAROUND_PLAYERS_CACHE_KEY = "streams.workaround_players"
 
-class StreamJob:
-    """Representation of a (multisubscriber) Audio Queue (item)stream job/task.
+
+class MultiClientStreamJob:
+    """Representation of a (multiclient) Audio Queue stream job/task.
 
     The whole idea here is that in case of a player (sync)group,
-    all players receive the exact same PCM audio chunks from the source audio.
-    A StreamJob is tied to a queueitem,
-    meaning that streaming of each QueueItem will have its own StreamJob.
-    In case a QueueItem is restarted (e.g. when seeking), a new StreamJob will be created.
+    all client players receive the exact same (PCM) audio chunks from the source audio.
+    A StreamJob is tied to a Queue and streams the queue flow stream,
+    In case a stream is restarted (e.g. when seeking), a new MultiClientStreamJob will be created.
     """
 
     def __init__(
         self,
-        queue_item: QueueItem,
-        pcm_sample_rate: int,
-        pcm_bit_depth: int,
-        audio_source: AsyncGenerator[bytes, None] | None = None,
-        flow_mode: bool = False,
+        stream_controller: StreamsController,
+        queue_id: str,
+        pcm_format: AudioFormat,
+        start_queue_item: QueueItem,
+        seek_position: int = 0,
+        fade_in: bool = False,
     ) -> None:
-        """Initialize MultiQueue instance."""
-        self.queue_item = queue_item
-        self.audio_source = audio_source
-        # internally all audio within MA is raw PCM, hence the pcm details
-        self.pcm_sample_rate = pcm_sample_rate
-        self.pcm_bit_depth = pcm_bit_depth
-        self.pcm_sample_size = int(pcm_sample_rate * (pcm_bit_depth / 8) * 2)
-        self.stream_id = shortuuid.uuid()
-        self.expected_consumers: set[str] = set()
-        self.flow_mode = flow_mode
-        self.subscribers: dict[str, asyncio.Queue[bytes]] = {}
+        """Initialize MultiClientStreamJob instance."""
+        self.stream_controller = stream_controller
+        self.queue_id = queue_id
+        self.queue = self.stream_controller.mass.players.queues.get(queue_id)
+        assert self.queue  # just in case
+        self.pcm_format = pcm_format
+        self.start_queue_item = start_queue_item
+        self.seek_position = seek_position
+        self.fade_in = fade_in
+        self.job_id = shortuuid.uuid()
+        self.expected_players: set[str] = set()
+        self.subscribed_players: dict[str, asyncio.Queue[bytes]] = {}
+        self.bytes_streamed: int = 0
+        self.client_seconds_skipped: dict[str, int] = {}
         self._all_clients_connected = asyncio.Event()
-        self._audio_task: asyncio.Task | None = None
-        self.seen_players: set[str] = set()
+        # start running the audio task in the background
+        self._audio_task = asyncio.create_task(self._stream_job_runner())
+        self.logger = stream_controller.logger.getChild(f"streamjob_{self.job_id}")
+        self._finished: bool = False
 
     @property
     def finished(self) -> bool:
         """Return if this StreamJob is finished."""
-        if self._audio_task is None:
-            return False
-        if not self._all_clients_connected.is_set():
-            return False
-        return self._audio_task.cancelled() or self._audio_task.done()
+        return self._finished or self._audio_task.done()
 
     @property
     def pending(self) -> bool:
         """Return if this Job is pending start."""
-        return not self._all_clients_connected.is_set()
+        return not self.finished and not self._all_clients_connected.is_set()
 
     @property
     def running(self) -> bool:
         """Return if this Job is running."""
         return not self.finished and not self.pending
 
+    def stop(self) -> None:
+        """Stop running this job."""
+        self._finished = True
+        if self._audio_task.done():
+            return
+        self._audio_task.cancel()
+        for sub_queue in self.subscribed_players.values():
+            with suppress(asyncio.QueueFull):
+                sub_queue.put_nowait(b"")
+
+    async def resolve_stream_url(
+        self,
+        child_player_id: str,
+    ) -> str:
+        """Resolve the childplayer specific stream URL to this streamjob."""
+        output_codec = ContentType(
+            await self.stream_controller.mass.config.get_player_config_value(
+                child_player_id, CONF_OUTPUT_CODEC
+            )
+        )
+        fmt = output_codec.value
+        # handle raw pcm
+        if output_codec.is_pcm():
+            player = self.stream_controller.mass.players.get(child_player_id)
+            player_max_bit_depth = 32 if player.supports_24bit else 16
+            output_sample_rate = min(self.pcm_format.sample_rate, player.max_sample_rate)
+            output_bit_depth = min(self.pcm_format.bit_depth, player_max_bit_depth)
+            output_channels = await self.stream_controller.mass.config.get_player_config_value(
+                child_player_id, CONF_OUTPUT_CHANNELS
+            )
+            channels = 1 if output_channels != "stereo" else 2
+            fmt += (
+                f";codec=pcm;rate={output_sample_rate};"
+                f"bitrate={output_bit_depth};channels={channels}"
+            )
+        url = f"{self.stream_controller._server.base_url}/{self.queue_id}/multi/{self.job_id}/{child_player_id}/{self.start_queue_item.queue_item_id}.{fmt}"  # noqa: E501
+        self.expected_players.add(child_player_id)
+        return url
+
     async def subscribe(self, player_id: str) -> AsyncGenerator[bytes, None]:
         """Subscribe consumer and iterate incoming chunks on the queue."""
-        self.start()
-        self.seen_players.add(player_id)
         try:
-            sub_queue = asyncio.Queue(1)
+            self.subscribed_players[player_id] = sub_queue = asyncio.Queue(1)
 
-            # some checks
-            assert player_id not in self.subscribers, "No duplicate subscriptions allowed"
-            assert not self.finished, "Already finished"
-            assert not self.running, "Already running"
+            if self._all_clients_connected.is_set():
+                # client subscribes while we're already started
+                self.logger.debug(
+                    "Client %s is joining while the stream is already started", player_id
+                )
+                # calculate how many seconds the client missed so far
+                self.client_seconds_skipped[player_id] = (
+                    self.bytes_streamed / self.pcm_format.pcm_sample_size
+                )
+            else:
+                self.logger.debug("Subscribed client %s", player_id)
 
-            self.subscribers[player_id] = sub_queue
-            if len(self.subscribers) == len(self.expected_consumers):
+            if len(self.subscribed_players) == len(self.expected_players):
                 # we reached the number of expected subscribers, set event
                 # so that chunks can be pushed
                 self._all_clients_connected.set()
-            else:
-                # wait until all expected subscribers arrived
-                # TODO: handle edge case where a player does not connect at all ?!
-                await self._all_clients_connected.wait()
 
             # keep reading audio chunks from the queue until we receive an empty one
             while True:
@@ -120,254 +187,386 @@ class StreamJob:
                     break
                 yield chunk
         finally:
-            empty_queue(sub_queue)
-            self.subscribers.pop(player_id)
-            # some delay here to detect misbehaving (reconnecting) players
-            await asyncio.sleep(2)
+            self.subscribed_players.pop(player_id, None)
+            self.logger.debug("Unsubscribed client %s", player_id)
             # check if this was the last subscriber and we should cancel
-            if len(self.subscribers) == 0 and self._audio_task and not self.finished:
+            await asyncio.sleep(2)
+            if len(self.subscribed_players) == 0 and self._audio_task and not self.finished:
+                self.logger.debug("Cleaning up, all clients disappeared...")
                 self._audio_task.cancel()
 
-    async def _put_data(self, data: Any, timeout: float = 120) -> None:
+    async def _put_chunk(self, chunk: bytes) -> None:
         """Put chunk of data to all subscribers."""
-        async with asyncio.timeout(timeout):
-            while len(self.subscribers) == 0:
-                # this may happen with misbehaving clients that do
-                # multiple GET requests for the same audio stream.
-                # they receive the first chunk, disconnect and then
-                # directly reconnect again.
-                if not self._audio_task or self.finished:
-                    return
-                await asyncio.sleep(0.1)
-            async with asyncio.TaskGroup() as tg:
-                for sub_id in self.subscribers:
-                    sub_queue = self.subscribers[sub_id]
-                    tg.create_task(sub_queue.put(data))
+        async with asyncio.TaskGroup() as tg:
+            for sub_queue in list(self.subscribed_players.values()):
+                # put this chunk on the player's subqueue
+                tg.create_task(sub_queue.put(chunk))
+        self.bytes_streamed += len(chunk)
 
     async def _stream_job_runner(self) -> None:
         """Feed audio chunks to StreamJob subscribers."""
         chunk_num = 0
-        async for chunk in self.audio_source:
-            chunk_num += 1
-            if chunk_num == 1:
+        async for chunk in self.stream_controller.get_flow_stream(
+            self.queue, self.start_queue_item, self.pcm_format, self.seek_position, self.fade_in
+        ):
+            if chunk_num == 0:
                 # wait until all expected clients are connected
                 try:
                     async with asyncio.timeout(10):
                         await self._all_clients_connected.wait()
-                except TimeoutError as err:
-                    if len(self.subscribers) == 0:
-                        raise TimeoutError("Clients did not connect within 10 seconds.") from err
+                except TimeoutError:
+                    if len(self.subscribed_players) == 0:
+                        self.stream_controller.logger.error(
+                            "Abort multi client stream job for queue %s: "
+                            "clients did not connect within timeout",
+                            self.queue.display_name,
+                        )
+                        break
+                    # not all clients connected but timeout expired, set flag and move on
+                    # with all clients that did connect
                     self._all_clients_connected.set()
-                    LOGGER.warning(
-                        "Starting stream job %s but not all clients connected within 10 seconds."
+                else:
+                    self.stream_controller.logger.debug(
+                        "Starting multi client stream job for queue %s "
+                        "with %s out of %s connected clients",
+                        self.queue.display_name,
+                        len(self.subscribed_players),
+                        len(self.expected_players),
                     )
-
-            await self._put_data(chunk)
+            await self._put_chunk(chunk)
+            chunk_num += 1
 
         # mark EOF with empty chunk
-        await self._put_data(b"")
+        await self._put_chunk(b"")
 
-    def start(self) -> None:
-        """Start running the stream job."""
-        if self._audio_task:
-            return
-        self._audio_task = asyncio.create_task(self._stream_job_runner())
+
+def parse_pcm_info(content_type: str) -> tuple[int, int, int]:
+    """Parse PCM info from a codec/content_type string."""
+    params = (
+        dict(urllib.parse.parse_qsl(content_type.replace(";", "&"))) if ";" in content_type else {}
+    )
+    sample_rate = int(params.get("rate", 44100))
+    sample_size = int(params.get("bitrate", 16))
+    channels = int(params.get("channels", 2))
+    return (sample_rate, sample_size, channels)
 
 
-class StreamsController:
-    """Controller to stream audio to players."""
+class StreamsController(CoreController):
+    """Webserver Controller to stream audio to players."""
 
-    def __init__(self, mass: MusicAssistant):
+    name: str = "streams"
+    friendly_name: str = "Streamserver"
+
+    def __init__(self, *args, **kwargs):
         """Initialize instance."""
-        self.mass = mass
-        # streamjobs contains all active stream jobs
-        # there may be multiple jobs for the same queue item (e.g. when seeking)
-        # the key is the (unique) stream_id for the StreamJob
-        self.stream_jobs: dict[str, StreamJob] = {}
-        # some players do multiple GET requests for the same audio stream
-        # to determine content type or content length
-        # we try to detect/report these players and workaround it.
-        # if a player_id is in the below set of player_ids, the first GET request
-        # of that player will be ignored and audio is served only in the 2nd request
+        super().__init__(*args, **kwargs)
+        self._server = Webserver(self.logger, enable_dynamic_routes=True)
+        self.multi_client_jobs: dict[str, MultiClientStreamJob] = {}
+        self.register_dynamic_route = self._server.register_dynamic_route
+        self.unregister_dynamic_route = self._server.unregister_dynamic_route
         self.workaround_players: set[str] = set()
 
+    @property
+    def base_url(self) -> str:
+        """Return the base_url for the streamserver."""
+        return self._server.base_url
+
+    async def get_config_entries(
+        self,
+        action: str | None = None,  # noqa: ARG002
+        values: dict[str, ConfigValueType] | None = None,  # noqa: ARG002
+    ) -> tuple[ConfigEntry, ...]:
+        """Return all Config Entries for this core module (if any)."""
+        return DEFAULT_CORE_CONFIG_ENTRIES + (
+            ConfigEntry(
+                key=CONF_BIND_PORT,
+                type=ConfigEntryType.STRING,
+                default_value=self._default_port,
+                label="TCP Port",
+                description="The TCP port to run the server. "
+                "Make sure that this server can be reached "
+                "on the given IP and TCP port by players on the local network.",
+            ),
+            ConfigEntry(
+                key=CONF_BIND_IP,
+                type=ConfigEntryType.STRING,
+                default_value=self._default_ip,
+                label="Bind to IP/interface",
+                description="Start the (web)server on this specific interface. \n"
+                "This IP address is communicated to players where to find this server. "
+                "Override the default in advanced scenarios, such as multi NIC configurations. \n"
+                "Make sure that this server can be reached "
+                "on the given IP and TCP port by players on the local network. \n"
+                "This is an advanced setting that should normally "
+                "not be adjusted in regular setups.",
+                advanced=True,
+            ),
+        )
+
     async def setup(self) -> None:
         """Async initialize of module."""
+        self._default_ip = await get_ip()
+        self._default_port = await select_free_port(8096, 9200)
         ffmpeg_present, libsoxr_support, version = await check_audio_support()
         if not ffmpeg_present:
-            LOGGER.error("FFmpeg binary not found on your system, playback will NOT work!.")
+            self.logger.error("FFmpeg binary not found on your system, playback will NOT work!.")
         elif not libsoxr_support:
-            LOGGER.warning(
+            self.logger.warning(
                 "FFmpeg version found without libsoxr support, "
                 "highest quality audio not available. "
             )
-        await self._cleanup_stale()
-        LOGGER.info(
-            "Started stream controller (using ffmpeg version %s %s)",
+        self.logger.info(
+            "Detected ffmpeg version %s %s",
             version,
             "with libsoxr support" if libsoxr_support else "",
         )
+        # restore known workaround players
+        if cache := await self.mass.cache.get(WORKAROUND_PLAYERS_CACHE_KEY):
+            self.workaround_players.update(cache)
+        # start the webserver
+        self.publish_port = bind_port = self.mass.config.get_raw_core_config_value(
+            self.name, CONF_BIND_IP, self._default_port
+        )
+        self.publish_ip = bind_ip = self.mass.config.get_raw_core_config_value(
+            self.name, CONF_BIND_IP, self._default_ip
+        )
+        await self._server.setup(
+            bind_ip=bind_ip,
+            bind_port=bind_port,
+            base_url=f"http://{bind_ip}:{bind_port}",
+            static_routes=[
+                ("GET", "/preview", self.serve_preview_stream),
+                (
+                    "GET",
+                    "/{queue_id}/multi/{job_id}/{player_id}/{queue_item_id}.{fmt}",
+                    self.serve_multi_subscriber_stream,
+                ),
+                (
+                    "GET",
+                    "/{queue_id}/flow/{queue_item_id}.{fmt}",
+                    self.serve_queue_flow_stream,
+                ),
+                (
+                    "GET",
+                    "/{queue_id}/single/{queue_item_id}.{fmt}",
+                    self.serve_queue_item_stream,
+                ),
+            ],
+        )
 
     async def close(self) -> None:
         """Cleanup on exit."""
+        await self._server.close()
+        await self.mass.cache.set(WORKAROUND_PLAYERS_CACHE_KEY, self.workaround_players)
 
     async def resolve_stream_url(
         self,
+        queue_id: str,
         queue_item: QueueItem,
-        player_id: str,
         seek_position: int = 0,
         fade_in: bool = False,
-        auto_start_runner: bool = True,
         flow_mode: bool = False,
-        output_codec: ContentType | None = None,
     ) -> str:
-        """Resolve the stream URL for the given QueueItem.
-
-        This is called just-in-time by the player implementation to get the URL to the audio.
-        It will create a StreamJob which is a background task responsible for feeding
-        the PCM audio chunks to the consumer(s).
-
-        - queue_item: the QueueItem that is about to be played (or buffered).
-        - player_id: the player_id of the player that will play the stream.
-          In case of a multi subscriber stream (e.g. sync/groups),
-          call resolve for every child player.
-        - seek_position: start playing from this specific position.
-        - fade_in: fade in the music at start (e.g. at resume).
-        - auto_start_runner: Start the audio stream in advance (stream track now).
-        - flow_mode: enable flow mode where the queue tracks are streamed as continuous stream.
-        - output_codec: Encode the stream in the given format (None for auto select).
+        """Resolve the (regular, single player) stream URL for the given QueueItem.
+
+        This is called just-in-time by the Queue controller to get the URL to the audio.
         """
-        # check if there is already a pending job
-        for stream_job in self.stream_jobs.values():
-            if stream_job.finished or stream_job.running:
-                continue
-            if stream_job.queue_item.queue_id != queue_item.queue_id:
-                continue
-            if stream_job.queue_item.queue_item_id != queue_item.queue_item_id:
-                continue
-            # if we hit this point, we have a match
-            break
-        else:
-            # register a new stream job
+        output_codec = ContentType(
+            await self.mass.config.get_player_config_value(queue_id, CONF_OUTPUT_CODEC)
+        )
+        fmt = output_codec.value
+        # handle raw pcm
+        if output_codec.is_pcm():
+            player = self.mass.players.get(queue_id)
+            player_max_bit_depth = 32 if player.supports_24bit else 16
             if flow_mode:
-                # flow mode streamjob
-                sample_rate = 48000  # hardcoded for now
-                bit_depth = 24  # hardcoded for now
-                stream_job = StreamJob(
-                    queue_item=queue_item,
-                    pcm_sample_rate=sample_rate,
-                    pcm_bit_depth=bit_depth,
-                    flow_mode=True,
-                )
-                stream_job.audio_source = self._get_flow_stream(
-                    stream_job, seek_position=seek_position, fade_in=fade_in
-                )
+                output_sample_rate = min(FLOW_MAX_SAMPLE_RATE, player.max_sample_rate)
+                output_bit_depth = min(FLOW_MAX_BIT_DEPTH, player_max_bit_depth)
             else:
-                # regular streamjob
                 streamdetails = await get_stream_details(self.mass, queue_item)
-                stream_job = StreamJob(
-                    queue_item=queue_item,
-                    audio_source=get_media_stream(
-                        self.mass,
-                        streamdetails=streamdetails,
-                        seek_position=seek_position,
-                        fade_in=fade_in,
-                    ),
-                    pcm_sample_rate=streamdetails.sample_rate,
-                    pcm_bit_depth=streamdetails.bit_depth,
+                output_sample_rate = min(
+                    streamdetails.audio_format.sample_rate, player.max_sample_rate
                 )
-
-        stream_job.expected_consumers.add(player_id)
-        self.stream_jobs[stream_job.stream_id] = stream_job
-        if auto_start_runner:
-            stream_job.start()
-
-        # generate player-specific URL for the stream job
-        if output_codec is None:
-            output_codec = ContentType(
-                await self.mass.config.get_player_config_value(player_id, CONF_OUTPUT_CODEC)
-            )
-        fmt = output_codec.value
-        url = f"{self.mass.webserver.base_url}/stream/{player_id}/{queue_item.queue_item_id}/{stream_job.stream_id}.{fmt}"  # noqa: E501
-        # handle pcm
-        if output_codec.is_pcm():
-            player = self.mass.players.get(player_id)
-            output_sample_rate = min(stream_job.pcm_sample_rate, player.max_sample_rate)
-            player_max_bit_depth = 32 if player.supports_24bit else 16
-            output_bit_depth = min(stream_job.pcm_bit_depth, player_max_bit_depth)
+                output_bit_depth = min(streamdetails.audio_format.bit_depth, player_max_bit_depth)
             output_channels = await self.mass.config.get_player_config_value(
-                player_id, CONF_OUTPUT_CHANNELS
+                queue_id, CONF_OUTPUT_CHANNELS
             )
             channels = 1 if output_channels != "stereo" else 2
-            url += (
+            fmt += (
                 f";codec=pcm;rate={output_sample_rate};"
                 f"bitrate={output_bit_depth};channels={channels}"
             )
+        query_params = {}
+        base_path = "flow" if flow_mode else "single"
+        url = f"{self._server.base_url}/{queue_id}/{base_path}/{queue_item.queue_item_id}.{fmt}"
+        if seek_position:
+            query_params["seek_position"] = str(seek_position)
+        if fade_in:
+            query_params["fade_in"] = "1"
+        if query_params:
+            url += "?" + urllib.parse.urlencode(query_params)
         return url
 
-    def get_preview_url(self, provider_instance_id_or_domain: str, track_id: str) -> str:
+    def resolve_preview_url(self, provider_instance_id_or_domain: str, track_id: str) -> str:
         """Return url to short preview sample."""
         enc_track_id = urllib.parse.quote(track_id)
         return (
-            f"{self.mass.webserver.base_url}/stream/preview?"
+            f"{self._server.base_url}/preview?"
             f"provider={provider_instance_id_or_domain}&item_id={enc_track_id}"
         )
 
-    async def serve_queue_stream(self, request: web.Request) -> web.Response:
-        """Serve Queue Stream audio to player(s)."""
-        LOGGER.debug(
-            "Got %s request to %s from %s\nheaders: %s\n",
-            request.method,
-            request.path,
-            request.remote,
-            request.headers,
+    async def create_multi_client_stream_job(
+        self,
+        queue_id: str,
+        start_queue_item: QueueItem,
+        seek_position: int = 0,
+        fade_in: bool = False,
+    ) -> MultiClientStreamJob:
+        """Create a MultiClientStreamJob for the given queue..
+
+        This is called by player/sync group implementations to start streaming
+        the queue audio to multiple players at once.
+        """
+        if existing_job := self.multi_client_jobs.pop(queue_id, None):  # noqa: SIM102
+            # cleanup existing job first
+            if not existing_job.finished:
+                existing_job.stop()
+
+        self.multi_client_jobs[queue_id] = stream_job = MultiClientStreamJob(
+            self,
+            queue_id=queue_id,
+            pcm_format=AudioFormat(
+                # hardcoded pcm quality of 48/24 for now
+                # TODO: change this to the highest quality supported by all child players ?
+                content_type=ContentType.from_bit_depth(24),
+                sample_rate=48000,
+                bit_depth=24,
+                channels=2,
+            ),
+            start_queue_item=start_queue_item,
+            seek_position=seek_position,
+            fade_in=fade_in,
         )
-        player_id = request.match_info["player_id"]
-        player = self.mass.players.get(player_id)
-        queue = self.mass.players.queues.get_active_queue(player_id)
-        if not player:
-            raise web.HTTPNotFound(reason=f"Unknown player_id: {player_id}")
-        stream_id = request.match_info["stream_id"]
-        stream_job = self.stream_jobs.get(stream_id)
-        if not stream_job or stream_job.finished:
-            # Player is trying to play a stream that already exited
-            if player.state == PlayerState.PAUSED:
-                await self.mass.players.queues.resume(player_id)
-            LOGGER.warning(
-                "Got stream request for an already finished stream job for player %s",
-                player.display_name,
-            )
-            raise web.HTTPNotFound(reason=f"Unknown stream_id: {stream_id}")
-
-        output_format_str = request.match_info["fmt"]
-        output_format = ContentType.try_parse(output_format_str)
-        output_sample_rate = min(stream_job.pcm_sample_rate, player.max_sample_rate)
-        player_max_bit_depth = 32 if player.supports_24bit else 16
-        output_bit_depth = min(stream_job.pcm_bit_depth, player_max_bit_depth)
-        if output_format == ContentType.PCM:
-            # resolve generic pcm type
-            output_format = ContentType.from_bit_depth(output_bit_depth)
-        if output_format.is_pcm() or output_format == ContentType.WAV:
-            output_channels = await self.mass.config.get_player_config_value(
-                player_id, CONF_OUTPUT_CHANNELS
-            )
-            channels = 1 if output_channels != "stereo" else 2
-            output_format_str = (
-                f"x-wav;codec=pcm;rate={output_sample_rate};"
-                f"bitrate={output_bit_depth};channels={channels}"
+        return stream_job
+
+    async def serve_queue_item_stream(self, request: web.Request) -> web.Response:
+        """Stream single queueitem audio to a player."""
+        self._log_request(request)
+        queue_id = request.match_info["queue_id"]
+        queue = self.mass.players.queues.get(queue_id)
+        if not queue:
+            raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
+        queue_player = self.mass.players.get(queue_id)
+        queue_item_id = request.match_info["queue_item_id"]
+        queue_item = self.mass.players.queues.get_item(queue_id, queue_item_id)
+        if not queue_item:
+            raise web.HTTPNotFound(reason=f"Unknown Queue item: {queue_item_id}")
+        try:
+            streamdetails = await get_stream_details(self.mass, queue_item=queue_item)
+        except MediaNotFoundError:
+            raise web.HTTPNotFound(
+                reason=f"Unable to retrieve streamdetails for item: {queue_item}"
             )
+        seek_position = int(request.query.get("seek_position", 0))
+        fade_in = bool(request.query.get("fade_in", 0))
+        # work out output format/details
+        output_format = await self._get_output_format(
+            output_format_str=request.match_info["fmt"],
+            queue_player=queue_player,
+            default_sample_rate=streamdetails.audio_format.sample_rate,
+            default_bit_depth=streamdetails.audio_format.bit_depth,
+        )
+
+        # prepare request, add some DLNA/UPNP compatible headers
+        headers = {
+            **DEFAULT_STREAM_HEADERS,
+            "Content-Type": f"audio/{output_format.output_format_str}",
+        }
+        resp = web.StreamResponse(
+            status=200,
+            reason="OK",
+            headers=headers,
+        )
+        await resp.prepare(request)
+
+        # return early if this is only a HEAD request
+        if request.method == "HEAD":
+            return resp
+
+        # all checks passed, start streaming!
+        self.logger.debug(
+            "Start serving audio stream for QueueItem %s to %s", queue_item.uri, queue.display_name
+        )
+
+        # collect player specific ffmpeg args to re-encode the source PCM stream
+        pcm_format = AudioFormat(
+            content_type=ContentType.from_bit_depth(streamdetails.audio_format.bit_depth),
+            sample_rate=streamdetails.audio_format.sample_rate,
+            bit_depth=streamdetails.audio_format.bit_depth,
+        )
+        ffmpeg_args = await self._get_player_ffmpeg_args(
+            queue_player,
+            input_format=pcm_format,
+            output_format=output_format,
+        )
+
+        async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc:
+            # feed stdin with pcm audio chunks from origin
+            async def read_audio():
+                try:
+                    async for chunk in get_media_stream(
+                        self.mass,
+                        streamdetails=streamdetails,
+                        pcm_format=pcm_format,
+                        seek_position=seek_position,
+                        fade_in=fade_in,
+                    ):
+                        try:
+                            await ffmpeg_proc.write(chunk)
+                        except BrokenPipeError:
+                            break
+                finally:
+                    ffmpeg_proc.write_eof()
+
+            ffmpeg_proc.attach_task(read_audio())
+
+            # read final chunks from stdout
+            async for chunk in ffmpeg_proc.iter_any():
+                try:
+                    await resp.write(chunk)
+                except (BrokenPipeError, ConnectionResetError):
+                    # race condition
+                    break
+
+        return resp
 
+    async def serve_queue_flow_stream(self, request: web.Request) -> web.Response:
+        """Stream Queue Flow audio to player."""
+        self._log_request(request)
+        queue_id = request.match_info["queue_id"]
+        queue = self.mass.players.queues.get(queue_id)
+        if not queue:
+            raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
+        start_queue_item_id = request.match_info["queue_item_id"]
+        start_queue_item = self.mass.players.queues.get_item(queue_id, start_queue_item_id)
+        if not start_queue_item:
+            raise web.HTTPNotFound(reason=f"Unknown Queue item: {start_queue_item_id}")
+        seek_position = int(request.query.get("seek_position", 0))
+        fade_in = bool(request.query.get("fade_in", 0))
+        queue_player = self.mass.players.get(queue_id)
+        # work out output format/details
+        output_format = await self._get_output_format(
+            output_format_str=request.match_info["fmt"],
+            queue_player=queue_player,
+            default_sample_rate=FLOW_MAX_SAMPLE_RATE,
+            default_bit_depth=FLOW_MAX_BIT_DEPTH,
+        )
         # prepare request, add some DLNA/UPNP compatible headers
         enable_icy = request.headers.get("Icy-MetaData", "") == "1"
-        icy_meta_interval = 65536 if output_format.is_lossless() else 8192
+        icy_meta_interval = 65536 if output_format.content_type.is_lossless() else 8192
         headers = {
-            "Content-Type": f"audio/{output_format_str}",
-            "transferMode.dlna.org": "Streaming",
-            "contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000",  # noqa: E501
-            "Cache-Control": "no-cache",
-            "Connection": "close",
-            "icy-name": "Music Assistant",
-            "icy-pub": "1",
+            **DEFAULT_STREAM_HEADERS,
+            "Content-Type": f"audio/{output_format.output_format_str}",
         }
         if enable_icy:
             headers["icy-metaint"] = str(icy_meta_interval)
@@ -383,48 +582,33 @@ class StreamsController:
         if request.method == "HEAD":
             return resp
 
-        # handle workaround for players that do 2 multiple GET requests
-        # for the same audio stream (because of the missing duration/length)
-        if player_id in self.workaround_players and player_id not in stream_job.seen_players:
-            stream_job.seen_players.add(player_id)
-            return resp
-
-        # guard for the same player connecting multiple times for the same stream
-        if player_id in stream_job.subscribers:
-            LOGGER.error(
-                "Player %s is making multiple requests for the same stream,"
-                " please create an issue report on the Music Assistant issue tracker.",
-                player.display_name,
-            )
-            # add the player to the list of players that need the workaround
-            self.workaround_players.add(player_id)
-            raise web.HTTPBadRequest(reason="Multiple connections are not allowed.")
-        if stream_job.running:
-            LOGGER.error(
-                "Player %s is making a request for an already running stream,"
-                " please create an issue report on the Music Assistant issue tracker.",
-                player.display_name,
-            )
-            self.mass.create_task(self.mass.players.queues.next(player_id))
-            raise web.HTTPBadRequest(reason="Stream is already running.")
-
         # all checks passed, start streaming!
-        LOGGER.debug("Start serving audio stream %s to %s", stream_id, player.name)
+        self.logger.debug("Start serving Queue flow audio stream for %s", queue_player.name)
 
         # collect player specific ffmpeg args to re-encode the source PCM stream
+        pcm_format = AudioFormat(
+            content_type=ContentType.from_bit_depth(output_format.bit_depth),
+            sample_rate=output_format.sample_rate,
+            bit_depth=output_format.bit_depth,
+            channels=2,
+        )
         ffmpeg_args = await self._get_player_ffmpeg_args(
-            player,
-            input_sample_rate=stream_job.pcm_sample_rate,
-            input_bit_depth=stream_job.pcm_bit_depth,
+            queue_player,
+            input_format=pcm_format,
             output_format=output_format,
-            output_sample_rate=output_sample_rate,
         )
 
         async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc:
             # feed stdin with pcm audio chunks from origin
             async def read_audio():
                 try:
-                    async for chunk in stream_job.subscribe(player_id):
+                    async for chunk in self.get_flow_stream(
+                        queue=queue,
+                        start_queue_item=start_queue_item,
+                        pcm_format=pcm_format,
+                        seek_position=seek_position,
+                        fade_in=fade_in,
+                    ):
                         try:
                             await ffmpeg_proc.write(chunk)
                         except BrokenPipeError:
@@ -473,55 +657,149 @@ class StreamsController:
 
         return resp
 
-    async def _get_flow_stream(
+    async def serve_multi_subscriber_stream(self, request: web.Request) -> web.Response:
+        """Stream Queue Flow audio to a child player within a multi subscriber setup."""
+        self._log_request(request)
+        queue_id = request.match_info["queue_id"]
+        streamjob = self.multi_client_jobs.get(queue_id)
+        if not streamjob:
+            raise web.HTTPNotFound(reason=f"Unknown StreamJob for queue: {queue_id}")
+        job_id = request.match_info["job_id"]
+        if job_id != streamjob.job_id:
+            raise web.HTTPNotFound(reason=f"StreamJob ID {job_id} mismatch for queue: {queue_id}")
+        child_player_id = request.match_info["player_id"]
+        child_player = self.mass.players.get(child_player_id)
+        if not child_player:
+            raise web.HTTPNotFound(reason=f"Unknown player: {child_player_id}")
+        # work out (childplayer specific!) output format/details
+        output_format = await self._get_output_format(
+            output_format_str=request.match_info["fmt"],
+            queue_player=child_player,
+            default_sample_rate=streamjob.pcm_format.sample_rate,
+            default_bit_depth=streamjob.pcm_format.bit_depth,
+        )
+        # prepare request, add some DLNA/UPNP compatible headers
+        headers = {
+            **DEFAULT_STREAM_HEADERS,
+            "Content-Type": f"audio/{output_format.output_format_str}",
+        }
+        resp = web.StreamResponse(
+            status=200,
+            reason="OK",
+            headers=headers,
+        )
+        await resp.prepare(request)
+
+        # return early if this is only a HEAD request
+        if request.method == "HEAD":
+            return resp
+
+        # some players (e.g. dlna, sonos) misbehave and do multiple GET requests
+        # to the stream in an attempt to get the audio details such as duration
+        # which is a bit pointless for our duration-less queue stream
+        # and it completely messes with the subscription logic
+        if child_player_id in streamjob.subscribed_players:
+            self.logger.warning(
+                "Player %s is making multiple requests "
+                "to the same stream, playback may be disturbed!",
+                child_player_id,
+            )
+
+        # all checks passed, start streaming!
+        self.logger.debug(
+            "Start serving multi-subscriber Queue flow audio stream for queue %s to player %s",
+            streamjob.queue.display_name,
+            child_player.display_name,
+        )
+
+        # collect player specific ffmpeg args to re-encode the source PCM stream
+        ffmpeg_args = await self._get_player_ffmpeg_args(
+            child_player,
+            input_format=streamjob.pcm_format,
+            output_format=output_format,
+        )
+
+        async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc:
+            # feed stdin with pcm audio chunks from origin
+            async def read_audio():
+                try:
+                    async for chunk in streamjob.subscribe(child_player_id):
+                        try:
+                            await ffmpeg_proc.write(chunk)
+                        except BrokenPipeError:
+                            break
+                finally:
+                    ffmpeg_proc.write_eof()
+
+            ffmpeg_proc.attach_task(read_audio())
+
+            # read final chunks from stdout
+            async for chunk in ffmpeg_proc.iter_any():
+                try:
+                    await resp.write(chunk)
+                except (BrokenPipeError, ConnectionResetError):
+                    # race condition
+                    break
+
+        return resp
+
+    async def serve_preview_stream(self, request: web.Request):
+        """Serve short preview sample."""
+        self._log_request(request)
+        provider_instance_id_or_domain = request.query["provider"]
+        item_id = urllib.parse.unquote(request.query["item_id"])
+        resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/mp3"})
+        await resp.prepare(request)
+        async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id):
+            await resp.write(chunk)
+        return resp
+
+    async def get_flow_stream(
         self,
-        stream_job: StreamJob,
+        queue: PlayerQueue,
+        start_queue_item: QueueItem,
+        pcm_format: AudioFormat,
         seek_position: int = 0,
         fade_in: bool = False,
     ) -> AsyncGenerator[bytes, None]:
         """Get a flow stream of all tracks in the queue."""
         # ruff: noqa: PLR0915
-        queue_id = stream_job.queue_item.queue_id
-        queue = self.mass.players.queues.get(queue_id)
-        queue_player = self.mass.players.get(queue_id)
+        assert pcm_format.content_type.is_pcm()
         queue_track = None
         last_fadeout_part = b""
-
-        LOGGER.info("Start Queue Flow stream for Queue %s", queue.display_name)
+        total_bytes_written = 0
+        self.logger.info("Start Queue Flow stream for Queue %s", queue.display_name)
 
         while True:
             # get (next) queue item to stream
             if queue_track is None:
-                queue_track = stream_job.queue_item
+                queue_track = start_queue_item
                 use_crossfade = queue.crossfade_enabled
             else:
                 seek_position = 0
                 fade_in = False
                 try:
                     (
+                        _,
                         queue_track,
                         use_crossfade,
-                    ) = await self.mass.players.queues.player_ready_for_next_track(
-                        queue_id, queue_track.queue_item_id
-                    )
+                    ) = await self.mass.players.queues.preload_next_url(queue.queue_id)
                 except QueueEmpty:
                     break
-                # store reference to the current queueitem on the streamjob
-                stream_job.queue_item = queue_track
 
             # get streamdetails
             try:
                 streamdetails = await get_stream_details(self.mass, queue_track)
             except MediaNotFoundError as err:
                 # streamdetails retrieval failed, skip to next track instead of bailing out...
-                LOGGER.warning(
+                self.logger.warning(
                     "Skip track %s due to missing streamdetails",
                     queue_track.name,
                     exc_info=err,
                 )
                 continue
 
-            LOGGER.debug(
+            self.logger.debug(
                 "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s",
                 streamdetails.uri,
                 queue_track.name,
@@ -530,10 +808,8 @@ class StreamsController:
             )
 
             # set some basic vars
-            sample_rate = stream_job.pcm_sample_rate
-            bit_depth = stream_job.pcm_bit_depth
-            pcm_sample_size = int(sample_rate * (bit_depth / 8) * 2)
-            crossfade_duration = 10
+            pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
+            crossfade_duration = 10  # TODO: grab from config
             crossfade_size = int(pcm_sample_size * crossfade_duration)
             queue_track.streamdetails.seconds_skipped = seek_position
             buffer_size = crossfade_size if use_crossfade else int(pcm_sample_size * 2)
@@ -545,24 +821,14 @@ class StreamsController:
             async for chunk in get_media_stream(
                 self.mass,
                 streamdetails,
+                pcm_format=pcm_format,
                 seek_position=seek_position,
                 fade_in=fade_in,
-                sample_rate=sample_rate,
-                bit_depth=bit_depth,
                 # only allow strip silence from begin if track is being crossfaded
                 strip_silence_begin=last_fadeout_part != b"",
             ):
                 chunk_num += 1
 
-                # slow down if the player buffers too aggressively
-                seconds_streamed = int(bytes_written / stream_job.pcm_sample_size)
-                if (
-                    seconds_streamed > 10
-                    and queue_player.corrected_elapsed_time > 10
-                    and (seconds_streamed - queue_player.corrected_elapsed_time) > 10
-                ):
-                    await asyncio.sleep(1)
-
                 ####  HANDLE FIRST PART OF TRACK
 
                 # buffer full for crossfade
@@ -574,8 +840,8 @@ class StreamsController:
                     crossfade_part = await crossfade_pcm_parts(
                         fadein_part,
                         last_fadeout_part,
-                        bit_depth,
-                        sample_rate,
+                        pcm_format.bit_depth,
+                        pcm_format.sample_rate,
                     )
                     # send crossfade_part
                     yield crossfade_part
@@ -605,7 +871,7 @@ class StreamsController:
 
             if bytes_written == 0:
                 # stream error: got empty first chunk ?!
-                LOGGER.warning("Stream error on %s", streamdetails.uri)
+                self.logger.warning("Stream error on %s", streamdetails.uri)
                 queue_track.streamdetails.seconds_streamed = 0
                 continue
 
@@ -622,73 +888,63 @@ class StreamsController:
 
             # end of the track reached - store accurate duration
             queue_track.streamdetails.seconds_streamed = bytes_written / pcm_sample_size
-            LOGGER.debug(
+            total_bytes_written += bytes_written
+            self.logger.debug(
                 "Finished Streaming queue track: %s (%s) on queue %s",
                 queue_track.streamdetails.uri,
                 queue_track.name,
                 queue.display_name,
             )
 
-        LOGGER.info("Finished Queue Flow stream for Queue %s", queue.display_name)
-
-    async def serve_preview(self, request: web.Request):
-        """Serve short preview sample."""
-        provider_instance_id_or_domain = request.query["provider"]
-        item_id = urllib.parse.unquote(request.query["item_id"])
-        resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/mp3"})
-        await resp.prepare(request)
-        async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id):
-            await resp.write(chunk)
-        return resp
+        self.logger.info("Finished Queue Flow stream for Queue %s", queue.display_name)
 
     async def _get_player_ffmpeg_args(
         self,
         player: Player,
-        input_sample_rate: int,
-        input_bit_depth: int,
-        output_format: ContentType,
-        output_sample_rate: int,
+        input_format: AudioFormat,
+        output_format: AudioFormat,
     ) -> list[str]:
         """Get player specific arguments for the given (pcm) input and output details."""
         player_conf = await self.mass.config.get_player_config(player.player_id)
-        conf_channels = player_conf.get_value(CONF_OUTPUT_CHANNELS)
         # generic args
         generic_args = [
             "ffmpeg",
             "-hide_banner",
             "-loglevel",
-            "warning" if LOGGER.isEnabledFor(logging.DEBUG) else "quiet",
+            "warning" if self.logger.isEnabledFor(logging.DEBUG) else "quiet",
             "-ignore_unknown",
         ]
         # input args
         input_args = [
             "-f",
-            ContentType.from_bit_depth(input_bit_depth).value,
+            input_format.content_type.value,
             "-ac",
-            "2",
+            str(input_format.channels),
+            "-channel_layout",
+            "mono" if input_format.channels == 1 else "stereo",
             "-ar",
-            str(input_sample_rate),
+            str(input_format.sample_rate),
             "-i",
             "-",
         ]
         input_args += ["-metadata", 'title="Music Assistant"']
         # select output args
-        if output_format == ContentType.FLAC:
+        if output_format.content_type == ContentType.FLAC:
             output_args = ["-f", "flac", "-compression_level", "3"]
-        elif output_format == ContentType.AAC:
-            output_args = ["-f", "adts", "-c:a", output_format.value, "-b:a", "320k"]
-        elif output_format == ContentType.MP3:
-            output_args = ["-f", "mp3", "-c:a", output_format.value, "-b:a", "320k"]
+        elif output_format.content_type == ContentType.AAC:
+            output_args = ["-f", "adts", "-c:a", "aac", "-b:a", "320k"]
+        elif output_format.content_type == ContentType.MP3:
+            output_args = ["-f", "mp3", "-c:a", "mp3", "-b:a", "320k"]
         else:
-            output_args = ["-f", output_format.value]
+            output_args = ["-f", output_format.content_type.value]
 
         output_args += [
             # append channels
             "-ac",
-            "1" if conf_channels != "stereo" else "2",
+            str(output_format.channels),
             # append sample rate
             "-ar",
-            str(output_sample_rate),
+            str(output_format.sample_rate),
             # output = pipe
             "-",
         ]
@@ -708,6 +964,7 @@ class StreamsController:
                 f"equalizer=frequency=9000:width=18000:width_type=h:gain={eq_treble}"
             )
         # handle output mixing only left or right
+        conf_channels = player_conf.get_value(CONF_OUTPUT_CHANNELS)
         if conf_channels == "left":
             filter_params.append("pan=mono|c0=FL")
         elif conf_channels == "right":
@@ -718,17 +975,48 @@ class StreamsController:
 
         return generic_args + input_args + extra_args + output_args
 
-    async def _cleanup_stale(self) -> None:
-        """Cleanup stale/done stream tasks."""
-        stale = set()
-        for stream_id, job in self.stream_jobs.items():
-            if job.finished:
-                stale.add(stream_id)
-        for stream_id in stale:
-            self.stream_jobs.pop(stream_id, None)
+    def _log_request(self, request: web.Request) -> None:
+        """Log request."""
+        if not self.logger.isEnabledFor(logging.DEBUG):
+            return
+        self.logger.debug(
+            "Got %s request to %s from %s\nheaders: %s\n",
+            request.method,
+            request.path,
+            request.remote,
+            request.headers,
+        )
 
-        # reschedule self to run every 5 minutes
-        def reschedule():
-            self.mass.create_task(self._cleanup_stale())
+    async def _get_output_format(
+        self,
+        output_format_str: str,
+        queue_player: Player,
+        default_sample_rate: int,
+        default_bit_depth: int,
+    ) -> AudioFormat:
+        """Parse (player specific) output format details for given format string."""
+        content_type = ContentType.try_parse(output_format_str)
+        if content_type.is_pcm() or content_type == ContentType.WAV:
+            # parse pcm details from format string
+            output_sample_rate, output_bit_depth, output_channels = parse_pcm_info(
+                output_format_str
+            )
+            if content_type == ContentType.PCM:
+                # resolve generic pcm type
+                content_type = ContentType.from_bit_depth(output_bit_depth)
 
-        self.mass.loop.call_later(300, reschedule)
+        else:
+            output_sample_rate = min(default_sample_rate, queue_player.max_sample_rate)
+            player_max_bit_depth = 32 if queue_player.supports_24bit else 16
+            output_bit_depth = min(default_bit_depth, player_max_bit_depth)
+            output_channels_str = await self.mass.config.get_player_config_value(
+                queue_player.player_id, CONF_OUTPUT_CHANNELS
+            )
+            output_channels = 1 if output_channels_str != "stereo" else 2
+        return AudioFormat(
+            content_type=content_type,
+            sample_rate=output_sample_rate,
+            bit_depth=output_bit_depth,
+            channels=output_channels,
+            output_format_str=output_format_str,
+        )
index da6cd28ab64e6b19eca36901080abe67c3f5c8c1..a461e37b38f2f1659c444dc19679cbb92a4237ca 100644 (file)
-"""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()
index b2d42c8c631cc3ce4e3a1710a9925273571bb7d1..96c8d2506bb4d2c25052ea71419574ea6d62ff5b 100644 (file)
@@ -15,7 +15,12 @@ import aiofiles
 from aiohttp import ClientTimeout
 
 from music_assistant.common.models.errors import AudioError, MediaNotFoundError, MusicAssistantError
-from music_assistant.common.models.media_items import ContentType, MediaType, StreamDetails
+from music_assistant.common.models.media_items import (
+    AudioFormat,
+    ContentType,
+    MediaType,
+    StreamDetails,
+)
 from music_assistant.constants import (
     CONF_VOLUME_NORMALIZATION,
     CONF_VOLUME_NORMALIZATION_TARGET,
@@ -174,7 +179,7 @@ async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> N
         "-i",
         input_file,
         "-f",
-        streamdetails.content_type,
+        streamdetails.audio_format.content_type,
         "-af",
         "ebur128=framelog=verbose",
         "-f",
@@ -263,7 +268,6 @@ async def get_stream_details(mass: MusicAssistant, queue_item: QueueItem) -> Str
                 streamdetails: StreamDetails = await music_prov.get_stream_details(
                     prov_media.item_id
                 )
-                streamdetails.content_type = ContentType(streamdetails.content_type)
             except MusicAssistantError as err:
                 LOGGER.warning(str(err))
             else:
@@ -283,7 +287,7 @@ async def get_stream_details(mass: MusicAssistant, queue_item: QueueItem) -> Str
         streamdetails.duration = queue_item.duration
     # make sure that ffmpeg handles mpeg dash streams directly
     if (
-        streamdetails.content_type == ContentType.MPEG_DASH
+        streamdetails.audio_format.content_type == ContentType.MPEG_DASH
         and streamdetails.data
         and streamdetails.data.startswith("http")
     ):
@@ -377,14 +381,14 @@ def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=
 async def get_media_stream(
     mass: MusicAssistant,
     streamdetails: StreamDetails,
+    pcm_format: AudioFormat,
     seek_position: int = 0,
     fade_in: bool = False,
-    sample_rate: int | None = None,
-    bit_depth: int | None = None,
     strip_silence_begin: bool = False,
     strip_silence_end: bool = True,
 ) -> AsyncGenerator[bytes, None]:
-    """Get the (PCM) audio stream for the given streamdetails.
+    """
+    Get the (raw PCM) audio stream for the given streamdetails.
 
     Other than stripping silence at end and beginning and optional
     volume normalization this is the pure, unaltered audio data as PCM chunks.
@@ -394,11 +398,8 @@ async def get_media_stream(
     is_radio = streamdetails.media_type == MediaType.RADIO or not streamdetails.duration
     if is_radio or seek_position:
         strip_silence_begin = False
-
-    sample_rate = sample_rate or streamdetails.sample_rate
-    bit_depth = bit_depth or streamdetails.bit_depth
     # chunk size = 2 seconds of pcm audio
-    pcm_sample_size = int(sample_rate * (bit_depth / 8) * 2)
+    pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
     chunk_size = pcm_sample_size * (1 if is_radio else 2)
     expected_chunks = int((streamdetails.duration or 0) / 2)
     if expected_chunks < 60:
@@ -408,8 +409,7 @@ async def get_media_stream(
     seek_pos = seek_position if (streamdetails.direct or not streamdetails.can_seek) else 0
     args = await _get_ffmpeg_args(
         streamdetails=streamdetails,
-        sample_rate=sample_rate,
-        bit_depth=bit_depth,
+        pcm_output_format=pcm_format,
         # only use ffmpeg seeking if the provider stream does not support seeking
         seek_position=seek_pos,
         fade_in=fade_in,
@@ -445,8 +445,8 @@ async def get_media_stream(
                     stripped_audio = await strip_silence(
                         mass,
                         prev_chunk + chunk,
-                        sample_rate=sample_rate,
-                        bit_depth=bit_depth,
+                        sample_rate=pcm_format.sample_rate,
+                        bit_depth=pcm_format.bit_depth,
                     )
                     yield stripped_audio
                     bytes_sent += len(stripped_audio)
@@ -470,8 +470,8 @@ async def get_media_stream(
                 stripped_audio = await strip_silence(
                     mass,
                     prev_chunk,
-                    sample_rate=sample_rate,
-                    bit_depth=bit_depth,
+                    sample_rate=pcm_format.sample_rate,
+                    bit_depth=pcm_format.bit_depth,
                     reverse=True,
                 )
                 yield stripped_audio
@@ -597,7 +597,7 @@ async def get_file_stream(
     if not streamdetails.size:
         stat = await asyncio.to_thread(os.stat, filename)
         streamdetails.size = stat.st_size
-    chunk_size = get_chunksize(streamdetails.content_type)
+    chunk_size = get_chunksize(streamdetails.audio_format.content_type)
     async with aiofiles.open(streamdetails.data, "rb") as _file:
         if seek_position:
             seek_pos = int((streamdetails.size / streamdetails.duration) * seek_position)
@@ -649,8 +649,8 @@ async def get_preview_stream(
         input_args += ["-ss", "30", "-i", streamdetails.direct]
     else:
         # the input is received from pipe/stdin
-        if streamdetails.content_type != ContentType.UNKNOWN:
-            input_args += ["-f", streamdetails.content_type]
+        if streamdetails.audio_format.content_type != ContentType.UNKNOWN:
+            input_args += ["-f", streamdetails.audio_format.content_type]
         input_args += ["-i", "-"]
 
     output_args = ["-to", "30", "-f", "mp3", "-"]
@@ -675,23 +675,25 @@ async def get_preview_stream(
 
 async def get_silence(
     duration: int,
-    output_fmt: ContentType = ContentType.WAV,
-    sample_rate: int = 44100,
-    bit_depth: int = 16,
+    output_format: AudioFormat,
 ) -> AsyncGenerator[bytes, None]:
     """Create stream of silence, encoded to format of choice."""
-    # wav silence = just zero's
-    if output_fmt == ContentType.WAV:
+    if output_format.content_type.is_pcm():
+        # pcm = just zeros
+        for _ in range(0, duration):
+            yield b"\0" * int(output_format.sample_rate * (output_format.bit_depth / 8) * 2)
+        return
+    if output_format.content_type == ContentType.WAV:
+        # wav silence = wave header + zero's
         yield create_wave_header(
-            samplerate=sample_rate,
+            samplerate=output_format.sample_rate,
             channels=2,
-            bitspersample=bit_depth,
+            bitspersample=output_format.bit_depth,
             duration=duration,
         )
         for _ in range(0, duration):
-            yield b"\0" * int(sample_rate * (bit_depth / 8) * 2)
+            yield b"\0" * int(output_format.sample_rate * (output_format.bit_depth / 8) * 2)
         return
-
     # use ffmpeg for all other encodings
     args = [
         "ffmpeg",
@@ -701,11 +703,11 @@ async def get_silence(
         "-f",
         "lavfi",
         "-i",
-        f"anullsrc=r={sample_rate}:cl={'stereo'}",
+        f"anullsrc=r={output_format.sample_rate}:cl={'stereo'}",
         "-t",
         str(duration),
         "-f",
-        output_fmt,
+        output_format.output_fmt.value,
         "-",
     ]
     async with AsyncProcess(args) as ffmpeg_proc:
@@ -734,14 +736,11 @@ def get_chunksize(
 
 async def _get_ffmpeg_args(
     streamdetails: StreamDetails,
-    sample_rate: int,
-    bit_depth: int,
+    pcm_output_format: AudioFormat,
     seek_position: int = 0,
     fade_in: bool = False,
 ) -> list[str]:
     """Collect all args to send to the ffmpeg process."""
-    input_format = streamdetails.content_type
-
     ffmpeg_present, libsoxr_support, version = await check_audio_support()
 
     if not ffmpeg_present:
@@ -763,7 +762,12 @@ async def _get_ffmpeg_args(
         "file,http,https,tcp,tls,crypto,pipe,fd",  # support nested protocols (e.g. within playlist)
     ]
     # collect input args
-    input_args = []
+    input_args = [
+        "-ac",
+        str(streamdetails.audio_format.channels),
+        "-channel_layout",
+        "mono" if streamdetails.audio_format.channels == 1 else "stereo",
+    ]
     if seek_position:
         input_args += ["-ss", str(seek_position)]
     if streamdetails.direct:
@@ -790,21 +794,23 @@ async def _get_ffmpeg_args(
         input_args += ["-i", streamdetails.direct]
     else:
         # the input is received from pipe/stdin
-        if streamdetails.content_type != ContentType.UNKNOWN:
-            input_args += ["-f", input_format]
-        input_args += ["-i", "-"]
+        if streamdetails.audio_format.content_type != ContentType.UNKNOWN:
+            input_args += ["-f", streamdetails.audio_format.content_type.value]
+        input_args += [
+            "-i",
+            "-",
+        ]
 
-    pcm_output_format = ContentType.from_bit_depth(bit_depth)
     # collect output args
     output_args = [
         "-acodec",
-        pcm_output_format.name.lower(),
+        pcm_output_format.content_type.name.lower(),
         "-f",
-        pcm_output_format,
+        pcm_output_format.content_type.value,
         "-ac",
-        "2",  # to simplify things, we always output 2 channels
+        str(pcm_output_format.channels),
         "-ar",
-        str(sample_rate),
+        str(pcm_output_format.sample_rate),
         "-",
     ]
     # collect extra and filter args
@@ -813,7 +819,7 @@ async def _get_ffmpeg_args(
     if streamdetails.gain_correct is not None:
         filter_params.append(f"volume={streamdetails.gain_correct}dB")
     if (
-        streamdetails.sample_rate != sample_rate
+        streamdetails.audio_format.sample_rate != pcm_output_format.sample_rate
         and libsoxr_support
         and streamdetails.media_type == MediaType.TRACK
     ):
index 197b136bf48f202c408fbb948ecf0a015720c537..aaf1ff709235a925afde16cb6665d234d0e3b7a1 100644 (file)
@@ -30,18 +30,18 @@ class AuthenticationHelper:
     @property
     def callback_url(self) -> str:
         """Return the callback URL."""
-        return f"{self.mass.webserver.base_url}/callback/{self.session_id}"
+        return f"{self.mass.streams.base_url}/callback/{self.session_id}"
 
     async def __aenter__(self) -> AuthenticationHelper:
         """Enter context manager."""
-        self.mass.webserver.register_route(
+        self.mass.streams.register_dynamic_route(
             f"/callback/{self.session_id}", self._handle_callback, "GET"
         )
         return self
 
     async def __aexit__(self, exc_type, exc_value, traceback) -> bool:
         """Exit context manager."""
-        self.mass.webserver.unregister_route(f"/callback/{self.session_id}", "GET")
+        self.mass.streams.unregister_dynamic_route(f"/callback/{self.session_id}", "GET")
 
     async def authenticate(self, auth_url: str, timeout: int = 60) -> dict[str, str]:
         """Start the auth process and return any query params if received on the callback."""
index 744e39f632a655e1032bb0eabb8172a81fa2c7c8..b537d4a4032b2f79235bc04b46c0ac7ec60ca14f 100644 (file)
@@ -15,48 +15,44 @@ if TYPE_CHECKING:
 
 
 def create_didl_metadata(
-    mass: MusicAssistant, url: str, queue_item: QueueItem, flow_mode: bool = False
+    mass: MusicAssistant, url: str, queue_item: QueueItem | None = None
 ) -> str:
-    """Create DIDL metadata string from url and QueueItem."""
-    ext = url.split(".")[-1]
-    is_radio = queue_item.media_type != MediaType.TRACK or not queue_item.duration
-    image_url = mass.metadata.get_image_url(queue_item.image) if queue_item.image else ""
-
-    if flow_mode:
+    """Create DIDL metadata string from url and (optional) QueueItem."""
+    ext = url.split(".")[-1].split("?")[0]
+    if queue_item is None:
         return (
             '<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"
@@ -71,18 +67,19 @@ def create_didl_metadata(
         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("&", "&amp;")
+    # data = data.replace("?", "&#63;")
     data = data.replace(">", "&gt;")
     data = data.replace("<", "&lt;")
     return data
index 6d8476fdacd52c3c454ba6fd5a67fcc87ba4b285..9e5763139afd1fe6bc03715491c1e3b6673cf62f 100644 (file)
@@ -13,7 +13,6 @@ from contextlib import suppress
 LOGGER = logging.getLogger(__name__)
 
 DEFAULT_CHUNKSIZE = 128000
-DEFAULT_TIMEOUT = 60
 
 # pylint: disable=invalid-name
 
@@ -91,23 +90,21 @@ class AsyncProcess:
                 break
             yield chunk
 
-    async def readexactly(self, n: int, timeout: int = DEFAULT_TIMEOUT) -> bytes:
+    async def readexactly(self, n: int) -> bytes:
         """Read exactly n bytes from the process stdout (or less if eof)."""
         try:
-            async with asyncio.timeout(timeout):
-                return await self._proc.stdout.readexactly(n)
+            return await self._proc.stdout.readexactly(n)
         except asyncio.IncompleteReadError as err:
             return err.partial
 
-    async def read(self, n: int, timeout: int = DEFAULT_TIMEOUT) -> bytes:
+    async def read(self, n: int) -> bytes:
         """Read up to n bytes from the stdout stream.
 
         If n is positive, this function try to read n bytes,
         and may return less or equal bytes than requested, but at least one byte.
         If EOF was received before any byte is read, this function returns empty byte object.
         """
-        async with asyncio.timeout(timeout):
-            return await self._proc.stdout.read(n)
+        return await self._proc.stdout.read(n)
 
     async def write(self, data: bytes) -> None:
         """Write data to process stdin."""
diff --git a/music_assistant/server/helpers/webserver.py b/music_assistant/server/helpers/webserver.py
new file mode 100644 (file)
index 0000000..b41663f
--- /dev/null
@@ -0,0 +1,118 @@
+"""Base Webserver logic for an HTTPServer that can handle dynamic routes."""
+from __future__ import annotations
+
+import logging
+from collections.abc import Awaitable, Callable
+
+from aiohttp import web
+
+
+class Webserver:
+    """Base Webserver logic for an HTTPServer that can handle dynamic routes."""
+
+    def __init__(
+        self,
+        logger: logging.Logger,
+        enable_dynamic_routes: bool = False,
+    ):
+        """Initialize instance."""
+        self.logger = logger
+        # the below gets initialized in async setup
+        self._apprunner: web.AppRunner | None = None
+        self._webapp: web.Application | None = None
+        self._tcp_site: web.TCPSite | None = None
+        self._static_routes: list[tuple[str, str, Awaitable]] | None = None
+        self._dynamic_routes: dict[str, Callable] | None = {} if enable_dynamic_routes else None
+        self._bind_port: int | None = None
+
+    async def setup(
+        self,
+        bind_ip: str | None,
+        bind_port: int,
+        base_url: str,
+        static_routes: list[tuple[str, str, Awaitable]] | None = None,
+        static_content: tuple[str, str, str] | None = None,
+    ) -> None:
+        """Async initialize of module."""
+        self._base_url = base_url[:-1] if base_url.endswith("/") else base_url
+        self._bind_port = bind_port
+        self._static_routes = static_routes
+        self._webapp = web.Application(logger=self.logger)
+        self.logger.info("Starting server on  %s:%s", bind_ip, bind_port)
+        self._apprunner = web.AppRunner(self._webapp, access_log=None)
+        # add static routes
+        if self._static_routes:
+            for method, path, handler in self._static_routes:
+                self._webapp.router.add_route(method, path, handler)
+        if static_content:
+            self._webapp.router.add_static(
+                static_content[0], static_content[1], name=static_content[2]
+            )
+        # register catch-all route to handle dynamic routes (if enabled)
+        if self._dynamic_routes is not None:
+            self._webapp.router.add_route("*", "/{tail:.*}", self._handle_catch_all)
+        await self._apprunner.setup()
+        # set host to None to bind to all addresses on both IPv4 and IPv6
+        host = None if bind_ip == "0.0.0.0" else bind_ip
+        self._tcp_site = web.TCPSite(self._apprunner, host=host, port=bind_port)
+        await self._tcp_site.start()
+
+    async def close(self) -> None:
+        """Cleanup on exit."""
+        # stop/clean webserver
+        await self._tcp_site.stop()
+        await self._apprunner.cleanup()
+        await self._webapp.shutdown()
+        await self._webapp.cleanup()
+
+    @property
+    def base_url(self):
+        """Return the base URL of this webserver."""
+        return self._base_url
+
+    @property
+    def port(self):
+        """Return the port of this webserver."""
+        return self._bind_port
+
+    def register_dynamic_route(self, path: str, handler: Awaitable, method: str = "*") -> Callable:
+        """Register a dynamic route on the webserver, returns handler to unregister."""
+        if self._dynamic_routes is None:
+            raise RuntimeError("Dynamic routes are not enabled")
+        key = f"{method}.{path}"
+        if key in self._dynamic_routes:
+            raise RuntimeError(f"Route {path} already registered.")
+        self._dynamic_routes[key] = handler
+
+        def _remove():
+            return self._dynamic_routes.pop(key)
+
+        return _remove
+
+    def unregister_dynamic_route(self, path: str, method: str = "*") -> None:
+        """Unregister a dynamic route from the webserver."""
+        if self._dynamic_routes is None:
+            raise RuntimeError("Dynamic routes are not enabled")
+        key = f"{method}.{path}"
+        self._dynamic_routes.pop(key)
+
+    async def serve_static(self, file_path: str, _request: web.Request) -> web.FileResponse:
+        """Serve file response."""
+        headers = {"Cache-Control": "no-cache"}
+        return web.FileResponse(file_path, headers=headers)
+
+    async def _handle_catch_all(self, request: web.Request) -> web.Response:
+        """Redirect request to correct destination."""
+        # find handler for the request
+        for key in (f"{request.method}.{request.path}", f"*.{request.path}"):
+            if handler := self._dynamic_routes.get(key):
+                return await handler(request)
+        # deny all other requests
+        self.logger.debug(
+            "Received unhandled %s request to %s from %s\nheaders: %s\n",
+            request.method,
+            request.path,
+            request.remote,
+            request.headers,
+        )
+        return web.Response(status=404)
diff --git a/music_assistant/server/models/core_controller.py b/music_assistant/server/models/core_controller.py
new file mode 100644 (file)
index 0000000..056f06a
--- /dev/null
@@ -0,0 +1,49 @@
+"""Model/base for a Core controller within Music Assistant."""
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from music_assistant.constants import CONF_LOG_LEVEL, ROOT_LOGGER_NAME
+
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
+    from music_assistant.server import MusicAssistant
+
+
+class CoreController:
+    """Base representation of a Core controller within Music Assistant."""
+
+    name: str
+    friendly_name: str
+
+    def __init__(self, mass: MusicAssistant) -> None:
+        """Initialize MusicProvider."""
+        self.mass = mass
+        self.logger = logging.getLogger(f"{ROOT_LOGGER_NAME}.{self.name}")
+        log_level = self.mass.config.get_raw_core_config_value(self.name, CONF_LOG_LEVEL, "GLOBAL")
+        if log_level != "GLOBAL":
+            self.logger.setLevel(log_level)
+
+    async def get_config_entries(
+        self,
+        action: str | None = None,  # noqa: ARG002
+        values: dict[str, ConfigValueType] | None = None,  # noqa: ARG002
+    ) -> tuple[ConfigEntry, ...]:
+        """Return all Config Entries for this core module (if any)."""
+        return tuple()
+
+    async def setup(self) -> None:
+        """Async initialize of module."""
+
+    async def close(self) -> None:
+        """Handle logic on server stop."""
+
+    async def reload(self) -> None:
+        """Reload this core controller."""
+        await self.close()
+        log_level = self.mass.config.get_raw_core_config_value(self.name, CONF_LOG_LEVEL, "GLOBAL")
+        if log_level == "GLOBAL":
+            log_level = logging.getLogger(ROOT_LOGGER_NAME).level
+        self.logger.setLevel(log_level)
+        await self.setup()
index 6127da977c9f133a443cb117f1d9391ffac9ed2d..258a26f6fd97dd4fc077f515d6ef61059418ece4 100644 (file)
@@ -11,6 +11,7 @@ from .provider import Provider
 
 if TYPE_CHECKING:
     from music_assistant.common.models.config_entries import ConfigEntry, PlayerConfig
+    from music_assistant.server.controllers.streams import MultiClientStreamJob
 
 # ruff: noqa: ARG001, ARG002
 
@@ -53,27 +54,38 @@ class PlayerProvider(Provider):
         """
 
     @abstractmethod
-    async def cmd_play_media(
+    async def cmd_play_url(
         self,
         player_id: str,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
-        flow_mode: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
     ) -> None:
-        """Send PLAY MEDIA command to given player.
+        """Send PLAY URL command to given player.
 
-        This is called when the Queue wants the player to start playing a specific QueueItem.
-        The player implementation can decide how to process the request, such as playing
-        queue items one-by-one or enqueue all/some items.
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
 
             - player_id: player_id of the player to handle the command.
-            - queue_item: the QueueItem to start playing on the player.
-            - seek_position: start playing from this specific position.
-            - fade_in: fade in the music at start (e.g. at resume).
-            - flow_mode: enable flow mode where the queue tracks are streamed as continuous stream.
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
         """
 
+    async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None:
+        """Handle StreamJob play command on given player.
+
+        This is called when the Queue wants the player to start playing media
+        to multiple subscribers at the same time using a MultiClientStreamJob.
+        The default implementation is that the URL to the stream is resolved for the player
+        and played like any regular play_url command, but implementation may override
+        this behavior for any more sophisticated handling (e.g. when syncing etc.)
+
+            - player_id: player_id of the player to handle the command.
+            - stream_job: the MultiClientStreamJob that the player should start playing.
+        """
+        url = await stream_job.resolve_stream_url(player_id)
+        await self.cmd_play_url(player_id=player_id, url=url, queue_item=None)
+
     async def cmd_power(self, player_id: str, powered: bool) -> None:
         """Send POWER command to given player.
 
index 53ff7e04fab1ac5e979075f86bb08246dc738687..6e92d7ac42ff547c9654cb1c74e955e3bf713910 100644 (file)
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
     from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig
     from music_assistant.common.models.provider import ProviderManifest
     from music_assistant.server import MusicAssistant
+    from music_assistant.server.controllers.streams import MultiClientStreamJob
     from music_assistant.server.models import ProviderInstanceType
     from music_assistant.server.providers.slimproto import SlimprotoProvider
 
@@ -171,25 +172,36 @@ class AirplayProvider(PlayerProvider):
         slimproto_prov = self.mass.get_provider("slimproto")
         await slimproto_prov.cmd_play(player_id)
 
-    async def cmd_play_media(
+    async def cmd_play_url(
         self,
         player_id: str,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
-        flow_mode: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
     ) -> None:
-        """Send PLAY MEDIA command to given player."""
+        """Send PLAY URL command to given player.
+
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
+
+            - player_id: player_id of the player to handle the command.
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
+        """
         # simply forward to underlying slimproto player
         slimproto_prov = self.mass.get_provider("slimproto")
-        await slimproto_prov.cmd_play_media(
+        await slimproto_prov.cmd_play_url(
             player_id,
+            url=url,
             queue_item=queue_item,
-            seek_position=seek_position,
-            fade_in=fade_in,
-            flow_mode=flow_mode,
         )
 
+    async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None:
+        """Handle StreamJob play command on given player."""
+        # simply forward to underlying slimproto player
+        slimproto_prov = self.mass.get_provider("slimproto")
+        await slimproto_prov.cmd_handle_stream_job(player_id=player_id, stream_job=stream_job)
+
     async def cmd_pause(self, player_id: str) -> None:
         """Send PAUSE command to given player."""
         # simply forward to underlying slimproto player
index d6f04db9ab3047a58069090b9237f6c30da5c661..3cd7f69b2800de29e7c92a5fe2599394a1d450c3 100644 (file)
@@ -103,8 +103,7 @@ class CastPlayer:
     logger: Logger
     status_listener: CastStatusListener | None = None
     mz_controller: MultizoneController | None = None
-    next_item: str | None = None
-    flow_mode_active: bool = False
+    next_url: str | None = None
     active_group: str | None = None
 
 
@@ -185,36 +184,35 @@ class ChromecastProvider(PlayerProvider):
         castplayer = self.castplayers[player_id]
         await asyncio.to_thread(castplayer.cc.media_controller.play)
 
-    async def cmd_play_media(
+    async def cmd_play_url(
         self,
         player_id: str,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
-        flow_mode: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
     ) -> None:
-        """Send PLAY MEDIA command to given player."""
+        """Send PLAY URL command to given player.
+
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
+
+            - player_id: player_id of the player to handle the command.
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
+        """
         castplayer = self.castplayers[player_id]
-        url = await self.mass.streams.resolve_stream_url(
-            queue_item=queue_item,
-            player_id=player_id,
-            seek_position=seek_position,
-            fade_in=fade_in,
-            flow_mode=flow_mode,
-        )
-        castplayer.flow_mode_active = flow_mode
 
-        # in flow mode, we just send the url and the metadata is of no use
-        if flow_mode:
+        # in flow/direct url mode, we just send the url and the metadata is of no use
+        if not queue_item:
             await asyncio.to_thread(
                 castplayer.cc.play_media,
                 url,
-                content_type=f"audio/{url.split('.')[-1]}",
+                content_type=f'audio/{url.split(".")[-1].split("?")[0]}',
                 title="Music Assistant",
                 thumb=MASS_LOGO_ONLINE,
                 media_info={
                     "customData": {
-                        "queue_item_id": queue_item.queue_item_id,
+                        "queue_item_id": "flow",
                     }
                 },
             )
@@ -232,7 +230,7 @@ class ChromecastProvider(PlayerProvider):
         # make sure that media controller app is launched
         await self._launch_app(castplayer)
         # send queue info to the CC
-        castplayer.next_item = None
+        castplayer.next_url = None
         media_controller = castplayer.cc.media_controller
         await asyncio.to_thread(media_controller.send_message, queuedata, True)
 
@@ -403,13 +401,13 @@ class ChromecastProvider(PlayerProvider):
         # handle stereo pairs
         if castplayer.cast_info.is_multichannel_group:
             castplayer.player.type = PlayerType.STEREO_PAIR
-            castplayer.player.group_childs = []
+            castplayer.player.group_childs = set()
         # handle cast groups
         if castplayer.cast_info.is_audio_group and not castplayer.cast_info.is_multichannel_group:
             castplayer.player.type = PlayerType.GROUP
-            castplayer.player.group_childs = [
+            castplayer.player.group_childs = {
                 str(UUID(x)) for x in castplayer.mz_controller.members
-            ]
+            }
             castplayer.player.supported_features = (
                 PlayerFeature.POWER,
                 PlayerFeature.VOLUME_SET,
@@ -421,7 +419,6 @@ class ChromecastProvider(PlayerProvider):
     def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus):
         """Handle updated MediaStatus."""
         castplayer.logger.debug("Received media status update: %s", status.player_state)
-        prev_item_id = castplayer.player.current_item_id
         # player state
         if status.player_is_playing:
             castplayer.player.state = PlayerState.PLAYING
@@ -438,20 +435,14 @@ class ChromecastProvider(PlayerProvider):
             castplayer.player.elapsed_time = status.current_time
 
         # current media
-        queue_item_id = status.media_custom_data.get("queue_item_id")
-        castplayer.player.current_item_id = queue_item_id
         castplayer.player.current_url = status.content_id
         self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id)
 
         # enqueue next item if needed
         if castplayer.player.state == PlayerState.PLAYING and (
-            prev_item_id != castplayer.player.current_item_id
-            or not castplayer.next_item
-            or castplayer.next_item == castplayer.player.current_item_id
+            not castplayer.next_url or castplayer.next_url == castplayer.player.current_url
         ):
-            asyncio.run_coroutine_threadsafe(
-                self._enqueue_next_track(castplayer, queue_item_id), self.mass.loop
-            )
+            asyncio.run_coroutine_threadsafe(self._enqueue_next_track(castplayer), self.mass.loop)
 
     def on_new_connection_status(self, castplayer: CastPlayer, status: ConnectionStatus) -> None:
         """Handle updated ConnectionStatus."""
@@ -485,37 +476,36 @@ class ChromecastProvider(PlayerProvider):
 
     ### Helpers / utils
 
-    async def _enqueue_next_track(self, castplayer: CastPlayer, current_queue_item_id: str) -> None:
+    async def _enqueue_next_track(self, castplayer: CastPlayer) -> None:
         """Enqueue the next track of the MA queue on the CC queue."""
-        if castplayer.flow_mode_active:
-            # not possible when we're in flow mode
-            return
-
-        if not current_queue_item_id:
-            return  # guard
         try:
-            next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track(
-                castplayer.player_id, current_queue_item_id
+            next_url, next_item, _ = await self.mass.players.queues.preload_next_url(
+                castplayer.player_id
             )
         except QueueEmpty:
             return
 
-        if castplayer.next_item == next_item.queue_item_id:
+        if castplayer.next_url == next_url:
             return  # already set ?!
-        castplayer.next_item = next_item.queue_item_id
+        castplayer.next_url = next_url
 
-        if crossfade:
-            self.logger.warning(
-                "Crossfade requested but Chromecast does not support crossfading,"
-                " consider using flow mode to enable crossfade on a Chromecast."
+        # in flow/direct url mode, we just send the url and the metadata is of no use
+        if not next_item:
+            await asyncio.to_thread(
+                castplayer.cc.play_media,
+                next_url,
+                content_type=f'audio/{next_url.split(".")[-1].split("?")[0]}',
+                title="Music Assistant",
+                thumb=MASS_LOGO_ONLINE,
+                enqueue=True,
+                media_info={
+                    "customData": {
+                        "queue_item_id": "flow",
+                    }
+                },
             )
-
-        url = await self.mass.streams.resolve_stream_url(
-            queue_item=next_item,
-            player_id=castplayer.player_id,
-            auto_start_runner=False,
-        )
-        cc_queue_items = [self._create_queue_item(next_item, url)]
+            return
+        cc_queue_items = [self._create_queue_item(next_item, next_url)]
 
         queuedata = {
             "type": "QUEUE_INSERT",
index bd1da036abf669f6a19a26b1c5aeae9ff8d362fc..c85362996ef06275b99b5554f7ffccafa64cec82 100644 (file)
@@ -26,6 +26,7 @@ from music_assistant.common.models.errors import LoginFailed
 from music_assistant.common.models.media_items import (
     Album,
     Artist,
+    AudioFormat,
     BrowseFolder,
     ItemMapping,
     MediaItemImage,
@@ -378,7 +379,9 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
         return StreamDetails(
             item_id=item_id,
             provider=self.instance_id,
-            content_type=ContentType.try_parse(url_details["format"].split("_")[0]),
+            audio_format=AudioFormat(
+                content_type=ContentType.try_parse(url_details["format"].split("_")[0])
+            ),
             duration=int(song_data["DURATION"]),
             data=url,
             expires=url_details["exp"],
index 13950dbe59fd86720da2562be6e72fca77f5fb12..9f44d4d81e10499270a44c469c8068c9337fb30e 100644 (file)
@@ -119,7 +119,8 @@ class DLNAPlayer:
     # Track BOOTID in SSDP advertisements for device changes
     bootid: int | None = None
     last_seen: float = field(default_factory=time.time)
-    next_item: str | None = None
+    next_url: str | None = None
+    next_item: QueueItem | None = None
     supports_next_uri = True
     end_of_track_reached = False
 
@@ -140,7 +141,6 @@ class DLNAPlayer:
                 self.player.elapsed_time_last_updated = (
                     self.device.media_position_updated_at.timestamp()
                 )
-            self.player.current_item_id = self.device._get_current_track_meta_data("queue_item_id")
             if self.device.media_duration and self.player.corrected_elapsed_time:
                 self.end_of_track_reached = (
                     self.device.media_duration - self.player.corrected_elapsed_time
@@ -224,7 +224,7 @@ class DLNAPlayerProvider(PlayerProvider):
 
         Called when provider is deregistered (e.g. MA exiting or config reloading).
         """
-        self.mass.webserver.unregister_route("/notify", "NOTIFY")
+        self.mass.streams.unregister_dynamic_route("/notify", "NOTIFY")
         async with asyncio.TaskGroup() as tg:
             for dlna_player in self.dlnaplayers.values():
                 tg.create_task(self._device_disconnect(dlna_player))
@@ -241,7 +241,7 @@ class DLNAPlayerProvider(PlayerProvider):
         """Send STOP command to given player."""
         dlna_player = self.dlnaplayers[player_id]
         dlna_player.end_of_track_reached = False
-        dlna_player.next_item = None
+        dlna_player.next_url = None
         assert dlna_player.device is not None
         await dlna_player.device.async_stop()
 
@@ -253,29 +253,29 @@ class DLNAPlayerProvider(PlayerProvider):
         await dlna_player.device.async_play()
 
     @catch_request_errors
-    async def cmd_play_media(
+    async def cmd_play_url(
         self,
         player_id: str,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
-        flow_mode: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
     ) -> None:
-        """Send PLAY MEDIA command to given player."""
+        """Send PLAY URL command to given player.
+
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
+
+            - player_id: player_id of the player to handle the command.
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
+        """
         dlna_player = self.dlnaplayers[player_id]
 
         # always clear queue (by sending stop) first
         if dlna_player.device.can_stop:
             await self.cmd_stop(player_id)
-        url = await self.mass.streams.resolve_stream_url(
-            queue_item=queue_item,
-            player_id=dlna_player.udn,
-            seek_position=seek_position,
-            fade_in=fade_in,
-            flow_mode=flow_mode,
-        )
 
-        didl_metadata = create_didl_metadata(self.mass, url, queue_item, flow_mode)
+        didl_metadata = create_didl_metadata(self.mass, url, queue_item)
         await dlna_player.device.async_set_transport_uri(url, queue_item.name, didl_metadata)
         # Play it
         await dlna_player.device.async_wait_for_can_play(10)
@@ -531,58 +531,46 @@ class DLNAPlayerProvider(PlayerProvider):
         dlna_player.last_seen = time.time()
         self.mass.create_task(self._update_player(dlna_player))
 
-    async def _enqueue_next_track(
-        self, dlna_player: DLNAPlayer, current_queue_item_id: str
-    ) -> None:
+    async def _enqueue_next_track(self, dlna_player: DLNAPlayer) -> None:
         """Enqueue the next track of the MA queue on the CC queue."""
-        if not current_queue_item_id:
-            return  # guard
-        if not self.mass.players.queues.get_item(dlna_player.udn, current_queue_item_id):
-            return  # guard
         try:
-            next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track(
-                dlna_player.udn, current_queue_item_id
-            )
+            (
+                next_url,
+                next_item,
+                _,
+            ) = await self.mass.players.queues.preload_next_url(dlna_player.udn)
         except QueueEmpty:
             return
 
-        if dlna_player.next_item == next_item.queue_item_id:
+        if dlna_player.next_url == next_url:
             return  # already set ?!
-        dlna_player.next_item = next_item.queue_item_id
+        dlna_player.next_url = next_url
+        dlna_player.next_item = next_item
 
         # no need to try setting the next url if we already know the player does not support it
         if not dlna_player.supports_next_uri:
             return
 
         # send queue item to dlna queue
-        url = await self.mass.streams.resolve_stream_url(
-            queue_item=next_item,
-            player_id=dlna_player.udn,
-            # DLNA pre-caches pretty aggressively so do not yet start the runner
-            auto_start_runner=False,
-        )
-        didl_metadata = create_didl_metadata(self.mass, url, next_item)
+        didl_metadata = create_didl_metadata(self.mass, next_url, next_item)
+        title = next_item.name if next_item else "Music Assistant"
         try:
-            await dlna_player.device.async_set_next_transport_uri(
-                url, next_item.name, didl_metadata
-            )
+            await dlna_player.device.async_set_next_transport_uri(next_url, title, didl_metadata)
         except UpnpError:
             dlna_player.supports_next_uri = False
             self.logger.info("Player does not support next uri")
 
         self.logger.debug(
             "Enqued next track (%s) to player %s",
-            next_item.name,
+            title,
             dlna_player.player.display_name,
         )
 
     async def _update_player(self, dlna_player: DLNAPlayer) -> None:
         """Update DLNA Player."""
-        prev_item_id = dlna_player.player.current_item_id
         prev_url = dlna_player.player.current_url
         prev_state = dlna_player.player.state
         dlna_player.update_attributes()
-        current_item_id = dlna_player.player.current_item_id
         current_url = dlna_player.player.current_url
         current_state = dlna_player.player.state
 
@@ -595,19 +583,17 @@ class DLNAPlayerProvider(PlayerProvider):
 
         # enqueue next item if needed
         if dlna_player.player.state == PlayerState.PLAYING and (
-            prev_item_id != current_item_id
-            or not dlna_player.next_item
-            or dlna_player.next_item == current_item_id
+            not dlna_player.next_url or dlna_player.next_url == current_url
         ):
-            self.mass.create_task(self._enqueue_next_track(dlna_player, current_item_id))
+            self.mass.create_task(self._enqueue_next_track(dlna_player))
         # if player does not support next uri, manual play it
         if (
             not dlna_player.supports_next_uri
             and prev_state == PlayerState.PLAYING
             and current_state == PlayerState.IDLE
-            and dlna_player.next_item
+            and dlna_player.next_url
             and dlna_player.end_of_track_reached
         ):
-            await self.mass.players.queues.play_index(dlna_player.udn, dlna_player.next_item)
+            await self.cmd_play_url(dlna_player.udn, dlna_player.next_url, dlna_player.next_item)
             dlna_player.end_of_track_reached = False
-            dlna_player.next_item = None
+            dlna_player.next_url = None
index bc3b9114f079c2800aae6a03a1c289836178f1eb..0cbe74b918fcbc43a45b04702ca761cc45d63a88 100644 (file)
@@ -23,7 +23,7 @@ class DLNANotifyServer(UpnpNotifyServer):
         """Initialize."""
         self.mass = mass
         self.event_handler = UpnpEventHandler(self, requester)
-        self.mass.webserver.register_route("/notify", self._handle_request, method="NOTIFY")
+        self.mass.streams.register_dynamic_route("/notify", self._handle_request, method="NOTIFY")
 
     async def _handle_request(self, request: Request) -> Response:
         """Handle incoming requests."""
@@ -40,4 +40,4 @@ class DLNANotifyServer(UpnpNotifyServer):
     @property
     def callback_url(self) -> str:
         """Return callback URL on which we are callable."""
-        return f"{self.mass.webserver.base_url}/notify"
+        return f"{self.mass.streams.base_url}/notify"
index 52c2fe8e729df54b80248bc486eaf64ced4247ba..09631a84d876d0e5832bc47f0c7847367cdeed6b 100644 (file)
@@ -119,25 +119,26 @@ class FanartTvMetadataProvider(MetadataProvider):
         """Get data from api."""
         url = f"http://webservice.fanart.tv/v3/{endpoint}"
         kwargs["api_key"] = app_var(4)
-        async with self.throttler:
-            async with self.mass.http_session.get(url, params=kwargs, ssl=False) as response:
-                try:
-                    result = await response.json()
-                except (
-                    aiohttp.client_exceptions.ContentTypeError,
-                    JSONDecodeError,
-                ):
-                    self.logger.error("Failed to retrieve %s", endpoint)
-                    text_result = await response.text()
-                    self.logger.debug(text_result)
-                    return None
-                except (
-                    aiohttp.client_exceptions.ClientConnectorError,
-                    aiohttp.client_exceptions.ServerDisconnectedError,
-                ):
-                    self.logger.warning("Failed to retrieve %s", endpoint)
-                    return None
-                if "error" in result and "limit" in result["error"]:
-                    self.logger.warning(result["error"])
-                    return None
-                return result
+        async with self.throttler, self.mass.http_session.get(
+            url, params=kwargs, ssl=False
+        ) as response:
+            try:
+                result = await response.json()
+            except (
+                aiohttp.client_exceptions.ContentTypeError,
+                JSONDecodeError,
+            ):
+                self.logger.error("Failed to retrieve %s", endpoint)
+                text_result = await response.text()
+                self.logger.debug(text_result)
+                return None
+            except (
+                aiohttp.client_exceptions.ClientConnectorError,
+                aiohttp.client_exceptions.ServerDisconnectedError,
+            ):
+                self.logger.warning("Failed to retrieve %s", endpoint)
+                return None
+            if "error" in result and "limit" in result["error"]:
+                self.logger.warning(result["error"])
+                return None
+            return result
index e42351496f110c7b61c1b61510c2f5b53bb8be6e..9c0f81bf2c33d915ecaff76904d0e8c8ecc1bcf2 100644 (file)
@@ -26,6 +26,7 @@ from music_assistant.common.models.errors import (
 from music_assistant.common.models.media_items import (
     Album,
     Artist,
+    AudioFormat,
     BrowseFolder,
     ContentType,
     ImageType,
@@ -560,14 +561,12 @@ class FileSystemProviderBase(MusicProvider):
         return StreamDetails(
             provider=self.instance_id,
             item_id=item_id,
-            content_type=prov_mapping.content_type,
+            audio_format=prov_mapping.audio_format,
             media_type=MediaType.TRACK,
             duration=db_item.duration,
             size=file_item.file_size,
-            sample_rate=prov_mapping.sample_rate,
-            bit_depth=prov_mapping.bit_depth,
             direct=file_item.local_path,
-            can_seek=prov_mapping.content_type in SEEKABLE_FILES,
+            can_seek=prov_mapping.audio_format.content_type in SEEKABLE_FILES,
         )
 
     async def get_audio_stream(
@@ -725,10 +724,12 @@ class FileSystemProviderBase(MusicProvider):
                 item_id=file_item.path,
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=ContentType.try_parse(tags.format),
-                sample_rate=tags.sample_rate,
-                bit_depth=tags.bits_per_sample,
-                bit_rate=tags.bit_rate,
+                audio_format=AudioFormat(
+                    content_type=ContentType.try_parse(tags.format),
+                    sample_rate=tags.sample_rate,
+                    bit_depth=tags.bits_per_sample,
+                    bit_rate=tags.bit_rate,
+                ),
             )
         )
         return track
index d49f6cbfb2b0022baf6982ab9972394743e53e3c..494394f5f28b0c1dbe0d1c73a3a62e09f0840c7a 100644 (file)
@@ -194,17 +194,16 @@ class MusicbrainzProvider(MetadataProvider):
         url = f"http://musicbrainz.org/ws/2/{endpoint}"
         headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/music-assistant"}
         kwargs["fmt"] = "json"  # type: ignore[assignment]
-        async with self.throttler:
-            async with self.mass.http_session.get(
-                url, headers=headers, params=kwargs, ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                except (
-                    aiohttp.client_exceptions.ContentTypeError,
-                    JSONDecodeError,
-                ) as exc:
-                    msg = await response.text()
-                    self.logger.warning("%s - %s", str(exc), msg)
-                    result = None
-                return result
+        async with self.throttler, self.mass.http_session.get(
+            url, headers=headers, params=kwargs, ssl=False
+        ) as response:
+            try:
+                result = await response.json()
+            except (
+                aiohttp.client_exceptions.ContentTypeError,
+                JSONDecodeError,
+            ) as exc:
+                msg = await response.text()
+                self.logger.warning("%s - %s", str(exc), msg)
+                result = None
+            return result
index 33fed19a73323530a279c81ef26e2da09537f5ee..82bcf72b8c7f30cb112625e040e7b4c3d2edbe5e 100644 (file)
@@ -35,6 +35,7 @@ from music_assistant.common.models.errors import InvalidDataError, LoginFailed,
 from music_assistant.common.models.media_items import (
     Album,
     Artist,
+    AudioFormat,
     ItemMapping,
     MediaItem,
     MediaItemChapter,
@@ -409,7 +410,9 @@ class PlexProvider(MusicProvider):
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
                 available=available,
-                content_type=ContentType.try_parse(content) if content else ContentType.UNKNOWN,
+                audio_format=AudioFormat(
+                    content_type=ContentType.try_parse(content) if content else ContentType.UNKNOWN,
+                ),
                 url=plex_track.getWebURL(),
             )
         )
@@ -585,9 +588,11 @@ class PlexProvider(MusicProvider):
         stream_details = StreamDetails(
             item_id=plex_track.key,
             provider=self.instance_id,
-            content_type=media_type,
+            audio_format=AudioFormat(
+                content_type=media_type,
+                channels=media.audioChannels,
+            ),
             duration=plex_track.duration,
-            channels=media.audioChannels,
             data=plex_track,
         )
 
@@ -597,18 +602,18 @@ class PlexProvider(MusicProvider):
         if media_type != ContentType.M4A:
             stream_details.direct = self._plex_server.url(media_part.key, True)
             if audio_stream.samplingRate:
-                stream_details.sample_rate = audio_stream.samplingRate
+                stream_details.audio_format.sample_rate = audio_stream.samplingRate
             if audio_stream.bitDepth:
-                stream_details.bit_depth = audio_stream.bitDepth
+                stream_details.audio_format.bit_depth = audio_stream.bitDepth
 
         else:
             url = plex_track.getStreamURL()
             media_info = await parse_tags(url)
 
-            stream_details.channels = media_info.channels
-            stream_details.content_type = ContentType.try_parse(media_info.format)
-            stream_details.sample_rate = media_info.sample_rate
-            stream_details.bit_depth = media_info.bits_per_sample
+            stream_details.audio_format.channels = media_info.channels
+            stream_details.audio_format.content_type = ContentType.try_parse(media_info.format)
+            stream_details.audio_format.sample_rate = media_info.sample_rate
+            stream_details.audio_format.bit_depth = media_info.bits_per_sample
 
         return stream_details
 
index 3d843b5c2110fdb07a9d4eb59f0eddd1ee780494..feff3ab3910f7e05aed7161f47501c18ae01fac7 100644 (file)
@@ -19,6 +19,7 @@ from music_assistant.common.models.media_items import (
     Album,
     AlbumType,
     Artist,
+    AudioFormat,
     ContentType,
     ImageType,
     MediaItemImage,
@@ -372,10 +373,12 @@ class QobuzProvider(MusicProvider):
         return StreamDetails(
             item_id=str(item_id),
             provider=self.instance_id,
-            content_type=content_type,
+            audio_format=AudioFormat(
+                content_type=content_type,
+                sample_rate=int(streamdata["sampling_rate"] * 1000),
+                bit_depth=streamdata["bit_depth"],
+            ),
             duration=streamdata["duration"],
-            sample_rate=int(streamdata["sampling_rate"] * 1000),
-            bit_depth=streamdata["bit_depth"],
             data=streamdata,  # we need these details for reporting playback
             expires=time.time() + 3600,  # not sure about the real allowed value
             direct=streamdata["url"],
@@ -457,9 +460,11 @@ class QobuzProvider(MusicProvider):
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
                 available=album_obj["streamable"] and album_obj["displayable"],
-                content_type=ContentType.FLAC,
-                sample_rate=album_obj["maximum_sampling_rate"] * 1000,
-                bit_depth=album_obj["maximum_bit_depth"],
+                audio_format=AudioFormat(
+                    content_type=ContentType.FLAC,
+                    sample_rate=album_obj["maximum_sampling_rate"] * 1000,
+                    bit_depth=album_obj["maximum_bit_depth"],
+                ),
                 url=album_obj.get("url", f'https://open.qobuz.com/album/{album_obj["id"]}'),
             )
         )
@@ -556,9 +561,11 @@ class QobuzProvider(MusicProvider):
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
                 available=track_obj["streamable"] and track_obj["displayable"],
-                content_type=ContentType.FLAC,
-                sample_rate=track_obj["maximum_sampling_rate"] * 1000,
-                bit_depth=track_obj["maximum_bit_depth"],
+                audio_format=AudioFormat(
+                    content_type=ContentType.FLAC,
+                    sample_rate=track_obj["maximum_sampling_rate"] * 1000,
+                    bit_depth=track_obj["maximum_bit_depth"],
+                ),
                 url=track_obj.get("url", f'https://open.qobuz.com/track/{track_obj["id"]}'),
             )
         )
@@ -655,29 +662,26 @@ class QobuzProvider(MusicProvider):
             kwargs["request_sig"] = request_sig
             kwargs["app_id"] = app_var(0)
             kwargs["user_auth_token"] = await self._auth_token()
-        async with self._throttler:
-            async with self.mass.http_session.get(
-                url, headers=headers, params=kwargs, ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                    # check for error in json
-                    if error := result.get("error"):
-                        raise ValueError(error)
-                    if result.get("status") and "error" in result["status"]:
-                        raise ValueError(result["status"])
-                except (
-                    aiohttp.ContentTypeError,
-                    JSONDecodeError,
-                    AssertionError,
-                    ValueError,
-                ) as err:
-                    text = await response.text()
-                    self.logger.exception(
-                        "Error while processing %s: %s", endpoint, text, exc_info=err
-                    )
-                    return None
-                return result
+        async with self._throttler, self.mass.http_session.get(
+            url, headers=headers, params=kwargs, ssl=False
+        ) as response:
+            try:
+                result = await response.json()
+                # check for error in json
+                if error := result.get("error"):
+                    raise ValueError(error)
+                if result.get("status") and "error" in result["status"]:
+                    raise ValueError(result["status"])
+            except (
+                aiohttp.ContentTypeError,
+                JSONDecodeError,
+                AssertionError,
+                ValueError,
+            ) as err:
+                text = await response.text()
+                self.logger.exception("Error while processing %s: %s", endpoint, text, exc_info=err)
+                return None
+            return result
 
     async def _post_data(self, endpoint, params=None, data=None):
         """Post data to api."""
index 36efa06f8b68963cba66f207b041ff86bcc7c33c..de32515183cfeb91d1874dfe4c2f23d592018ef9 100644 (file)
@@ -10,6 +10,7 @@ from radios import FilterBy, Order, RadioBrowser, RadioBrowserError
 from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
 from music_assistant.common.models.enums import LinkType, ProviderFeature
 from music_assistant.common.models.media_items import (
+    AudioFormat,
     BrowseFolder,
     ContentType,
     ImageType,
@@ -322,7 +323,9 @@ class RadioBrowserProvider(MusicProvider):
         return StreamDetails(
             provider=self.domain,
             item_id=item_id,
-            content_type=ContentType.try_parse(stream.codec),
+            audio_format=AudioFormat(
+                content_type=ContentType.try_parse(stream.codec),
+            ),
             media_type=MediaType.RADIO,
             data=url_resolved,
             expires=time() + 24 * 3600,
index a7cddde1c321fac98be465bcd18bc59cab7541fa..0e7a09cf2b2f4834d058c86d505fa6e15c5c0c9b 100644 (file)
@@ -11,7 +11,7 @@ from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any
 
 from aioslimproto.client import PlayerState as SlimPlayerState
-from aioslimproto.client import SlimClient
+from aioslimproto.client import SlimClient as SlimClientOrg
 from aioslimproto.client import TransitionType as SlimTransition
 from aioslimproto.const import EventType as SlimEventType
 from aioslimproto.discovery import start_discovery
@@ -42,15 +42,15 @@ if TYPE_CHECKING:
     from music_assistant.common.models.config_entries import ProviderConfig
     from music_assistant.common.models.provider import ProviderManifest
     from music_assistant.server import MusicAssistant
+    from music_assistant.server.controllers.streams import MultiClientStreamJob
     from music_assistant.server.models import ProviderInstanceType
 
 CACHE_KEY_PREV_STATE = "slimproto_prev_state"
 
 # sync constants
 MIN_DEVIATION_ADJUST = 10  # 10 milliseconds
-MAX_DEVIATION_ADJUST = 20000  # 10 seconds
-MIN_REQ_PLAYPOINTS = 2  # we need at least 8 measurements
-MIN_REQ_MILLISECONDS = 500
+MIN_REQ_PLAYPOINTS = 4  # we need at least 4 measurements
+ENABLE_EXPERIMENTAL_SYNC_JOIN = False  # WIP
 
 # TODO: Implement display support
 
@@ -68,7 +68,7 @@ class SyncPlayPoint:
     """Simple structure to describe a Sync Playpoint."""
 
     timestamp: float
-    item_id: str
+    sync_job_id: str
     diff: int
 
 
@@ -171,6 +171,7 @@ class SlimprotoProvider(PlayerProvider):
     _socket_clients: dict[str, SlimClient]
     _sync_playpoints: dict[str, deque[SyncPlayPoint]]
     _virtual_providers: dict[str, tuple[Callable, Callable]]
+    _do_not_resync_before: dict[str, float]
     _cli: LmsCli
     port: int = DEFAULT_SLIMPROTO_PORT
 
@@ -179,6 +180,7 @@ class SlimprotoProvider(PlayerProvider):
         self._socket_clients = {}
         self._sync_playpoints = {}
         self._virtual_providers = {}
+        self._do_not_resync_before = {}
         self.port = self.config.get_value(CONF_PORT)
         # start slimproto socket server
         try:
@@ -202,10 +204,10 @@ class SlimprotoProvider(PlayerProvider):
         if self.config.get_value(CONF_DISCOVERY):
             self._socket_servers.append(
                 await start_discovery(
-                    self.mass.base_ip,
+                    self.mass.streams.publish_ip,
                     self.port,
                     self._cli.cli_port if enable_telnet else None,
-                    self.mass.webserver.port if enable_json else None,
+                    self.mass.streams.publish_ip if enable_json else None,
                     "Music Assistant",
                     self.mass.server_id,
                 )
@@ -234,7 +236,7 @@ class SlimprotoProvider(PlayerProvider):
         self.logger.debug("Socket client connected: %s", addr)
 
         def client_callback(
-            event_type: SlimEventType, client: SlimClient, data: Any = None  # noqa: ARG001
+            event_type: SlimEventType | str, client: SlimClient, data: Any = None  # noqa: ARG001
         ):
             if event_type == SlimEventType.PLAYER_DISCONNECTED:
                 self.mass.create_task(self._handle_disconnected(client))
@@ -252,6 +254,11 @@ class SlimprotoProvider(PlayerProvider):
                 self.mass.create_task(self._handle_buffer_ready(client))
                 return
 
+            if event_type == "output_underrun":
+                # player ran out of buffer
+                self.mass.create_task(self._handle_output_underrun(client))
+                return
+
             if event_type == SlimEventType.PLAYER_HEARTBEAT:
                 self._handle_player_heartbeat(client)
                 return
@@ -320,7 +327,7 @@ class SlimprotoProvider(PlayerProvider):
                 type=ConfigEntryType.INTEGER,
                 range=(0, 1500),
                 default_value=0,
-                label="Correct synchronization delay",
+                label="Audio synchronization delay correction",
                 description="If this player is playing audio synced with other players "
                 "and you always hear the audio too late on this player, "
                 "you can shift the audio a bit.",
@@ -355,71 +362,76 @@ class SlimprotoProvider(PlayerProvider):
                     continue
                 tg.create_task(client.play())
 
-    async def cmd_play_media(
+    async def cmd_play_url(
         self,
         player_id: str,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
-        flow_mode: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
     ) -> None:
-        """Send PLAY MEDIA command to given player.
+        """Send PLAY URL command to given player.
 
-        This is called when the Queue wants the player to start playing a specific QueueItem.
-        The player implementation can decide how to process the request, such as playing
-        queue items one-by-one or enqueue all/some items.
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
 
             - player_id: player_id of the player to handle the command.
-            - queue_item: the QueueItem to start playing on the player.
-            - seek_position: start playing from this specific position.
-            - fade_in: fade in the music at start (e.g. at resume).
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
         """
         # send stop first
         await self.cmd_stop(player_id)
 
         player = self.mass.players.get(player_id)
-        # make sure that the (master) player is powered
-        # powering any client players must be done in other ways
-        if not player.synced_to:
-            await self._socket_clients[player_id].power(True)
+        if player.synced_to:
+            raise RuntimeError("A synced player cannot receive play commands directly")
 
         # forward command to player and any connected sync child's
         sync_clients = [x for x in self._get_sync_clients(player_id)]
         async with asyncio.TaskGroup() as tg:
             for client in sync_clients:
                 tg.create_task(
-                    self._handle_play_media(
+                    self._handle_play_url(
                         client,
+                        url=url,
                         queue_item=queue_item,
-                        seek_position=seek_position,
-                        fade_in=fade_in,
                         send_flush=True,
-                        flow_mode=flow_mode,
                         auto_play=len(sync_clients) == 1,
                     )
                 )
 
-    async def _handle_play_media(
+    async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None:
+        """Handle StreamJob play command on given player."""
+        # send stop first
+        await self.cmd_stop(player_id)
+
+        player = self.mass.players.get(player_id)
+        if player.synced_to:
+            raise RuntimeError("A synced player cannot receive play commands directly")
+        sync_clients = [x for x in self._get_sync_clients(player_id)]
+        async with asyncio.TaskGroup() as tg:
+            for client in sync_clients:
+                url = await stream_job.resolve_stream_url(client.player_id)
+                tg.create_task(
+                    self._handle_play_url(
+                        client,
+                        url=url,
+                        queue_item=None,
+                        send_flush=True,
+                        auto_play=len(sync_clients) == 1,
+                    )
+                )
+
+    async def _handle_play_url(
         self,
         client: SlimClient,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
         send_flush: bool = True,
         crossfade: bool = False,
-        flow_mode: bool = False,
         auto_play: bool = False,
     ) -> None:
         """Handle PlayMedia on slimproto player(s)."""
         player_id = client.player_id
-
-        url = await self.mass.streams.resolve_stream_url(
-            queue_item=queue_item,
-            player_id=player_id,
-            seek_position=seek_position,
-            fade_in=fade_in,
-            flow_mode=flow_mode,
-        )
         if crossfade:
             transition_duration = await self.mass.config.get_player_config_value(
                 player_id, CONF_CROSSFADE_DURATION
@@ -429,8 +441,10 @@ class SlimprotoProvider(PlayerProvider):
 
         await client.play_url(
             url=url,
-            mime_type=f"audio/{url.split('.')[-1]}",
-            metadata={"item_id": queue_item.queue_item_id, "title": queue_item.name},
+            mime_type=f"audio/{url.split('.')[-1].split('?')[0]}",
+            metadata={"item_id": queue_item.queue_item_id, "title": queue_item.name}
+            if queue_item
+            else None,
             send_flush=send_flush,
             transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE,
             transition_duration=transition_duration,
@@ -457,9 +471,6 @@ class SlimprotoProvider(PlayerProvider):
         """Send POWER command to given player."""
         if client := self._socket_clients.get(player_id):
             await client.power(powered)
-            # if player := self.mass.players.get(player_id, raise_unavailable=False):
-            #     player.powered = powered
-            #     self.mass.players.update(player_id)
             # store last state in cache
             await self.mass.cache.set(
                 f"{CACHE_KEY_PREV_STATE}.{player_id}", (powered, client.volume_level)
@@ -482,29 +493,47 @@ class SlimprotoProvider(PlayerProvider):
     async def cmd_sync(self, player_id: str, target_player: str) -> None:
         """Handle SYNC command for given player."""
         child_player = self.mass.players.get(player_id)
-        assert child_player
+        assert child_player  # guard
         parent_player = self.mass.players.get(target_player)
-        assert parent_player
-        parent_player.group_childs.append(child_player.player_id)
+        assert parent_player  # guard
+        # always make sure that the parent player is part of the sync group
+        parent_player.group_childs.add(parent_player.player_id)
+        parent_player.group_childs.add(child_player.player_id)
         child_player.synced_to = parent_player.player_id
-        self.mass.players.update(child_player.player_id)
-        self.mass.players.update(parent_player.player_id)
-        if parent_player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
-            # playback needs to be restarted to get all players in sync
-            # TODO: If there is any need, we could make this smarter where the new
-            # sync child waits for the next track (or pcm chunk even).
-            active_queue = self.mass.players.queues.get_active_queue(parent_player.player_id)
-            await self.mass.players.queues.resume(active_queue.queue_id)
+        # check if we should (re)start or join a stream session
+        active_queue = self.mass.players.queues.get_active_queue(parent_player.player_id)
+        if (
+            ENABLE_EXPERIMENTAL_SYNC_JOIN
+            and (stream_job := self.mass.streams.multi_client_jobs.get(active_queue.queue_id))
+            and (stream_job.pending or stream_job.running)
+        ):
+            # this is a brave attempt to get players to to just join an existing stream
+            # session without having to resume playback
+            # it does not work reliable so far so consider this a WIP
+            # for someone to pickup with a lot of patience and too much time
+            url = await stream_job.resolve_stream_url(player_id)
+            client = self._socket_clients[player_id]
+            await self._handle_play_url(client, url, None, auto_play=True)
+        elif parent_player.state == PlayerState.PLAYING:
+            # playback needs to be restarted to form a new multi client stream session
+            await self.mass.players.queues.resume(active_queue.queue_id, fade_in=False)
+        else:
+            # make sure that the player manager gets an update
+            self.mass.players.update(child_player.player_id)
+            self.mass.players.update(parent_player.player_id)
 
     async def cmd_unsync(self, player_id: str) -> None:
         """Handle UNSYNC command for given player."""
         child_player = self.mass.players.get(player_id)
         parent_player = self.mass.players.get(child_player.synced_to)
-        if child_player.state == PlayerState.PLAYING:
-            await self.cmd_stop(child_player.player_id)
+        # make sure to send stop to the player
+        await self.cmd_stop(child_player.player_id)
         child_player.synced_to = None
-        with suppress(ValueError):
+        with suppress(KeyError):
             parent_player.group_childs.remove(child_player.player_id)
+        if parent_player.group_childs == {parent_player.player_id}:
+            # last child vanished; the sync group is dissolved
+            parent_player.group_childs.remove(parent_player.player_id)
         self.mass.players.update(child_player.player_id)
         self.mass.players.update(parent_player.player_id)
 
@@ -563,9 +592,6 @@ class SlimprotoProvider(PlayerProvider):
         # update player state on player events
         player.available = True
         player.current_url = client.current_url
-        player.current_item_id = (
-            client.current_metadata["item_id"] if client.current_metadata else None
-        )
         player.name = client.name
         player.powered = client.powered
         player.state = STATE_MAP[client.state]
@@ -587,15 +613,30 @@ class SlimprotoProvider(PlayerProvider):
             # ignore server heartbeats when stopped
             return
 
-        player = self.mass.players.get(client.player_id)
-        sync_master_id = player.synced_to
-
         # elapsed time change on the player will be auto picked up
         # by the player manager.
+        player = self.mass.players.get(client.player_id)
         player.elapsed_time = client.elapsed_seconds
         player.elapsed_time_last_updated = time.time()
 
         # handle sync
+        if player.synced_to:
+            self._handle_client_sync(client)
+
+    async def _handle_output_underrun(self, client: SlimClient) -> None:
+        """Process SlimClient Output Underrun Event."""
+        player = self.mass.players.get(client.player_id)
+        self.logger.error("Player %s ran out of buffer", player.display_name)
+        if player.synced_to:
+            # if player is synced, resync it
+            await self.cmd_sync(player.player_id, player.synced_to)
+        else:
+            await self.cmd_stop(client.player_id)
+
+    def _handle_client_sync(self, client: SlimClient) -> None:
+        """Synchronize audio of a sync client."""
+        player = self.mass.players.get(client.player_id)
+        sync_master_id = player.synced_to
         if not sync_master_id:
             # we only correct sync child's, not the sync master itself
             return
@@ -609,28 +650,29 @@ class SlimprotoProvider(PlayerProvider):
         if client.state != SlimPlayerState.PLAYING:
             return
 
+        if backoff_time := self._do_not_resync_before.get(client.player_id):  # noqa: SIM102
+            # player has set a timestamp we should backoff from syncing it
+            if time.time() < backoff_time:
+                return
+
         # we collect a few playpoints of the player to determine
         # average lag/drift so we can adjust accordingly
-        sync_playpoints = self._sync_playpoints.setdefault(client.player_id, deque(maxlen=5))
-
-        # make sure client has loaded the same track as sync master
-        client_item_id = client.current_metadata["item_id"] if client.current_metadata else None
-        master_item_id = (
-            sync_master.current_metadata["item_id"] if sync_master.current_metadata else None
+        sync_playpoints = self._sync_playpoints.setdefault(
+            client.player_id, deque(maxlen=MIN_REQ_PLAYPOINTS)
         )
-        if client_item_id != master_item_id:
-            return
-        # ignore sync when player is transitioning to a new track (next metadata is loaded)
-        next_item_id = client._next_metadata["item_id"] if client._next_metadata else None
-        if next_item_id and client_item_id != next_item_id:
+
+        active_queue = self.mass.players.queues.get_active_queue(client.player_id)
+        stream_job = self.mass.streams.multi_client_jobs.get(active_queue.queue_id)
+        if not stream_job:
+            # should not happen, but just in case
             return
 
         last_playpoint = sync_playpoints[-1] if sync_playpoints else None
         if last_playpoint and (time.time() - last_playpoint.timestamp) > 10:
             # last playpoint is too old, invalidate
             sync_playpoints.clear()
-        if last_playpoint and last_playpoint.item_id != client_item_id:
-            # item has changed, invalidate
+        if last_playpoint and last_playpoint.sync_job_id != stream_job.job_id:
+            # streamjob has changed, invalidate
             sync_playpoints.clear()
 
         diff = int(
@@ -638,13 +680,8 @@ class SlimprotoProvider(PlayerProvider):
             - self._get_corrected_elapsed_milliseconds(client)
         )
 
-        if abs(diff) > MAX_DEVIATION_ADJUST:
-            # safety guard when player is transitioning or something is just plain wrong
-            sync_playpoints.clear()
-            return
-
         # we can now append the current playpoint to our list
-        sync_playpoints.append(SyncPlayPoint(time.time(), client_item_id, diff))
+        sync_playpoints.append(SyncPlayPoint(time.time(), stream_job.job_id, diff))
 
         if len(sync_playpoints) < MIN_REQ_PLAYPOINTS:
             return
@@ -656,15 +693,17 @@ class SlimprotoProvider(PlayerProvider):
         if delta < MIN_DEVIATION_ADJUST:
             return
 
-        # handle player lagging behind, fix with skip_ahead
+        # resync the player by skipping ahead or pause for x amount of (milli)seconds
+        sync_playpoints.clear()
         if avg_diff > 0:
+            # handle player lagging behind, fix with skip_ahead
             self.logger.debug("%s resync: skipAhead %sms", player.display_name, delta)
-            sync_playpoints.clear()
+            self._do_not_resync_before[client.player_id] = time.time() + 2
             asyncio.create_task(self._skip_over(client.player_id, delta))
         else:
             # handle player is drifting too far ahead, use pause_for to adjust
             self.logger.debug("%s resync: pauseFor %sms", player.display_name, delta)
-            sync_playpoints.clear()
+            self._do_not_resync_before[client.player_id] = time.time() + (delta / 1000) + 2
             asyncio.create_task(self._pause_for(client.player_id, delta))
 
     async def _handle_decoder_ready(self, client: SlimClient) -> None:
@@ -677,15 +716,18 @@ class SlimprotoProvider(PlayerProvider):
             return
         if client.state == SlimPlayerState.STOPPED:
             return
+        if player.active_source != player.player_id:
+            return
         try:
-            next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track(
-                client.player_id, client.current_metadata["item_id"]
+            next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url(
+                client.player_id
             )
             async with asyncio.TaskGroup() as tg:
                 for client in self._get_sync_clients(client.player_id):
                     tg.create_task(
-                        self._handle_play_media(
+                        self._handle_play_url(
                             client,
+                            url=next_url,
                             queue_item=next_item,
                             send_flush=False,
                             crossfade=crossfade,
@@ -719,7 +761,8 @@ class SlimprotoProvider(PlayerProvider):
         # all child's ready (or timeout) - start play
         async with asyncio.TaskGroup() as tg:
             for client in self._get_sync_clients(player.player_id):
-                timestamp = client.jiffies + 100
+                timestamp = client.jiffies + 20
+                self._do_not_resync_before[client.player_id] = time.time() + 1
                 tg.create_task(client.send_strm(b"u", replay_gain=int(timestamp)))
 
     async def _handle_connected(self, client: SlimClient) -> None:
@@ -777,15 +820,31 @@ class SlimprotoProvider(PlayerProvider):
     def _get_sync_clients(self, player_id: str) -> Generator[SlimClient]:
         """Get all sync clients for a player."""
         player = self.mass.players.get(player_id)
-        for child_id in [player.player_id] + player.group_childs:
+        # we need to return the player itself too
+        group_child_ids = {player_id}
+        group_child_ids.update(player.group_childs)
+        for child_id in group_child_ids:
             if client := self._socket_clients.get(child_id):
                 yield client
 
     def _get_corrected_elapsed_milliseconds(self, client: SlimClient) -> int:
         """Return corrected elapsed milliseconds."""
+        skipped_millis = 0
+        active_queue = self.mass.players.queues.get_active_queue(client.player_id)
+        if stream_job := self.mass.streams.multi_client_jobs.get(active_queue.queue_id):
+            skipped_millis = stream_job.client_seconds_skipped.get(client.player_id, 0) * 1000
         sync_delay = self.mass.config.get_raw_player_config_value(
             client.player_id, CONF_SYNC_ADJUST, 0
         )
+        current_millis = int(skipped_millis + client.elapsed_milliseconds)
         if sync_delay != 0:
-            return client.elapsed_milliseconds - sync_delay
-        return client.elapsed_milliseconds
+            return current_millis - sync_delay
+        return current_millis
+
+
+class SlimClient(SlimClientOrg):
+    """Patched SLIMProto socket client."""
+
+    def _process_stat_stmo(self, data: bytes) -> None:  # noqa: ARG002
+        """Process incoming stat STMo message: Output Underrun."""
+        self.callback("output_underrun", self)
index d758dd95733faa7764af1186d72ccbfac5718273..2e80f1a591c90c84d3f07a4c580f5d1ad0461f58 100644 (file)
@@ -145,8 +145,8 @@ class LmsCli:
         """Handle async initialization of the plugin."""
         if self.enable_json:
             self.logger.info("Registering jsonrpc endpoints on the webserver")
-            self.mass.webserver.register_route("/jsonrpc.js", self._handle_jsonrpc)
-            self.mass.webserver.register_route("/cometd", self._handle_cometd)
+            self.mass.streams.register_dynamic_route("/jsonrpc.js", self._handle_jsonrpc)
+            self.mass.streams.register_dynamic_route("/cometd", self._handle_cometd)
             self._unsub_callback = self.mass.subscribe(
                 self._on_mass_event,
                 (EventType.PLAYER_UPDATED, EventType.QUEUE_UPDATED),
@@ -163,8 +163,8 @@ class LmsCli:
 
         Called when provider is deregistered (e.g. MA exiting or config reloading).
         """
-        self.mass.webserver.unregister_route("/jsonrpc.js")
-        self.mass.webserver.unregister_route("/cometd")
+        self.mass.streams.unregister_dynamic_route("/jsonrpc.js")
+        self.mass.streams.unregister_dynamic_route("/cometd")
         if self._unsub_callback:
             self._unsub_callback()
             self._unsub_callback = None
@@ -644,7 +644,7 @@ class LmsCli:
                 "sleep": 0,
                 "will_sleep_in": 0,
                 "sync_master": player.synced_to,
-                "sync_slaves": ",".join(player.group_childs),
+                "sync_slaves": ",".join(x for x in player.group_childs if x != player_id),
                 "mixer volume": player.volume_level,
                 "playlist repeat": REPEATMODE_MAP[queue.repeat_mode],
                 "playlist shuffle": int(queue.shuffle_enabled),
@@ -743,8 +743,8 @@ class LmsCli:
             players.append(player_item_from_mass(start_index + index, mass_player))
         return ServerStatusResponse(
             {
-                "httpport": self.mass.webserver.port,
-                "ip": self.mass.base_ip,
+                "httpport": self.mass.streams.port,
+                "ip": self.mass.streams.publish_ip,
                 "version": "7.999.999",
                 "uuid": self.mass.server_id,
                 # TODO: set these vars ?
index ccd3e57c8b06736f31ad496d617aecc9ecf1d16d..883a21ae947a44ccd17fbcab6c0a5f5936a1cc82 100644 (file)
@@ -4,7 +4,6 @@ from __future__ import annotations
 import asyncio
 import logging
 import time
-import xml.etree.ElementTree as ET  # noqa: N817
 from contextlib import suppress
 from dataclasses import dataclass, field
 from typing import TYPE_CHECKING, Any
@@ -15,13 +14,7 @@ from soco.events_base import SubscriptionBase
 from soco.groups import ZoneGroup
 
 from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant.common.models.enums import (
-    ContentType,
-    MediaType,
-    PlayerFeature,
-    PlayerState,
-    PlayerType,
-)
+from music_assistant.common.models.enums import PlayerFeature, PlayerState, PlayerType
 from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty
 from music_assistant.common.models.player import DeviceInfo, Player
 from music_assistant.common.models.queue_item import QueueItem
@@ -78,9 +71,8 @@ class SonosPlayer:
     soco: soco.SoCo
     player: Player
     is_stereo_pair: bool = False
-    next_item: str | None = None
+    next_url: str | None = None
     elapsed_time: int = 0
-    current_item_id: str | None = None
     radio_mode_started: float | None = None
 
     subscriptions: list[SubscriptionBase] = field(default_factory=list)
@@ -118,19 +110,6 @@ class SonosPlayer:
             self.track_info_updated = time.time()
             self.elapsed_time = _timespan_secs(self.track_info["position"]) or 0
 
-            current_item_id = None
-            if track_metadata := self.track_info.get("metadata"):
-                # extract queue_item_id from metadata xml
-                try:
-                    xml_root = ET.XML(track_metadata)
-                    for match in xml_root.iter("{http://purl.org/dc/elements/1.1/}queueItemId"):
-                        item_id = match.text
-                        current_item_id = item_id
-                        break
-                except (ET.ParseError, AttributeError):
-                    pass
-            self.current_item_id = current_item_id
-
         # speaker info
         if update_speaker_info:
             self.speaker_info = self.soco.get_speaker_info()
@@ -159,7 +138,6 @@ class SonosPlayer:
 
         # media info (track info)
         self.player.current_url = self.track_info["uri"]
-        self.player.current_item_id = self.current_item_id
 
         if self.radio_mode_started is not None:
             # sonos reports bullshit elapsed time while playing radio,
@@ -176,14 +154,21 @@ class SonosPlayer:
         if self.group_info and self.group_info.coordinator.uid == self.player_id:
             # this player is the sync leader
             self.player.synced_to = None
-            self.player.group_childs = {
-                x.uid for x in self.group_info.members if x.uid != self.player_id and x.is_visible
-            }
-            if not self.player.group_childs:
+            group_members = {x.uid for x in self.group_info.members if x.is_visible}
+            if not group_members:
+                # not sure about this ?!
                 self.player.type = PlayerType.STEREO_PAIR
+            elif group_members == {self.player_id}:
+                self.player.group_childs = set()
+            else:
+                self.player.group_childs = group_members
         elif self.group_info and self.group_info.coordinator:
             # player is synced to
+            self.player.group_childs = set()
             self.player.synced_to = self.group_info.coordinator.uid
+        else:
+            # unsure
+            self.player.group_childs = set()
 
     async def check_poll(self) -> None:
         """Check if any of the endpoints needs to be polled for info."""
@@ -266,15 +251,22 @@ class SonosPlayerProvider(PlayerProvider):
             return
         await asyncio.to_thread(sonos_player.soco.play)
 
-    async def cmd_play_media(
+    async def cmd_play_url(
         self,
         player_id: str,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
-        flow_mode: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
     ) -> None:
-        """Send PLAY MEDIA command to given player."""
+        """Send PLAY URL command to given player.
+
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
+
+            - player_id: player_id of the player to handle the command.
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
+        """
         sonos_player = self.sonosplayers[player_id]
         if not sonos_player.soco.is_coordinator:
             self.logger.debug(
@@ -283,34 +275,20 @@ class SonosPlayerProvider(PlayerProvider):
             )
             return
         # always stop and clear queue first
-        sonos_player.next_item = None
+        sonos_player.next_url = None
         await asyncio.to_thread(sonos_player.soco.stop)
         await asyncio.to_thread(sonos_player.soco.clear_queue)
 
-        radio_mode = (
-            flow_mode or not queue_item.duration or queue_item.media_type == MediaType.RADIO
-        )
-        url = await self.mass.streams.resolve_stream_url(
-            queue_item=queue_item,
-            player_id=sonos_player.player_id,
-            seek_position=seek_position,
-            fade_in=fade_in,
-            flow_mode=flow_mode,
-            output_codec=ContentType.MP3 if radio_mode else None,
-        )
-        if radio_mode:
+        if queue_item is None:
+            # enforce mp3 radio mode for flow stream
+            url = url.replace(".flac", ".mp3").replace(".wav", ".mp3")
             sonos_player.radio_mode_started = time.time()
-            url = url.replace("http", "x-rincon-mp3radio")
-            metadata = create_didl_metadata(self.mass, url, queue_item, flow_mode)
-            # sonos does multiple get requests if no duration is known
-            # our stream engine does not like that, hence the workaround
-            self.mass.streams.workaround_players.add(sonos_player.player_id)
-            await asyncio.to_thread(sonos_player.soco.play_uri, url, meta=metadata)
+            await asyncio.to_thread(
+                sonos_player.soco.play_uri, url, title="Music Assistant", force_radio=True
+            )
         else:
             sonos_player.radio_mode_started = None
-            await self._enqueue_item(
-                sonos_player, queue_item=queue_item, url=url, flow_mode=flow_mode
-            )
+            await self._enqueue_item(sonos_player, url=url, queue_item=queue_item)
             await asyncio.to_thread(sonos_player.soco.play_from_queue, 0)
 
     async def cmd_pause(self, player_id: str) -> None:
@@ -527,24 +505,18 @@ class SonosPlayerProvider(PlayerProvider):
         sonos_player.group_info_updated = time.time()
         asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop)
 
-    async def _enqueue_next_track(
-        self, sonos_player: SonosPlayer, current_queue_item_id: str
-    ) -> None:
+    async def _enqueue_next_track(self, sonos_player: SonosPlayer) -> None:
         """Enqueue the next track of the MA queue on the CC queue."""
-        if not current_queue_item_id:
-            return  # guard
-        if not self.mass.players.queues.get_item(sonos_player.player_id, current_queue_item_id):
-            return  # guard
         try:
-            next_item, crossfade = await self.mass.players.queues.player_ready_for_next_track(
-                sonos_player.player_id, current_queue_item_id
+            next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url(
+                sonos_player.player_id
             )
         except QueueEmpty:
             return
 
-        if sonos_player.next_item == next_item.queue_item_id:
+        if sonos_player.next_url == next_url:
             return  # already set ?!
-        sonos_player.next_item = next_item.queue_item_id
+        sonos_player.next_url = next_url
 
         # set crossfade according to queue mode
         if sonos_player.soco.cross_fade != crossfade:
@@ -556,25 +528,16 @@ class SonosPlayerProvider(PlayerProvider):
             await asyncio.to_thread(set_crossfade)
 
         # send queue item to sonos queue
-        is_radio = next_item.media_type != MediaType.TRACK
-        url = await self.mass.streams.resolve_stream_url(
-            queue_item=next_item,
-            player_id=sonos_player.player_id,
-            # Sonos pre-caches pretty aggressively so do not yet start the runner
-            auto_start_runner=False,
-            output_codec=ContentType.MP3 if is_radio else None,
-        )
-        await self._enqueue_item(sonos_player, queue_item=next_item, url=url)
+        await self._enqueue_item(sonos_player, url=next_url, queue_item=next_item)
 
     async def _enqueue_item(
         self,
         sonos_player: SonosPlayer,
-        queue_item: QueueItem,
         url: str,
-        flow_mode: bool = False,
+        queue_item: QueueItem | None = None,
     ) -> None:
         """Enqueue a queue item to the Sonos player Queue."""
-        metadata = create_didl_metadata(self.mass, url, queue_item, flow_mode)
+        metadata = create_didl_metadata(self.mass, url, queue_item)
         await asyncio.to_thread(
             sonos_player.soco.avTransport.AddURIToQueue,
             [
@@ -586,17 +549,14 @@ class SonosPlayerProvider(PlayerProvider):
             ],
             timeout=60,
         )
-        if sonos_player.player_id in self.mass.streams.workaround_players:
-            self.mass.streams.workaround_players.remove(sonos_player.player_id)
         self.logger.debug(
             "Enqued track (%s) to player %s",
-            queue_item.name,
+            queue_item.name if queue_item else url,
             sonos_player.player.display_name,
         )
 
     async def _update_player(self, sonos_player: SonosPlayer, signal_update: bool = True) -> None:
         """Update Sonos Player."""
-        prev_item_id = sonos_player.current_item_id
         prev_url = sonos_player.player.current_url
         prev_state = sonos_player.player.state
         sonos_player.update_attributes()
@@ -622,13 +582,9 @@ class SonosPlayerProvider(PlayerProvider):
 
         # enqueue next item if needed
         if sonos_player.player.state == PlayerState.PLAYING and (
-            prev_item_id != sonos_player.current_item_id
-            or not sonos_player.next_item
-            or sonos_player.next_item == sonos_player.current_item_id
+            sonos_player.next_url or sonos_player.next_url == sonos_player.player.current_url
         ):
-            self.mass.create_task(
-                self._enqueue_next_track(sonos_player, sonos_player.current_item_id)
-            )
+            self.mass.create_task(self._enqueue_next_track(sonos_player))
 
 
 def _convert_state(sonos_state: str) -> PlayerState:
index d9acbd4c1559bfa2f32a9e03cbbee7f52980960b..ddc5f8761d63e47298be2cfed2d4488c6438b8e4 100644 (file)
@@ -12,6 +12,7 @@ from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature
 from music_assistant.common.models.errors import InvalidDataError, LoginFailed
 from music_assistant.common.models.media_items import (
     Artist,
+    AudioFormat,
     ContentType,
     ImageType,
     MediaItemImage,
@@ -288,6 +289,9 @@ class SoundcloudMusicProvider(MusicProvider):
             provider=self.instance_id,
             item_id=item_id,
             content_type=ContentType.try_parse(stream_format),
+            audio_format=AudioFormat(
+                content_type=ContentType.try_parse(stream_format),
+            ),
             direct=url,
         )
 
@@ -371,7 +375,9 @@ class SoundcloudMusicProvider(MusicProvider):
                 item_id=track_obj["id"],
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=ContentType.MP3,
+                audio_format=AudioFormat(
+                    content_type=ContentType.MP3,
+                ),
                 url=track_obj["permalink_url"],
             )
         )
index 908c27e4fe0f140b75b7a5e1a662624e1b07c02b..8d350399750f4db7cb528429f1300b425e2dea1e 100644 (file)
@@ -23,6 +23,7 @@ from music_assistant.common.models.media_items import (
     Album,
     AlbumType,
     Artist,
+    AudioFormat,
     ContentType,
     ImageType,
     MediaItemImage,
@@ -355,7 +356,9 @@ class SpotifyProvider(MusicProvider):
         return StreamDetails(
             item_id=track.item_id,
             provider=self.instance_id,
-            content_type=ContentType.OGG,
+            audio_format=AudioFormat(
+                content_type=ContentType.OGG,
+            ),
             duration=track.duration,
         )
 
@@ -448,8 +451,7 @@ class SpotifyProvider(MusicProvider):
                 item_id=album_obj["id"],
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=ContentType.OGG,
-                bit_rate=320,
+                audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
                 url=album_obj["external_urls"]["spotify"],
             )
         )
@@ -497,8 +499,10 @@ class SpotifyProvider(MusicProvider):
                 item_id=track_obj["id"],
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=ContentType.OGG,
-                bit_rate=320,
+                audio_format=AudioFormat(
+                    content_type=ContentType.OGG,
+                    bit_rate=320,
+                ),
                 url=track_obj["external_urls"]["spotify"],
                 available=not track_obj["is_local"] and track_obj["is_playable"],
             )
index 3d08f2c5a2fbd2a0ed2c4daa11417a6987ac6995..21a159c1d34a9afbbdd67dee98d7c54555b1cb4b 100644 (file)
@@ -311,25 +311,26 @@ class AudioDbMetadataProvider(MetadataProvider):
     async def _get_data(self, endpoint, **kwargs) -> dict | None:
         """Get data from api."""
         url = f"https://theaudiodb.com/api/v1/json/{app_var(3)}/{endpoint}"
-        async with self.throttler:
-            async with self.mass.http_session.get(url, params=kwargs, ssl=False) as response:
-                try:
-                    result = await response.json()
-                except (
-                    aiohttp.client_exceptions.ContentTypeError,
-                    JSONDecodeError,
-                ):
-                    self.logger.error("Failed to retrieve %s", endpoint)
-                    text_result = await response.text()
-                    self.logger.debug(text_result)
-                    return None
-                except (
-                    aiohttp.client_exceptions.ClientConnectorError,
-                    aiohttp.client_exceptions.ServerDisconnectedError,
-                ):
-                    self.logger.warning("Failed to retrieve %s", endpoint)
-                    return None
-                if "error" in result and "limit" in result["error"]:
-                    self.logger.warning(result["error"])
-                    return None
-                return result
+        async with self.throttler, self.mass.http_session.get(
+            url, params=kwargs, ssl=False
+        ) as response:
+            try:
+                result = await response.json()
+            except (
+                aiohttp.client_exceptions.ContentTypeError,
+                JSONDecodeError,
+            ):
+                self.logger.error("Failed to retrieve %s", endpoint)
+                text_result = await response.text()
+                self.logger.debug(text_result)
+                return None
+            except (
+                aiohttp.client_exceptions.ClientConnectorError,
+                aiohttp.client_exceptions.ServerDisconnectedError,
+            ):
+                self.logger.warning("Failed to retrieve %s", endpoint)
+                return None
+            if "error" in result and "limit" in result["error"]:
+                self.logger.warning(result["error"])
+                return None
+            return result
index 92791a09529d329ea94cd0ab61a53b3b0600427d..66077c6fc883a0f2605a95649e6426c7e05b3090 100644 (file)
@@ -31,6 +31,7 @@ from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.common.models.media_items import (
     Album,
     Artist,
+    AudioFormat,
     ContentType,
     ItemMapping,
     MediaItemImage,
@@ -364,9 +365,11 @@ class TidalProvider(MusicProvider):
         return StreamDetails(
             item_id=track.id,
             provider=self.instance_id,
-            content_type=ContentType.FLAC,
-            sample_rate=44100,
-            bit_depth=16,
+            audio_format=AudioFormat(
+                content_type=ContentType.FLAC,
+                sample_rate=44100,
+                bit_depth=16,
+            ),
             duration=track.duration,
             direct=url,
         )
@@ -514,7 +517,9 @@ class TidalProvider(MusicProvider):
                 item_id=album_id,
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=ContentType.FLAC,
+                audio_format=AudioFormat(
+                    content_type=ContentType.FLAC,
+                ),
                 url=f"http://www.tidal.com/album/{album_id}",
                 available=available,
             )
@@ -566,9 +571,11 @@ class TidalProvider(MusicProvider):
                 item_id=track_id,
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=ContentType.FLAC,
-                sample_rate=44100,
-                bit_depth=16,
+                audio_format=AudioFormat(
+                    content_type=ContentType.FLAC,
+                    sample_rate=44100,
+                    bit_depth=16,
+                ),
                 url=f"http://www.tidal.com/tracks/{track_id}",
                 available=available,
             )
index 8f22a453983da3a9979d09b3bd557a00c6b396de..b0f3ecbc2fe47adc3a2adc7e5d30b9f28811bd79 100644 (file)
@@ -12,6 +12,7 @@ from music_assistant.common.models.config_entries import ConfigEntry, ConfigValu
 from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature
 from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.common.models.media_items import (
+    AudioFormat,
     ContentType,
     ImageType,
     MediaItemImage,
@@ -172,8 +173,10 @@ class TuneInProvider(MusicProvider):
                 item_id=item_id,
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=content_type,
-                bit_rate=bit_rate,
+                audio_format=AudioFormat(
+                    content_type=content_type,
+                    bit_rate=bit_rate,
+                ),
                 details=url,
             )
         )
@@ -201,6 +204,9 @@ class TuneInProvider(MusicProvider):
                 provider=self.instance_id,
                 item_id=item_id,
                 content_type=ContentType.UNKNOWN,
+                audio_format=AudioFormat(
+                    content_type=ContentType.UNKNOWN,
+                ),
                 media_type=MediaType.RADIO,
                 data=item_id,
             )
@@ -221,7 +227,9 @@ class TuneInProvider(MusicProvider):
             return StreamDetails(
                 provider=self.domain,
                 item_id=item_id,
-                content_type=ContentType(stream["media_type"]),
+                audio_format=AudioFormat(
+                    content_type=ContentType(stream["media_type"]),
+                ),
                 media_type=MediaType.RADIO,
                 data=url,
                 expires=time() + 24 * 3600,
@@ -246,11 +254,12 @@ class TuneInProvider(MusicProvider):
             kwargs["username"] = self.config.get_value(CONF_USERNAME)
             kwargs["partnerId"] = "1"
             kwargs["render"] = "json"
-        async with self._throttler:
-            async with self.mass.http_session.get(url, params=kwargs, ssl=False) as response:
-                result = await response.json()
-                if not result or "error" in result:
-                    self.logger.error(url)
-                    self.logger.error(kwargs)
-                    result = None
-                return result
+        async with self._throttler, self.mass.http_session.get(
+            url, params=kwargs, ssl=False
+        ) as response:
+            result = await response.json()
+            if not result or "error" in result:
+                self.logger.error(url)
+                self.logger.error(kwargs)
+                result = None
+            return result
index 46792a9f5dd94b395527aa2186b5414f1b06f76e..5b9119a3794a4226efe18f8144efb9d31ba1529e 100644 (file)
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
     from music_assistant.common.models.config_entries import ProviderConfig
     from music_assistant.common.models.provider import ProviderManifest
     from music_assistant.server import MusicAssistant
+    from music_assistant.server.controllers.streams import MultiClientStreamJob
     from music_assistant.server.models import ProviderInstanceType
 
 
@@ -181,24 +182,21 @@ class UniversalGroupProvider(PlayerProvider):
             ):
                 tg.create_task(self.mass.players.cmd_play(member.player_id))
 
-    async def cmd_play_media(
+    async def cmd_play_url(
         self,
         player_id: str,
-        queue_item: QueueItem,
-        seek_position: int = 0,
-        fade_in: bool = False,
-        flow_mode: bool = False,
+        url: str,
+        queue_item: QueueItem | None,
     ) -> None:
-        """Send PLAY MEDIA command to given player.
+        """Send PLAY URL command to given player.
 
-        This is called when the Queue wants the player to start playing a specific QueueItem.
-        The player implementation can decide how to process the request, such as playing
-        queue items one-by-one or enqueue all/some items.
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
 
             - player_id: player_id of the player to handle the command.
-            - queue_item: the QueueItem to start playing on the player.
-            - seek_position: start playing from this specific position.
-            - fade_in: fade in the music at start (e.g. at resume).
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
         """
         # send stop first
         await self.cmd_stop(player_id)
@@ -206,6 +204,7 @@ class UniversalGroupProvider(PlayerProvider):
         await self.cmd_power(player_id, True)
         group_player = self.mass.players.get(player_id)
         group_player.extra_data["optimistic_state"] = PlayerState.PLAYING
+
         # forward command to all (powered) group child's
         async with asyncio.TaskGroup() as tg:
             for member in self._get_active_members(
@@ -213,13 +212,26 @@ class UniversalGroupProvider(PlayerProvider):
             ):
                 player_prov = self.mass.players.get_player_provider(member.player_id)
                 tg.create_task(
-                    player_prov.cmd_play_media(
-                        member.player_id,
-                        queue_item=queue_item,
-                        seek_position=seek_position,
-                        fade_in=fade_in,
-                        flow_mode=flow_mode,
-                    )
+                    player_prov.cmd_play_url(member.player_id, url=url, queue_item=queue_item)
+                )
+
+    async def cmd_handle_stream_job(self, player_id: str, stream_job: MultiClientStreamJob) -> None:
+        """Handle StreamJob play command on given player."""
+        # send stop first
+        await self.cmd_stop(player_id)
+        # power ON
+        await self.cmd_power(player_id, True)
+        group_player = self.mass.players.get(player_id)
+        group_player.extra_data["optimistic_state"] = PlayerState.PLAYING
+        # forward command to all (powered) group child's
+        async with asyncio.TaskGroup() as tg:
+            for member in self._get_active_members(
+                player_id, only_powered=True, skip_sync_childs=True
+            ):
+                player_prov = self.mass.players.get_player_provider(member.player_id)
+                # we forward the stream_job to child to allow for nested groups etc
+                tg.create_task(
+                    player_prov.cmd_handle_stream_job(member.player_id, stream_job=stream_job)
                 )
 
     async def cmd_pause(self, player_id: str) -> None:
@@ -290,7 +302,6 @@ class UniversalGroupProvider(PlayerProvider):
                 continue
             if member.state not in (PlayerState.PLAYING, PlayerState.PAUSED):
                 continue
-            group_player.current_item_id = member.current_item_id
             group_player.current_url = member.current_url
             group_player.elapsed_time = member.elapsed_time
             group_player.elapsed_time_last_updated = member.elapsed_time_last_updated
@@ -298,7 +309,6 @@ class UniversalGroupProvider(PlayerProvider):
             break
         else:
             group_player.state = PlayerState.IDLE
-            group_player.current_item_id = None
             group_player.current_url = None
 
     def on_child_state(
index 1788a2cb0043cdadb048a87b42fb5f19d4032076..77cbd38e7945c61493ea6f8f29e06b1548f9df84 100644 (file)
@@ -9,6 +9,7 @@ from music_assistant.common.models.config_entries import ConfigEntry, ConfigValu
 from music_assistant.common.models.enums import ContentType, ImageType, MediaType
 from music_assistant.common.models.media_items import (
     Artist,
+    AudioFormat,
     MediaItemImage,
     MediaItemType,
     ProviderMapping,
@@ -125,10 +126,12 @@ class URLProvider(MusicProvider):
                 item_id=item_id,
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
-                content_type=ContentType.try_parse(media_info.format),
-                sample_rate=media_info.sample_rate,
-                bit_depth=media_info.bits_per_sample,
-                bit_rate=media_info.bit_rate,
+                audio_format=AudioFormat(
+                    content_type=ContentType.try_parse(media_info.format),
+                    sample_rate=media_info.sample_rate,
+                    bit_depth=media_info.bits_per_sample,
+                    bit_rate=media_info.bit_rate,
+                ),
             )
         }
         if media_info.has_cover_image:
@@ -181,10 +184,12 @@ class URLProvider(MusicProvider):
         return StreamDetails(
             provider=self.instance_id,
             item_id=item_id,
-            content_type=ContentType.try_parse(media_info.format),
+            audio_format=AudioFormat(
+                content_type=ContentType.try_parse(media_info.format),
+                sample_rate=media_info.sample_rate,
+                bit_depth=media_info.bits_per_sample,
+            ),
             media_type=MediaType.RADIO if is_radio else MediaType.TRACK,
-            sample_rate=media_info.sample_rate,
-            bit_depth=media_info.bits_per_sample,
             direct=None if is_radio and not mpeg_dash_stream else url,
             data=url,
         )
diff --git a/music_assistant/server/providers/websocket_api/__init__.py b/music_assistant/server/providers/websocket_api/__init__.py
deleted file mode 100644 (file)
index f55b7d7..0000000
+++ /dev/null
@@ -1,282 +0,0 @@
-"""Default Music Assistant Websocket API."""
-from __future__ import annotations
-
-import asyncio
-import inspect
-import logging
-import weakref
-from concurrent import futures
-from contextlib import suppress
-from typing import TYPE_CHECKING, Any, Final
-
-from aiohttp import WSMsgType, web
-
-from music_assistant.common.models.api import (
-    ChunkedResultMessage,
-    CommandMessage,
-    ErrorResultMessage,
-    MessageType,
-    SuccessResultMessage,
-)
-from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant.common.models.errors import InvalidCommand
-from music_assistant.common.models.event import MassEvent
-from music_assistant.constants import ROOT_LOGGER_NAME
-from music_assistant.server.helpers.api import APICommandHandler, parse_arguments
-from music_assistant.server.models.plugin import PluginProvider
-
-if TYPE_CHECKING:
-    from music_assistant.common.models.config_entries import ProviderConfig
-    from music_assistant.common.models.provider import ProviderManifest
-    from music_assistant.server import MusicAssistant
-    from music_assistant.server.models import ProviderInstanceType
-
-
-DEBUG = False  # Set to True to enable very verbose logging of all incoming/outgoing messages
-MAX_PENDING_MSG = 512
-CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
-LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.websocket_api")
-
-
-async def setup(
-    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
-) -> ProviderInstanceType:
-    """Initialize provider(instance) with given configuration."""
-    prov = WebsocketAPI(mass, manifest, config)
-    await prov.handle_setup()
-    return prov
-
-
-async def get_config_entries(
-    mass: MusicAssistant,
-    instance_id: str | None = None,
-    action: str | None = None,
-    values: dict[str, ConfigValueType] | None = None,
-) -> tuple[ConfigEntry, ...]:
-    """
-    Return Config entries to setup this provider.
-
-    instance_id: id of an existing provider instance (None if new instance setup).
-    action: [optional] action key called from config entries UI.
-    values: the (intermediate) raw values for config entries sent with the action.
-    """
-    # ruff: noqa: ARG001
-    return tuple()  # we do not have any config entries (yet)
-
-
-class WebsocketAPI(PluginProvider):
-    """Default Music Assistant Websocket API."""
-
-    clients: weakref.WeakSet[WebsocketClientHandler] = weakref.WeakSet()
-
-    async def handle_setup(self) -> None:
-        """Handle async initialization of the plugin."""
-        self.mass.webserver.register_route("/ws", self._handle_ws_client)
-
-    async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse:
-        connection = WebsocketClientHandler(self.mass, request)
-        try:
-            self.clients.add(connection)
-            return await connection.handle_client()
-        finally:
-            self.clients.remove(connection)
-
-    async def unload(self) -> None:
-        """
-        Handle unload/close of the provider.
-
-        Called when provider is deregistered (e.g. MA exiting or config reloading).
-        """
-        self.mass.webserver.unregister_route("/ws")
-        for client in set(self.clients):
-            await client.disconnect()
-
-
-class WebSocketLogAdapter(logging.LoggerAdapter):
-    """Add connection id to websocket log messages."""
-
-    def process(self, msg: str, kwargs: Any) -> tuple[str, Any]:
-        """Add connid to websocket log messages."""
-        return f'[{self.extra["connid"]}] {msg}', kwargs
-
-
-class WebsocketClientHandler:
-    """Handle an active websocket client connection."""
-
-    def __init__(self, mass: MusicAssistant, request: web.Request) -> None:
-        """Initialize an active connection."""
-        self.mass = mass
-        self.request = request
-        self.wsock = web.WebSocketResponse(heartbeat=55)
-        self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG)
-        self._handle_task: asyncio.Task | None = None
-        self._writer_task: asyncio.Task | None = None
-        self._logger = WebSocketLogAdapter(LOGGER, {"connid": id(self)})
-
-    async def disconnect(self) -> None:
-        """Disconnect client."""
-        self._cancel()
-        if self._writer_task is not None:
-            await self._writer_task
-
-    async def handle_client(self) -> web.WebSocketResponse:
-        """Handle a websocket response."""
-        # ruff: noqa: PLR0915
-        request = self.request
-        wsock = self.wsock
-        try:
-            async with asyncio.timeout(10):
-                await wsock.prepare(request)
-        except asyncio.TimeoutError:
-            self._logger.warning("Timeout preparing request from %s", request.remote)
-            return wsock
-
-        self._logger.debug("Connection from %s", request.remote)
-        self._handle_task = asyncio.current_task()
-        self._writer_task = asyncio.create_task(self._writer())
-
-        # send server(version) info when client connects
-        self._send_message(self.mass.get_server_info())
-
-        # forward all events to clients
-        def handle_event(event: MassEvent) -> None:
-            self._send_message(event)
-
-        unsub_callback = self.mass.subscribe(handle_event)
-
-        disconnect_warn = None
-
-        try:
-            while not wsock.closed:
-                msg = await wsock.receive()
-
-                if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING):
-                    break
-
-                if msg.type != WSMsgType.TEXT:
-                    disconnect_warn = "Received non-Text message."
-                    break
-
-                if DEBUG:
-                    self._logger.debug("Received: %s", msg.data)
-
-                try:
-                    command_msg = CommandMessage.from_json(msg.data)
-                except ValueError:
-                    disconnect_warn = f"Received invalid JSON: {msg.data}"
-                    break
-
-                self._handle_command(command_msg)
-
-        except asyncio.CancelledError:
-            self._logger.debug("Connection closed by client")
-
-        except Exception:  # pylint: disable=broad-except
-            self._logger.exception("Unexpected error inside websocket API")
-
-        finally:
-            # Handle connection shutting down.
-            unsub_callback()
-            self._logger.debug("Unsubscribed from events")
-
-            try:
-                self._to_write.put_nowait(None)
-                # Make sure all error messages are written before closing
-                await self._writer_task
-                await wsock.close()
-            except asyncio.QueueFull:  # can be raised by put_nowait
-                self._writer_task.cancel()
-
-            finally:
-                if disconnect_warn is None:
-                    self._logger.debug("Disconnected")
-                else:
-                    self._logger.warning("Disconnected: %s", disconnect_warn)
-
-        return wsock
-
-    def _handle_command(self, msg: CommandMessage) -> None:
-        """Handle an incoming command from the client."""
-        self._logger.debug("Handling command %s", msg.command)
-
-        # work out handler for the given path/command
-        handler = self.mass.command_handlers.get(msg.command)
-
-        if handler is None:
-            self._send_message(
-                ErrorResultMessage(
-                    msg.message_id,
-                    InvalidCommand.error_code,
-                    f"Invalid command: {msg.command}",
-                )
-            )
-            self._logger.warning("Invalid command: %s", msg.command)
-            return
-
-        # schedule task to handle the command
-        asyncio.create_task(self._run_handler(handler, msg))
-
-    async def _run_handler(self, handler: APICommandHandler, msg: CommandMessage) -> None:
-        try:
-            args = parse_arguments(handler.signature, handler.type_hints, msg.args)
-            result = handler.target(**args)
-            if inspect.isasyncgen(result):
-                # async generator = send chunked response
-                chunk_size = 100
-                batch: list[Any] = []
-                async for item in result:
-                    batch.append(item)
-                    if len(batch) == chunk_size:
-                        self._send_message(ChunkedResultMessage(msg.message_id, batch))
-                        batch = []
-                # send last chunk
-                self._send_message(ChunkedResultMessage(msg.message_id, batch, True))
-                del batch
-                return
-            if asyncio.iscoroutine(result):
-                result = await result
-            self._send_message(SuccessResultMessage(msg.message_id, result))
-        except Exception as err:  # pylint: disable=broad-except
-            self._logger.exception("Error handling message: %s", msg)
-            self._send_message(
-                ErrorResultMessage(msg.message_id, getattr(err, "error_code", 999), str(err))
-            )
-
-    async def _writer(self) -> None:
-        """Write outgoing messages."""
-        # Exceptions if Socket disconnected or cancelled by connection handler
-        with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS):
-            while not self.wsock.closed:
-                if (process := await self._to_write.get()) is None:
-                    break
-
-                if not isinstance(process, str):
-                    message: str = process()
-                else:
-                    message = process
-                if DEBUG:
-                    self._logger.debug("Writing: %s", message)
-                await self.wsock.send_str(message)
-
-    def _send_message(self, message: MessageType) -> None:
-        """Send a message to the client.
-
-        Closes connection if the client is not reading the messages.
-
-        Async friendly.
-        """
-        _message = message.to_json()
-
-        try:
-            self._to_write.put_nowait(_message)
-        except asyncio.QueueFull:
-            self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG)
-
-            self._cancel()
-
-    def _cancel(self) -> None:
-        """Cancel the connection."""
-        if self._handle_task is not None:
-            self._handle_task.cancel()
-        if self._writer_task is not None:
-            self._writer_task.cancel()
diff --git a/music_assistant/server/providers/websocket_api/manifest.json b/music_assistant/server/providers/websocket_api/manifest.json
deleted file mode 100644 (file)
index 9ef085c..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-{
-  "type": "plugin",
-  "domain": "websocket_api",
-  "name": "Websocket API",
-  "description": "The default Websocket API for interacting with Music Assistant which is also used by the Music Assistant frontend.",
-  "codeowners": ["@music-assistant"],
-  "requirements": [],
-  "documentation": "",
-  "multi_instance": false,
-  "builtin": true,
-  "hidden": true,
-  "load_by_default": true,
-  "icon": "md:webhook"
-}
index bf40c3f1ee3f4bae725735eea03430cbf16416c8..274993aba45afa01903dc922b061ad4c84bbca12 100644 (file)
@@ -24,6 +24,7 @@ from music_assistant.common.models.media_items import (
     Album,
     AlbumType,
     Artist,
+    AudioFormat,
     ContentType,
     ImageType,
     ItemMapping,
@@ -513,7 +514,9 @@ class YoutubeMusicProvider(MusicProvider):
         stream_details = StreamDetails(
             provider=self.instance_id,
             item_id=item_id,
-            content_type=ContentType.try_parse(stream_format["mimeType"]),
+            audio_format=AudioFormat(
+                content_type=ContentType.try_parse(stream_format["mimeType"]),
+            ),
             direct=url,
         )
         if (
@@ -524,9 +527,9 @@ class YoutubeMusicProvider(MusicProvider):
                 track_obj["streamingData"].get("expiresInSeconds")
             )
         if stream_format.get("audioChannels") and str(stream_format.get("audioChannels")).isdigit():
-            stream_details.channels = int(stream_format.get("audioChannels"))
+            stream_details.audio_format.channels = int(stream_format.get("audioChannels"))
         if stream_format.get("audioSampleRate") and stream_format.get("audioSampleRate").isdigit():
-            stream_details.sample_rate = int(stream_format.get("audioSampleRate"))
+            stream_details.audio_format.sample_rate = int(stream_format.get("audioSampleRate"))
         return stream_details
 
     async def _post_data(self, endpoint: str, data: dict[str, str], **kwargs):  # noqa: ARG002
@@ -736,7 +739,9 @@ class YoutubeMusicProvider(MusicProvider):
                 provider_domain=self.domain,
                 provider_instance=self.instance_id,
                 available=available,
-                content_type=ContentType.M4A,
+                audio_format=AudioFormat(
+                    content_type=ContentType.M4A,
+                ),
             )
         )
         return track
index 8972da8295ac04b48a444a71026326ce9870f983..61f94080d56fccd7884ce6ac156636275699fa99 100644 (file)
@@ -12,7 +12,7 @@ from uuid import uuid4
 from aiohttp import ClientSession, TCPConnector
 from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf
 
-from music_assistant.common.helpers.util import get_ip, get_ip_pton
+from music_assistant.common.helpers.util import get_ip_pton
 from music_assistant.common.models.api import ServerInfoMessage
 from music_assistant.common.models.config_entries import ProviderConfig
 from music_assistant.common.models.enums import EventType, ProviderType
@@ -66,28 +66,24 @@ class MusicAssistant:
     loop: asyncio.AbstractEventLoop
     http_session: ClientSession
     zeroconf: Zeroconf
+    config: ConfigController
+    webserver: WebserverController
+    cache: CacheController
+    metadata: MetaDataController
+    music: MusicController
+    players: PlayerController
+    streams: StreamsController
 
     def __init__(self, storage_path: str) -> None:
         """Initialize the MusicAssistant Server."""
         self.storage_path = storage_path
-        self.base_ip = get_ip()
         # we dynamically register command handlers which can be consumed by the apis
         self.command_handlers: dict[str, APICommandHandler] = {}
         self._subscribers: set[EventSubscriptionType] = set()
         self._available_providers: dict[str, ProviderManifest] = {}
         self._providers: dict[str, ProviderInstanceType] = {}
-        # init core controllers
-        self.config = ConfigController(self)
-        self.webserver = WebserverController(self)
-        self.cache = CacheController(self)
-        self.metadata = MetaDataController(self)
-        self.music = MusicController(self)
-        self.players = PlayerController(self)
-        self.streams = StreamsController(self)
         self._tracked_tasks: dict[str, asyncio.Task] = {}
         self.closing = False
-        # register all api commands (methods with decorator)
-        self._register_api_commands()
 
     async def start(self) -> None:
         """Start running the Music Assistant server."""
@@ -105,22 +101,31 @@ class MusicAssistant:
             ),
         )
         # setup config controller first and fetch important config values
+        self.config = ConfigController(self)
         await self.config.setup()
         LOGGER.info(
-            "Starting Music Assistant Server (%s) - autodetected IP-address: %s",
+            "Starting Music Assistant Server (%s)",
             self.server_id,
-            self.base_ip,
         )
         # setup other core controllers
+        self.cache = CacheController(self)
+        self.webserver = WebserverController(self)
+        self.metadata = MetaDataController(self)
+        self.music = MusicController(self)
+        self.players = PlayerController(self)
+        self.streams = StreamsController(self)
+        # register all api commands (methods with decorator)
+        self._register_api_commands()
         await self.cache.setup()
         await self.webserver.setup()
         await self.music.setup()
         await self.metadata.setup()
         await self.players.setup()
         await self.streams.setup()
+        # setup discovery
+        self.create_task(self._setup_discovery())
         # load providers
         await self._load_providers()
-        self._setup_discovery()
 
     async def stop(self) -> None:
         """Stop running the music assistant server."""
@@ -276,6 +281,8 @@ class MusicAssistant:
 
         Tasks created by this helper will be properly cancelled on stop.
         """
+        if target is None:
+            raise RuntimeError("Target is missing")
         if existing := self._tracked_tasks.get(task_id):
             # prevent duplicate tasks if task_id is given and already present
             return existing
@@ -296,8 +303,9 @@ class MusicAssistant:
             if LOGGER.isEnabledFor(logging.DEBUG) and not _task.cancelled() and _task.exception():
                 task_name = _task.get_name() if hasattr(_task, "get_name") else _task
                 LOGGER.exception(
-                    "Exception in task %s",
+                    "Exception in task %s - target: %s",
                     task_name,
+                    str(target),
                     exc_info=task.exception(),
                 )
 
@@ -321,7 +329,8 @@ class MusicAssistant:
         handler: Callable,
     ) -> None:
         """Dynamically register a command on the API."""
-        assert command not in self.command_handlers, "Command already registered"
+        if command in self.command_handlers:
+            raise RuntimeError(f"Command {command} is already registered")
         self.command_handlers[command] = APICommandHandler.parse(command, handler)
 
     async def load_provider(self, conf: ProviderConfig) -> None:  # noqa: C901
@@ -490,7 +499,7 @@ class MusicAssistant:
                         exc_info=exc,
                     )
 
-    def _setup_discovery(self) -> None:
+    async def _setup_discovery(self) -> None:
         """Make this Music Assistant instance discoverable on the network."""
         zeroconf_type = "_mass._tcp.local."
         server_id = self.server_id
@@ -498,8 +507,8 @@ class MusicAssistant:
         info = ServiceInfo(
             zeroconf_type,
             name=f"{server_id}.{zeroconf_type}",
-            addresses=[get_ip_pton(self.base_ip)],
-            port=self.webserver.port,
+            addresses=[await get_ip_pton(self.streams.publish_ip)],
+            port=self.streams.publish_port,
             properties=self.get_server_info().to_dict(),
             server="mass.local.",
         )
@@ -507,9 +516,9 @@ class MusicAssistant:
         try:
             existing = getattr(self, "mass_zc_service_set", None)
             if existing:
-                self.zeroconf.update_service(info)
+                await self.zeroconf.async_update_service(info)
             else:
-                self.zeroconf.register_service(info)
+                await self.zeroconf.async_register_service(info)
             setattr(self, "mass_zc_service_set", True)
         except NonUniqueNameException:
             LOGGER.error(