From 2db557c87d5e0e4c6504a8e0c55047dbc8e44f72 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 8 Oct 2020 01:30:37 +0200 Subject: [PATCH] cosmetic fixes --- music_assistant/constants.py | 2 +- music_assistant/helpers/process.py | 7 +- music_assistant/managers/config.py | 14 +- music_assistant/managers/players.py | 2 +- music_assistant/managers/streams.py | 108 +++------ music_assistant/models/config_entry.py | 2 +- music_assistant/models/player_queue.py | 36 ++- .../providers/builtin_player/__init__.py | 209 ----------------- .../builtin_player/translations.json | 5 - .../providers/{webplayer => mass}/__init__.py | 217 ++++++++++++++++-- .../{builtin_player => mass}/icon.png | Bin .../__init__.py | 10 +- .../icon.png | Bin .../translations.json | 4 +- music_assistant/providers/webplayer/icon.png | Bin 8346 -> 0 bytes music_assistant/translations.json | 14 +- 16 files changed, 285 insertions(+), 345 deletions(-) delete mode 100644 music_assistant/providers/builtin_player/__init__.py delete mode 100644 music_assistant/providers/builtin_player/translations.json rename music_assistant/providers/{webplayer => mass}/__init__.py (57%) rename music_assistant/providers/{builtin_player => mass}/icon.png (100%) rename music_assistant/providers/{group_player => universal_group}/__init__.py (98%) rename music_assistant/providers/{group_player => universal_group}/icon.png (100%) rename music_assistant/providers/{group_player => universal_group}/translations.json (87%) delete mode 100644 music_assistant/providers/webplayer/icon.png diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 6d771a66..4681c180 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -1,6 +1,6 @@ """All constants for Music Assistant.""" -__version__ = "0.0.52" +__version__ = "0.0.53" REQUIRED_PYTHON_VER = "3.7" # configuration keys/attributes diff --git a/music_assistant/helpers/process.py b/music_assistant/helpers/process.py index f65a3e28..95f15437 100644 --- a/music_assistant/helpers/process.py +++ b/music_assistant/helpers/process.py @@ -18,7 +18,7 @@ import threading import time from typing import AsyncGenerator, List, Optional -LOGGER = logging.getLogger("AsyncProcess") +LOGGER = logging.getLogger("mass.helpers") class AsyncProcess(object): @@ -68,7 +68,6 @@ class AsyncProcess(object): await self.__queue_out.get() self.__queue_out.task_done() await self.__proc_task - LOGGER.debug("[%s] Context manager closed", self._id) return True async def iterate_chunks(self) -> AsyncGenerator[bytes, None]: @@ -112,9 +111,6 @@ class AsyncProcess(object): def __run_proc(self): """Run process in executor.""" try: - LOGGER.debug( - "[%s] Starting process with args: %s", self._id, str(self._process_args) - ) proc = subprocess.Popen( self._process_args, shell=self._enable_shell, @@ -143,7 +139,6 @@ class AsyncProcess(object): if proc.poll() is None: proc.terminate() proc.communicate() - LOGGER.debug("[%s] process finished", self._id) def __write_stdin(self, _stdin): """Put chunks from queue to stdin.""" diff --git a/music_assistant/managers/config.py b/music_assistant/managers/config.py index 6657991b..c6d58422 100755 --- a/music_assistant/managers/config.py +++ b/music_assistant/managers/config.py @@ -114,6 +114,12 @@ DEFAULT_PROVIDER_CONFIG_ENTRIES = [ DEFAULT_BASE_CONFIG_ENTRIES = { CONF_KEY_BASE_WEBSERVER: [ + ConfigEntry( + entry_key="__name__", + entry_type=ConfigEntryType.LABEL, + label=CONF_KEY_BASE_WEBSERVER, + hidden=True, + ), ConfigEntry( entry_key=CONF_HTTP_PORT, entry_type=ConfigEntryType.INT, @@ -146,11 +152,17 @@ DEFAULT_BASE_CONFIG_ENTRIES = { entry_key=CONF_EXTERNAL_URL, entry_type=ConfigEntryType.STRING, default_value=f"http://{get_external_ip()}:8095", - label="External url (fqdn)", + label=CONF_EXTERNAL_URL, description="desc_external_url", ), ], CONF_KEY_BASE_SECURITY: [ + ConfigEntry( + entry_key="__name__", + entry_type=ConfigEntryType.LABEL, + label=CONF_KEY_BASE_SECURITY, + hidden=True, + ), ConfigEntry( entry_key=CONF_USERNAME, entry_type=ConfigEntryType.STRING, diff --git a/music_assistant/managers/players.py b/music_assistant/managers/players.py index c2eb3c55..455abdd7 100755 --- a/music_assistant/managers/players.py +++ b/music_assistant/managers/players.py @@ -107,7 +107,7 @@ class PlayerManager: return self._player_states.get(player_id) @callback - def get_player(self, player_id: str) -> PlayerState: + def get_player(self, player_id: str) -> Player: """Return Player by player_id or None if player does not exist.""" player_state = self._player_states.get(player_id) if player_state: diff --git a/music_assistant/managers/streams.py b/music_assistant/managers/streams.py index b9599bcb..3b66de7f 100755 --- a/music_assistant/managers/streams.py +++ b/music_assistant/managers/streams.py @@ -18,7 +18,11 @@ from typing import AsyncGenerator, List, Optional, Tuple import pyloudnorm import soundfile from aiofile import AIOFile, Reader -from music_assistant.constants import EVENT_STREAM_ENDED, EVENT_STREAM_STARTED +from music_assistant.constants import ( + CONF_MAX_SAMPLE_RATE, + EVENT_STREAM_ENDED, + EVENT_STREAM_STARTED, +) from music_assistant.helpers.encryption import ( async_decrypt_bytes, async_decrypt_string, @@ -85,34 +89,18 @@ class StreamManager: if resample: args += ["rate", "-v", str(resample)] - LOGGER.debug( - "[async_get_sox_stream] [%s/%s] started using args: %s", - streamdetails.provider, - streamdetails.item_id, - " ".join(args), - ) async with AsyncProcess(args, chunk_size, enable_write=True) as sox_proc: cancelled = False async def fill_buffer(): """Forward audio chunks to sox stdin.""" - LOGGER.debug( - "[async_get_sox_stream] [%s/%s] fill_buffer started", - streamdetails.provider, - streamdetails.item_id, - ) # feed audio data into sox stdin for processing async for chunk in self.async_get_media_stream(streamdetails): - if self.mass.exit or cancelled: + if self.mass.exit or cancelled or not chunk: break await sox_proc.write(chunk) await sox_proc.write_eof() - LOGGER.debug( - "[async_get_sox_stream] [%s/%s] fill_buffer finished", - streamdetails.provider, - streamdetails.item_id, - ) fill_buffer_task = self.mass.loop.create_task(fill_buffer()) # yield chunks from stdout @@ -134,48 +122,47 @@ class StreamManager: cancelled = True fill_buffer_task.cancel() LOGGER.debug( - "[async_get_sox_stream] [%s/%s] cancelled", - streamdetails.provider, - streamdetails.item_id, - ) - raise exc - else: - LOGGER.debug( - "[async_get_sox_stream] [%s/%s] finished", + "[async_get_sox_stream] [%s/%s] cancelled: %s", streamdetails.provider, streamdetails.item_id, + str(exc), ) async def async_queue_stream_flac(self, player_id) -> AsyncGenerator[bytes, None]: """Stream the PlayerQueue's tracks as constant feed in flac format.""" - chunk_size = 571392 # 74,7% of pcm + chunk_size = 512000 - args = ["sox", "-t", "s32", "-c", "2", "-r", "96000", "-", "-t", "flac", "-"] + player_conf = self.mass.config.get_player_config(player_id) + sample_rate = player_conf.get(CONF_MAX_SAMPLE_RATE, 96000) + + args = [ + "sox", + "-t", + "s32", + "-c", + "2", + "-r", + str(sample_rate), + "-", + "-t", + "flac", + "-", + ] async with AsyncProcess(args, chunk_size, enable_write=True) as sox_proc: - LOGGER.debug( - "[async_queue_stream_flac] [%s] started using args: %s", - player_id, - " ".join(args), - ) - # feed stdin with pcm samples cancelled = False async def fill_buffer(): """Feed audio data into sox stdin for processing.""" - LOGGER.debug( - "[async_queue_stream_flac] [%s] fill buffer started", player_id - ) - async for chunk in self.async_queue_stream_pcm(player_id, 96000, 32): - if self.mass.exit or cancelled: + async for chunk in self.async_queue_stream_pcm( + player_id, sample_rate, 32 + ): + if self.mass.exit or cancelled or not chunk: break await sox_proc.write(chunk) # write eof when no more data await sox_proc.write_eof() - LOGGER.debug( - "[async_queue_stream_flac] [%s] fill buffer finished", player_id - ) fill_buffer_task = self.mass.loop.create_task(fill_buffer()) try: @@ -187,14 +174,7 @@ class StreamManager: cancelled = True fill_buffer_task.cancel() LOGGER.debug( - "[async_queue_stream_flac] [%s] cancelled", - player_id, - ) - raise exc - else: - LOGGER.debug( - "[async_queue_stream_flac] [%s] finished", - player_id, + "[async_queue_stream_flac] [%s] cancelled: %s", player_id, str(exc) ) async def async_queue_stream_pcm( @@ -202,14 +182,6 @@ class StreamManager: ) -> AsyncGenerator[bytes, None]: """Stream the PlayerQueue's tracks as constant feed in PCM raw audio.""" player_queue = self.mass.players.get_player_queue(player_id) - queue_conf = self.mass.config.get_player_config(player_id) - fade_length = try_parse_int(queue_conf["crossfade_duration"]) - pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)] - sample_size = int(sample_rate * (bit_depth / 8) * 2) # 1 second - if fade_length: - buffer_size = sample_size * fade_length - else: - buffer_size = sample_size * 10 LOGGER.info("Start Queue Stream for player %s ", player_id) @@ -225,8 +197,15 @@ class StreamManager: else: queue_track = player_queue.next_item if not queue_track: - LOGGER.debug("no (more) tracks left in queue") + LOGGER.info("no (more) tracks left in queue") break + + # get crossfade details + fade_length = player_queue.crossfade_duration + pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)] + sample_size = int(sample_rate * (bit_depth / 8) * 2) # 1 second + buffer_size = sample_size * fade_length if fade_length else sample_size * 10 + # get streamdetails streamdetails = await self.mass.music.async_get_stream_details( queue_track, player_id @@ -414,12 +393,6 @@ class StreamManager: # signal start of stream event self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails) - LOGGER.debug( - "[async_get_media_stream] [%s/%s] started, using %s", - streamdetails.provider, - streamdetails.item_id, - stream_type, - ) if stream_type == StreamType.CACHE: async for chunk in async_yield_chunks(audio_data, chunk_size): @@ -453,11 +426,6 @@ class StreamManager: # send analyze job to background worker if not stream_type == StreamType.CACHE: self.mass.add_job(self.__analyze_audio, streamdetails, audio_data) - LOGGER.debug( - "[async_get_media_stream] [%s/%s] Finished", - streamdetails.provider, - streamdetails.item_id, - ) def __get_player_sox_options( self, player_id: str, streamdetails: StreamDetails diff --git a/music_assistant/models/config_entry.py b/music_assistant/models/config_entry.py index a094c99e..5dc8f989 100644 --- a/music_assistant/models/config_entry.py +++ b/music_assistant/models/config_entry.py @@ -22,7 +22,7 @@ class ConfigEntry: entry_key: str entry_type: ConfigEntryType - default_value: Any = None + default_value: Any = "" values: List[Any] = field(default_factory=list) # select from list of values range: Tuple[Any] = () # select values within range label: str = "" # a friendly name for the setting diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 00ccbe46..4ca5c7bd 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -9,6 +9,7 @@ from enum import Enum from typing import List, Optional, Tuple from music_assistant.constants import ( + CONF_CROSSFADE_DURATION, EVENT_QUEUE_ITEMS_UPDATED, EVENT_QUEUE_TIME_UPDATED, EVENT_QUEUE_UPDATED, @@ -68,7 +69,6 @@ class PlayerQueue: self._items = [] self._shuffle_enabled = False self._repeat_enabled = False - self._crossfade_enabled = False self._cur_index = 0 self._cur_item_time = 0 self._last_item = None @@ -145,11 +145,6 @@ class PlayerQueue: self.mass.add_job(self.async_update_state()) self.mass.add_job(self.__async_save_state()) - @property - def crossfade_enabled(self) -> bool: - """Return if crossfade is enabled for this player's queue.""" - return self._crossfade_enabled - @property def cur_index(self) -> OptionalInt: """ @@ -237,6 +232,19 @@ class PlayerQueue: else not self.supports_queue ) + @property + def crossfade_duration(self) -> int: + """Return crossfade duration (if enabled).""" + player_settings = self.mass.config.get_player_config(self.player_id) + if player_settings: + return player_settings.get(CONF_CROSSFADE_DURATION, 0) + return 0 + + @property + def crossfade_enabled(self) -> bool: + """Return bool if crossfade is enabled.""" + return self.crossfade_duration > 0 + @property def supports_queue(self) -> bool: """Return if this player supports native queue.""" @@ -266,9 +274,6 @@ class PlayerQueue: async def async_next(self) -> None: """Play the next track in the queue.""" - self._crossfade_enabled = ( - self.mass.config.player_settings[self.player_id]["crossfade_duration"] > 0 - ) if self.cur_index is None: return if self.use_queue_stream: @@ -277,9 +282,6 @@ class PlayerQueue: async def async_previous(self) -> None: """Play the previous track in the queue.""" - self._crossfade_enabled = ( - self.mass.config.player_settings[self.player_id]["crossfade_duration"] > 0 - ) if self.cur_index is None: return if self.use_queue_stream: @@ -288,9 +290,6 @@ class PlayerQueue: async def async_resume(self) -> None: """Resume previous queue.""" - self._crossfade_enabled = ( - self.mass.config.player_settings[self.player_id]["crossfade_duration"] > 0 - ) if self.items: prev_index = self.cur_index if self.use_queue_stream or not self.supports_queue: @@ -307,9 +306,6 @@ class PlayerQueue: async def async_play_index(self, index: int) -> None: """Play item at index X in queue.""" - self._crossfade_enabled = ( - self.mass.config.player_settings[self.player_id]["crossfade_duration"] > 0 - ) if not isinstance(index, int): index = self.__index_by_id(index) if not len(self.items) > index: @@ -357,9 +353,6 @@ class PlayerQueue: async def async_load(self, queue_items: List[QueueItem]) -> None: """Load (overwrite) queue with new items.""" - self._crossfade_enabled = ( - self.mass.config.player_settings[self.player_id]["crossfade_duration"] > 0 - ) for index, item in enumerate(queue_items): item.sort_index = index if self._shuffle_enabled: @@ -529,7 +522,6 @@ class PlayerQueue: async def async_start_queue_stream(self) -> None: """Call when queue_streamer starts playing the queue stream.""" self._last_queue_startindex = self._next_queue_startindex - self._cur_item_time = 0 return self.get_item(self._next_queue_startindex) diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py deleted file mode 100644 index 4b097f0a..00000000 --- a/music_assistant/providers/builtin_player/__init__.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Local player provider.""" -import asyncio -import logging -import signal -import subprocess -from typing import List - -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import DeviceInfo, PlaybackState, Player -from music_assistant.models.provider import PlayerProvider - -PROV_ID = "builtin_player" -PROV_NAME = "Built-in (local) player" -LOGGER = logging.getLogger(PROV_ID) - - -async def async_setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = BuiltinPlayerProvider() - await mass.async_register_provider(prov) - - -class BuiltinPlayerProvider(PlayerProvider): - """Demo PlayerProvider which provides a single local player.""" - - @property - def id(self) -> str: - """Return provider ID for this provider.""" - return PROV_ID - - @property - def name(self) -> str: - """Return provider Name for this provider.""" - return PROV_NAME - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return Config Entries for this provider.""" - return [] - - async def async_on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - player = BuiltinPlayer("local_player", "Built-in player on the server") - self.mass.add_job(self.mass.players.async_add_player(player)) - return True - - async def async_on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - for player in self.players: - await player.async_cmd_stop() - - -class BuiltinPlayer(Player): - """Representation of a BuiltinPlayer.""" - - def __init__(self, player_id: str, name: str) -> None: - """Initialize the built-in player.""" - self._player_id = player_id - self._name = name - self._powered = False - self._elapsed_time = 0 - self._state = PlaybackState.Stopped - self._current_uri = "" - self._volume_level = 100 - self._muted = False - self._sox = None - self._progress_task = None - - @property - def player_id(self) -> str: - """Return player id of this player.""" - return self._player_id - - @property - def provider_id(self) -> str: - """Return provider id of this player.""" - return PROV_ID - - @property - def name(self) -> str: - """Return name of the player.""" - return self._name - - @property - def powered(self) -> bool: - """Return current power state of player.""" - return self._powered - - @property - def elapsed_time(self) -> float: - """Return elapsed_time of current playing uri in seconds.""" - return self._elapsed_time - - @property - def state(self) -> PlaybackState: - """Return current PlaybackState of player.""" - return self._state - - @property - def available(self) -> bool: - """Return current availablity of player.""" - return True - - @property - def current_uri(self) -> str: - """Return currently loaded uri of player (if any).""" - return self._current_uri - - @property - def volume_level(self) -> int: - """Return current volume level of player (scale 0..100).""" - return self._volume_level - - @property - def muted(self) -> bool: - """Return current mute state of player.""" - return self._muted - - @property - def is_group_player(self) -> bool: - """Return True if this player is a group player.""" - return False - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this player.""" - return DeviceInfo( - model="Demo", address="http://demo:12345", manufacturer=PROV_NAME - ) - - # SERVICE CALLS / PLAYER COMMANDS - - async def async_cmd_play_uri(self, uri: str): - """Play the specified uri/url on the player.""" - if self._sox: - await self.async_cmd_stop() - self._current_uri = uri - self._sox = subprocess.Popen(["play", "-t", "flac", "-q", uri]) - self._state = PlaybackState.Playing - self._powered = True - self.update_state() - - async def report_progress(): - """Report fake progress while sox is playing.""" - LOGGER.info("Playback started on player %s", self.name) - self._elapsed_time = 0 - while self._sox and not self._sox.poll(): - await asyncio.sleep(1) - self._elapsed_time += 1 - self.update_state() - LOGGER.info("Playback stopped on player %s", self.name) - self._elapsed_time = 0 - self._state = PlaybackState.Stopped - self.update_state() - - if self._progress_task: - self._progress_task.cancel() - self._progress_task = self.mass.add_job(report_progress) - - async def async_cmd_stop(self) -> None: - """Send STOP command to player.""" - if self._sox: - self._sox.terminate() - self._sox = None - self._state = PlaybackState.Stopped - self.update_state() - - async def async_cmd_play(self) -> None: - """Send PLAY command to player.""" - if self._sox: - self._sox.send_signal(signal.SIGCONT) - self._state = PlaybackState.Playing - self.update_state() - - async def async_cmd_pause(self): - """Send PAUSE command to given player.""" - if self._sox: - self._sox.send_signal(signal.SIGSTOP) - self._state = PlaybackState.Paused - self.update_state() - - async def async_cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - self._powered = True - self.update_state() - - async def async_cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - await self.async_cmd_stop() - self._powered = False - self.update_state() - - async def async_cmd_volume_set(self, volume_level: int) -> None: - """ - Send volume level command to given player. - - :param volume_level: volume level to set (0..100). - """ - self._volume_level = volume_level - self.update_state() - - async def async_cmd_volume_mute(self, is_muted=False): - """ - Send volume MUTE command to given player. - - :param is_muted: bool with new mute state. - """ - self._muted = is_muted - self.update_state() diff --git a/music_assistant/providers/builtin_player/translations.json b/music_assistant/providers/builtin_player/translations.json deleted file mode 100644 index 12c5f9c5..00000000 --- a/music_assistant/providers/builtin_player/translations.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "nl": { - "Built-in (local) player": "Ingebouwde speler van de server" - } -} \ No newline at end of file diff --git a/music_assistant/providers/webplayer/__init__.py b/music_assistant/providers/mass/__init__.py similarity index 57% rename from music_assistant/providers/webplayer/__init__.py rename to music_assistant/providers/mass/__init__.py index 184a2f1b..e864dabc 100644 --- a/music_assistant/providers/webplayer/__init__.py +++ b/music_assistant/providers/mass/__init__.py @@ -1,10 +1,13 @@ -"""Webplayer support.""" +"""Builtin player provider.""" +import asyncio import logging +import signal +import subprocess import time from typing import List from music_assistant.helpers.typing import MusicAssistantType -from music_assistant.helpers.util import run_periodic +from music_assistant.helpers.util import get_hostname, run_periodic from music_assistant.models.config_entry import ConfigEntry from music_assistant.models.player import ( DeviceInfo, @@ -14,8 +17,8 @@ from music_assistant.models.player import ( ) from music_assistant.models.provider import PlayerProvider -PROV_ID = "webplayer" -PROV_NAME = "WebPlayer" +PROV_ID = "mass" +PROV_NAME = "Music Assistant" LOGGER = logging.getLogger(PROV_ID) CONFIG_ENTRIES = [] @@ -29,23 +32,18 @@ EVENT_WEBPLAYER_REGISTER = "webplayer register" async def async_setup(mass): """Perform async setup of this Plugin/Provider.""" - prov = WebPlayerProvider() + prov = MassPlayerProvider() await mass.async_register_provider(prov) -class WebPlayerProvider(PlayerProvider): +class MassPlayerProvider(PlayerProvider): """ - Implementation of a player using pure HTML/javascript. + Built-in PlayerProvider. - Used in the front-end. - Communication is handled through the websocket connection - and our internal event bus. + Provides a single headless local player on the server using SoX. + Provides virtual players in the frontend using websockets. """ - _players = {} - - ### Provider specific implementation ##### - @property def id(self) -> str: """Return provider ID for this provider.""" @@ -59,22 +57,34 @@ class WebPlayerProvider(PlayerProvider): @property def config_entries(self) -> List[ConfigEntry]: """Return Config Entries for this provider.""" - return CONFIG_ENTRIES + return [] async def async_on_start(self) -> bool: """Handle initialization of the provider based on config.""" + # add local sox player on the server + player = BuiltinLocalPlayer("server_player", f"Server: {get_hostname()}") + self.mass.add_job(self.mass.players.async_add_player(player)) + # listen for websockets events to dynamically create players self.mass.add_event_listener( self.async_handle_mass_event, [EVENT_WEBPLAYER_STATE, EVENT_WEBPLAYER_REGISTER], ) self.mass.add_job(self.async_check_players()) + return True + + async def async_on_stop(self): + """Handle correct close/cleanup of the provider on exit.""" + for player in self.players: + await player.async_cmd_stop() async def async_handle_mass_event(self, msg, msg_details): """Handle received event for the webplayer component.""" player = self.mass.players.get_player(msg_details["player_id"]) if not player: # register new player - player = WebPlayer(self.mass, msg_details["player_id"], msg_details["name"]) + player = WebsocketsPlayer( + self.mass, msg_details["player_id"], msg_details["name"] + ) await self.mass.players.async_add_player(player) await player.handle_player_state(msg_details) @@ -84,6 +94,8 @@ class WebPlayerProvider(PlayerProvider): cur_time = time.time() offline_players = [] for player in self.players: + if not isinstance(player, WebsocketsPlayer): + continue if cur_time - player.last_message > 30: offline_players.append(player.player_id) for player_id in offline_players: @@ -92,7 +104,7 @@ class WebPlayerProvider(PlayerProvider): async def __async_handle_player_state(self, data): """Handle state event from player.""" player_id = data["player_id"] - player = self._players[player_id] + player = self.mass.players.get_player(player_id) if "volume_level" in data: player.volume_level = data["volume_level"] if "muted" in data: @@ -108,11 +120,176 @@ class WebPlayerProvider(PlayerProvider): if "name" in data: player.name = data["name"] player.last_message = time.time() - self.mass.add_job(self.mass.players.async_update_player(player)) + player.update_state() -class WebPlayer(Player): - """Definition of a webplayer.""" +class BuiltinLocalPlayer(Player): + """Representation of a local player on the server using SoX.""" + + def __init__(self, player_id: str, name: str) -> None: + """Initialize the built-in player.""" + self._player_id = player_id + self._name = name + self._powered = False + self._elapsed_time = 0 + self._state = PlaybackState.Stopped + self._current_uri = "" + self._volume_level = 100 + self._muted = False + self._sox = None + self._progress_task = None + + @property + def player_id(self) -> str: + """Return player id of this player.""" + return self._player_id + + @property + def provider_id(self) -> str: + """Return provider id of this player.""" + return PROV_ID + + @property + def name(self) -> str: + """Return name of the player.""" + return self._name + + @property + def powered(self) -> bool: + """Return current power state of player.""" + return self._powered + + @property + def elapsed_time(self) -> float: + """Return elapsed_time of current playing uri in seconds.""" + return self._elapsed_time + + @property + def state(self) -> PlaybackState: + """Return current PlaybackState of player.""" + return self._state + + @property + def available(self) -> bool: + """Return current availablity of player.""" + return True + + @property + def current_uri(self) -> str: + """Return currently loaded uri of player (if any).""" + return self._current_uri + + @property + def volume_level(self) -> int: + """Return current volume level of player (scale 0..100).""" + return self._volume_level + + @property + def muted(self) -> bool: + """Return current mute state of player.""" + return self._muted + + @property + def is_group_player(self) -> bool: + """Return True if this player is a group player.""" + return False + + @property + def device_info(self) -> DeviceInfo: + """Return the device info for this player.""" + return DeviceInfo( + model="Demo", address="http://demo:12345", manufacturer=PROV_NAME + ) + + # SERVICE CALLS / PLAYER COMMANDS + + async def async_cmd_play_uri(self, uri: str): + """Play the specified uri/url on the player.""" + if self._sox: + await self.async_cmd_stop() + self._current_uri = uri + self._sox = subprocess.Popen(["play", "-t", "flac", "-q", uri]) + self._state = PlaybackState.Playing + self._powered = True + self.update_state() + + async def report_progress(): + """Report fake progress while sox is playing.""" + LOGGER.info("Playback started on player %s", self.name) + self._elapsed_time = 0 + while self._sox and not self._sox.poll(): + await asyncio.sleep(1) + self._elapsed_time += 1 + self.update_state() + LOGGER.info("Playback stopped on player %s", self.name) + self._elapsed_time = 0 + self._state = PlaybackState.Stopped + self.update_state() + + if self._progress_task: + self._progress_task.cancel() + self._progress_task = self.mass.add_job(report_progress) + + async def async_cmd_stop(self) -> None: + """Send STOP command to player.""" + if self._sox: + self._sox.terminate() + self._sox = None + self._state = PlaybackState.Stopped + self.update_state() + + async def async_cmd_play(self) -> None: + """Send PLAY command to player.""" + if self._sox: + self._sox.send_signal(signal.SIGCONT) + self._state = PlaybackState.Playing + self.update_state() + + async def async_cmd_pause(self): + """Send PAUSE command to given player.""" + if self._sox: + self._sox.send_signal(signal.SIGSTOP) + self._state = PlaybackState.Paused + self.update_state() + + async def async_cmd_power_on(self) -> None: + """Send POWER ON command to player.""" + self._powered = True + self.update_state() + + async def async_cmd_power_off(self) -> None: + """Send POWER OFF command to player.""" + await self.async_cmd_stop() + self._powered = False + self.update_state() + + async def async_cmd_volume_set(self, volume_level: int) -> None: + """ + Send volume level command to given player. + + :param volume_level: volume level to set (0..100). + """ + self._volume_level = volume_level + self.update_state() + + async def async_cmd_volume_mute(self, is_muted=False): + """ + Send volume MUTE command to given player. + + :param is_muted: bool with new mute state. + """ + self._muted = is_muted + self.update_state() + + +class WebsocketsPlayer(Player): + """ + Implementation of a player using pure HTML/javascript. + + Used in the front-end. + Communication is handled through the websocket connection + and our internal event bus. + """ def __init__(self, mass: MusicAssistantType, player_id: str, player_name: str): """Initialize the webplayer.""" diff --git a/music_assistant/providers/builtin_player/icon.png b/music_assistant/providers/mass/icon.png similarity index 100% rename from music_assistant/providers/builtin_player/icon.png rename to music_assistant/providers/mass/icon.png diff --git a/music_assistant/providers/group_player/__init__.py b/music_assistant/providers/universal_group/__init__.py similarity index 98% rename from music_assistant/providers/group_player/__init__.py rename to music_assistant/providers/universal_group/__init__.py index a0dec967..e12700c2 100644 --- a/music_assistant/providers/group_player/__init__.py +++ b/music_assistant/providers/universal_group/__init__.py @@ -9,8 +9,8 @@ from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType from music_assistant.models.player import DeviceInfo, PlaybackState, Player from music_assistant.models.provider import PlayerProvider -PROV_ID = "group_player" -PROV_NAME = "Group player creator" +PROV_ID = "universal_group" +PROV_NAME = "Universal Group player" LOGGER = logging.getLogger(PROV_ID) CONF_PLAYER_COUNT = "group_player_count" @@ -73,9 +73,9 @@ class GroupPlayer(Player): """Initialize.""" self.mass = mass self._player_index = player_index - self._player_id = f"group_player_{player_index}" + self._player_id = f"{PROV_ID}_{player_index}" self._provider_id = PROV_ID - self._name = f"Group Player {player_index}" + self._name = f"{PROV_NAME} {player_index}" self._powered = False self._state = PlaybackState.Stopped self._available = True @@ -135,7 +135,7 @@ class GroupPlayer(Player): @property def elapsed_time(self): - """Return elapsed timefor first child player.""" + """Return elapsed time for first child player.""" if self.state in [PlaybackState.Playing, PlaybackState.Paused]: for player_id in self.group_childs: player = self.mass.players.get_player(player_id) diff --git a/music_assistant/providers/group_player/icon.png b/music_assistant/providers/universal_group/icon.png similarity index 100% rename from music_assistant/providers/group_player/icon.png rename to music_assistant/providers/universal_group/icon.png diff --git a/music_assistant/providers/group_player/translations.json b/music_assistant/providers/universal_group/translations.json similarity index 87% rename from music_assistant/providers/group_player/translations.json rename to music_assistant/providers/universal_group/translations.json index aac339cb..016b527f 100644 --- a/music_assistant/providers/group_player/translations.json +++ b/music_assistant/providers/universal_group/translations.json @@ -1,6 +1,6 @@ { "en": { - "Universal Group Players": "Universal Group Players", + "Universal Group player": "Universal Group Player", "group_player_count": "Number of group players", "group_player_count_desc": "Select how many Universal group players should be created.", "group_player_players": "Players in group", @@ -9,7 +9,7 @@ "group_player_master_desc": "Select the player that should act as group master." }, "nl": { - "Universal Group Players": "Universele groep spelers", + "Universal Group player": "Universele groep speler", "group_player_count": "Aantal groep spelers", "group_player_count_desc": "Selecteer hoeveel groep spelers er aangemaakt moeten worden.", "group_player_players": "Groepsspelers", diff --git a/music_assistant/providers/webplayer/icon.png b/music_assistant/providers/webplayer/icon.png deleted file mode 100644 index ffcf4fa0005ebf8753adbdc2ada10987b4d18550..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8346 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 diff --git a/music_assistant/translations.json b/music_assistant/translations.json index 529ec5e2..15e90b67 100644 --- a/music_assistant/translations.json +++ b/music_assistant/translations.json @@ -16,6 +16,10 @@ "https_port": "HTTPS Port", "ssl_certificate": "SSL Certificate file location", "ssl_key": "Path to certificate key file", + "external_url": "External URL", + "group_delay": "Correction of groupdelay", + "web": "Webserver", + "security": "Security", "desc_sample_rate": "Set the maximum sample rate this player can handle.", "desc_volume_normalisation": "Enable R128 volume normalisation to play music at an equally loud volume.", @@ -29,7 +33,8 @@ "desc_ssl_key": "Supply the full path to the file containing the private key.", "desc_external_url": "Supply the full URL how this Music Assistant instance can be accessed from outside. Make sure this matches the common name of the certificate.", "desc_base_username": "Username to access this Music Assistant server.", - "desc_base_password": "A password to protect this Music Assistant server. Can be left blank but this is extremely dangerous if this server is reachable from outside." + "desc_base_password": "A password to protect this Music Assistant server. Can be left blank but this is extremely dangerous if this server is reachable from outside.", + "desc_group_delay": "Only used on grouped playback. Adjust the delay of the grouped playback on this player" }, "nl": { "enabled": "Ingeschakeld", @@ -48,6 +53,10 @@ "https_port": "HTTPS Port", "ssl_certificate": "SSL Certificaat bestandslocatie", "ssl_key": "Pad naar het certificaat key bestand", + "external_url": "External URL", + "web": "Webserver", + "security": "Beveiliging", + "group_delay": "Correctie van groepsvertraging", "desc_sample_rate": "Stel de maximale sample rate in die deze speler aankan.", "desc_volume_normalisation": "R128 volume normalisatie inschakelen om muziek altijd op een gelijk volume af te spelen.", @@ -61,6 +70,7 @@ "desc_ssl_key": "Geef het pad om naar het bestand met de private key.", "desc_external_url": "Geef de URL waarop deze Music Assistant server extern te benaderen is. Zorg dat dit overeenomst met het certificaat.", "desc_base_username": "Gebruikersnaam waarmee deze server beveiligd moet worden.", - "desc_base_password": "Wachtwoord waarmee deze server beveiligd moet worden. Mag worden leeggelaten maar dit is extreem gevaarlijk indien je besluit de server extern toegankelijk te maken." + "desc_base_password": "Wachtwoord waarmee deze server beveiligd moet worden. Mag worden leeggelaten maar dit is extreem gevaarlijk indien je besluit de server extern toegankelijk te maken.", + "desc_group_delay": "Gebruikt bij afspelen in groep. Pas de vertraging aan voor deze player." } } -- 2.34.1