From: Marcel van der Veldt Date: Mon, 26 Jan 2026 19:23:03 +0000 (+0100) Subject: Config handling improvements (#3021) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=51e87071a2dbf7d315ac843186d9920c6372e0b5;p=music-assistant-server.git Config handling improvements (#3021) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index d9a5515a..115c23c5 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -8,7 +8,7 @@ from music_assistant_models.config_entries import ( ConfigEntry, ConfigValueOption, ) -from music_assistant_models.enums import ConfigEntryType, ContentType, HidePlayerOption +from music_assistant_models.enums import ConfigEntryType, ContentType from music_assistant_models.media_items import AudioFormat APPLICATION_NAME: Final = "Music Assistant" @@ -55,9 +55,6 @@ CONF_PASSWORD: Final[str] = "password" CONF_VOLUME_NORMALIZATION: Final[str] = "volume_normalization" CONF_VOLUME_NORMALIZATION_TARGET: Final[str] = "volume_normalization_target" CONF_OUTPUT_LIMITER: Final[str] = "output_limiter" -CONF_DEPRECATED_EQ_BASS: Final[str] = "eq_bass" -CONF_DEPRECATED_EQ_MID: Final[str] = "eq_mid" -CONF_DEPRECATED_EQ_TREBLE: Final[str] = "eq_treble" CONF_PLAYER_DSP: Final[str] = "player_dsp" CONF_PLAYER_DSP_PRESETS: Final[str] = "player_dsp_presets" CONF_OUTPUT_CHANNELS: Final[str] = "output_channels" @@ -69,10 +66,9 @@ CONF_BIND_IP: Final[str] = "bind_ip" CONF_BIND_PORT: Final[str] = "bind_port" CONF_PUBLISH_IP: Final[str] = "publish_ip" CONF_AUTO_PLAY: Final[str] = "auto_play" -CONF_DEPRECATED_CROSSFADE: Final[str] = "crossfade" CONF_GROUP_MEMBERS: Final[str] = "group_members" CONF_DYNAMIC_GROUP_MEMBERS: Final[str] = "dynamic_members" -CONF_HIDE_PLAYER_IN_UI: Final[str] = "hide_player_in_ui" +CONF_HIDE_IN_UI: Final[str] = "hide_in_ui" CONF_EXPOSE_PLAYER_TO_HA: Final[str] = "expose_player_to_ha" CONF_SYNC_ADJUST: Final[str] = "sync_adjust" CONF_TTS_PRE_ANNOUNCE: Final[str] = "tts_pre_announce" @@ -173,30 +169,9 @@ DEFAULT_CORE_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,) CONF_ENTRY_FLOW_MODE = ConfigEntry( key=CONF_FLOW_MODE, type=ConfigEntryType.BOOLEAN, - label="Enable queue flow mode", + label="Enforce Gapless playback with Queue Flow Mode streaming", default_value=False, -) - -CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED = ConfigEntry.from_dict( - {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": True} -) - -CONF_ENTRY_FLOW_MODE_ENFORCED = ConfigEntry.from_dict( - { - **CONF_ENTRY_FLOW_MODE.to_dict(), - "default_value": True, - "value": True, - "hidden": True, - } -) - -CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED = ConfigEntry.from_dict( - { - **CONF_ENTRY_FLOW_MODE.to_dict(), - "default_value": False, - "value": False, - "hidden": True, - } + category="advanced", ) @@ -224,6 +199,7 @@ CONF_ENTRY_OUTPUT_CHANNELS = ConfigEntry( default_value="stereo", label="Output Channel Mode", category="audio", + requires_reload=True, ) CONF_ENTRY_VOLUME_NORMALIZATION = ConfigEntry( @@ -233,6 +209,7 @@ CONF_ENTRY_VOLUME_NORMALIZATION = ConfigEntry( default_value=True, description="Enable volume normalization (EBU-R128 based)", category="audio", + requires_reload=True, ) CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry( @@ -244,6 +221,7 @@ CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry( description="Adjust average (perceived) loudness to this target level", depends_on=CONF_VOLUME_NORMALIZATION, category="advanced", + requires_reload=True, ) CONF_ENTRY_OUTPUT_LIMITER = ConfigEntry( @@ -253,64 +231,9 @@ CONF_ENTRY_OUTPUT_LIMITER = ConfigEntry( default_value=True, description="Activates a limiter that prevents audio distortion by making loud peaks quieter.", category="audio", + requires_reload=True, ) -# These EQ Options are deprecated and will be removed in the future -# To allow for automatic migration to the new DSP system, they are still included in the config -CONF_ENTRY_DEPRECATED_EQ_BASS = ConfigEntry( - key=CONF_DEPRECATED_EQ_BASS, - type=ConfigEntryType.INTEGER, - range=(-10, 10), - default_value=0, - label="Equalizer: bass", - description="Use the builtin basic equalizer to adjust the bass of audio.", - category="audio", - hidden=True, # Hidden, use DSP instead -) - -CONF_ENTRY_DEPRECATED_EQ_MID = ConfigEntry( - key=CONF_DEPRECATED_EQ_MID, - type=ConfigEntryType.INTEGER, - range=(-10, 10), - default_value=0, - label="Equalizer: midrange", - description="Use the builtin basic equalizer to adjust the midrange of audio.", - category="audio", - hidden=True, # Hidden, use DSP instead -) - -CONF_ENTRY_DEPRECATED_EQ_TREBLE = ConfigEntry( - key=CONF_DEPRECATED_EQ_TREBLE, - type=ConfigEntryType.INTEGER, - range=(-10, 10), - default_value=0, - label="Equalizer: treble", - description="Use the builtin basic equalizer to adjust the treble of audio.", - category="audio", - hidden=True, # Hidden, use DSP instead -) - - -CONF_ENTRY_DEPRECATED_CROSSFADE = ConfigEntry( - key=CONF_DEPRECATED_CROSSFADE, - type=ConfigEntryType.BOOLEAN, - label="Enable crossfade", - default_value=False, - description="Enable a crossfade transition between (queue) tracks.", - category="audio", - hidden=True, # Hidden, use Smart Fades instead -) - -CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED = ConfigEntry( - key=CONF_DEPRECATED_CROSSFADE, - type=ConfigEntryType.BOOLEAN, - label="Enable crossfade", - default_value=False, - description="Enable a crossfade transition between (queue) tracks.\n\n " - "Requires flow-mode to be enabled", - category="audio", - depends_on=CONF_FLOW_MODE, -) CONF_ENTRY_SMART_FADES_MODE = ConfigEntry( key=CONF_SMART_FADES_MODE, @@ -328,6 +251,7 @@ CONF_ENTRY_SMART_FADES_MODE = ConfigEntry( "- 'Standard Crossfade': Regular crossfade that crossfades the last/first x-seconds of a " "track.", category="audio", + requires_reload=True, ) CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry( @@ -341,55 +265,7 @@ CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry( depends_on=CONF_SMART_FADES_MODE, depends_on_value="standard_crossfade", category="audio", -) - -CONF_ENTRY_HIDE_PLAYER_IN_UI = ConfigEntry( - key=CONF_HIDE_PLAYER_IN_UI, - type=ConfigEntryType.STRING, - label="Hide this player in the user interface", - multi_value=True, - options=[ - ConfigValueOption("Always", HidePlayerOption.ALWAYS.value), - ConfigValueOption("When powered off", HidePlayerOption.WHEN_OFF.value), - ConfigValueOption("When group active", HidePlayerOption.WHEN_GROUP_ACTIVE.value), - ConfigValueOption("When synced", HidePlayerOption.WHEN_SYNCED.value), - ConfigValueOption("When unavailable", HidePlayerOption.WHEN_UNAVAILABLE.value), - ], - default_value=[ - HidePlayerOption.WHEN_UNAVAILABLE.value, - HidePlayerOption.WHEN_GROUP_ACTIVE.value, - HidePlayerOption.WHEN_SYNCED.value, - ], -) -CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT = ConfigEntry.from_dict( - {**CONF_ENTRY_HIDE_PLAYER_IN_UI.to_dict(), "default_value": [HidePlayerOption.ALWAYS.value]} -) - -CONF_ENTRY_HIDE_PLAYER_IN_UI_GROUP_PLAYER = ConfigEntry.from_dict( - { - **CONF_ENTRY_HIDE_PLAYER_IN_UI.to_dict(), - "default_value": [HidePlayerOption.WHEN_UNAVAILABLE.value], - "options": [ - ConfigValueOption("Always", HidePlayerOption.ALWAYS.value).to_dict(), - ConfigValueOption("When powered off", HidePlayerOption.WHEN_OFF.value).to_dict(), - ConfigValueOption( - "When unavailable", HidePlayerOption.WHEN_UNAVAILABLE.value - ).to_dict(), - ], - } -) - -CONF_ENTRY_EXPOSE_PLAYER_TO_HA = ConfigEntry( - key=CONF_EXPOSE_PLAYER_TO_HA, - type=ConfigEntryType.BOOLEAN, - label="Expose this player to Home Assistant", - default_value=True, - description="Expose this player to the Home Assistant integration. \n" - "If disabled, this player will not be imported into Home Assistant.", - category="advanced", -) -CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED = ConfigEntry.from_dict( - {**CONF_ENTRY_EXPOSE_PLAYER_TO_HA.to_dict(), "default_value": False} + requires_reload=True, ) @@ -411,6 +287,7 @@ CONF_ENTRY_OUTPUT_CODEC = ConfigEntry( "into e.g. a lossy mp3 codec or you like to save some network bandwidth. \n\n " "Choosing a lossy codec saves some bandwidth at the cost of audio quality.", category="advanced", + requires_reload=True, ) CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3 = ConfigEntry.from_dict( @@ -447,6 +324,7 @@ CONF_ENTRY_SYNC_ADJUST = ConfigEntry( "and you always hear the audio too early or late on this player, " "you can shift the audio a bit.", category="advanced", + requires_reload=True, ) @@ -520,20 +398,6 @@ HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES = ( CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY_HIDDEN, ) -CONF_ENTRY_PLAYER_ICON = ConfigEntry( - key=CONF_ICON, - type=ConfigEntryType.ICON, - default_value="mdi-speaker", - label="Icon", - description="Material design icon for this player. " - "\n\nSee https://pictogrammers.com/library/mdi/", - category="generic", -) - -CONF_ENTRY_PLAYER_ICON_GROUP = ConfigEntry.from_dict( - {**CONF_ENTRY_PLAYER_ICON.to_dict(), "default_value": "mdi-speaker-multiple"} -) - CONF_ENTRY_SAMPLE_RATES = ConfigEntry( key=CONF_SAMPLE_RATES, @@ -563,6 +427,7 @@ CONF_ENTRY_SAMPLE_RATES = ConfigEntry( category="advanced", description="The sample rates (and bit depths) supported by this player.\n" "Content with unsupported sample rates will be automatically resampled.", + requires_reload=True, ) @@ -580,6 +445,7 @@ CONF_ENTRY_HTTP_PROFILE = ConfigEntry( description="This is considered to be a very advanced setting, only adjust this if needed, " "for example if your player stops playing halfway streams or if you experience " "other playback related issues. In most cases the default setting is fine.", + requires_reload=True, ) CONF_ENTRY_HTTP_PROFILE_DEFAULT_1 = ConfigEntry.from_dict( @@ -617,6 +483,7 @@ CONF_ENTRY_ENABLE_ICY_METADATA = ConfigEntry( ConfigValueOption("Profile 2 - full info (including image)", "full"), ], depends_on=CONF_FLOW_MODE, + depends_on_value_not=False, default_value="disabled", label="Try to inject metadata into stream (ICY)", category="advanced", @@ -624,6 +491,7 @@ CONF_ENTRY_ENABLE_ICY_METADATA = ConfigEntry( "even when flow mode is enabled.\n\nThis is called ICY metadata and is what is used by " "online radio stations to show you what is playing. \n\nBe aware that not all players support " "this correctly. If you experience issues with playback, try disabling this setting.", + requires_reload=True, ) CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN = ConfigEntry.from_dict( @@ -633,12 +501,19 @@ CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN = ConfigEntry.from_dict( CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED = ConfigEntry.from_dict( { **CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), - "default_value": False, - "value": False, + "default_value": "disabled", + "value": "disabled", "hidden": True, } ) +CONF_ENTRY_ICY_METADATA_DEFAULT_FULL = ConfigEntry.from_dict( + { + **CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), + "default_value": "full", + } +) + CONF_ENTRY_SUPPORT_GAPLESS_DIFFERENT_SAMPLE_RATES = ConfigEntry( key="gapless_different_sample_rates", type=ConfigEntryType.BOOLEAN, @@ -649,6 +524,7 @@ CONF_ENTRY_SUPPORT_GAPLESS_DIFFERENT_SAMPLE_RATES = ConfigEntry( "experience audio glitches during transitioning between tracks.", default_value=False, category="advanced", + requires_reload=True, ) CONF_ENTRY_WARN_PREVIEW = ConfigEntry( @@ -909,6 +785,21 @@ CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS = ConfigEntry( ) +CONF_ENTRY_PLAYER_ICON = ConfigEntry( + key=CONF_ICON, + type=ConfigEntryType.ICON, + default_value="mdi-speaker", + label="Icon", + description="Material design icon for this player. " + "\n\nSee https://pictogrammers.com/library/mdi/", + category="generic", +) + +CONF_ENTRY_PLAYER_ICON_GROUP = ConfigEntry.from_dict( + {**CONF_ENTRY_PLAYER_ICON.to_dict(), "default_value": "mdi-speaker-multiple"} +) + + def create_sample_rates_config_entry( supported_sample_rates: list[int] | None = None, supported_bit_depths: list[int] | None = None, @@ -1009,3 +900,9 @@ SOUNDTRACK_INDICATORS = [ r"\bfrom the film\b", r"\boriginal.*cast.*recording\b", ] + +# List of providers that do not use HTTP streaming +# but consume raw audio data over other protocols +# for provider domains in this list, we won't show the default +# http-streaming specific config options in player settings +NON_HTTP_PROVIDERS = ("airplay", "sendspin", "snapcast") diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 9a07583c..3f016d59 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -18,13 +18,26 @@ from music_assistant_models import config_entries from music_assistant_models.config_entries import ( MULTI_VALUE_SPLITTER, ConfigEntry, + ConfigValueOption, ConfigValueType, CoreConfig, PlayerConfig, ProviderConfig, ) -from music_assistant_models.dsp import DSPConfig, DSPConfigPreset, ToneControlFilter -from music_assistant_models.enums import EventType, ProviderFeature, ProviderType +from music_assistant_models.constants import ( + PLAYER_CONTROL_FAKE, + PLAYER_CONTROL_NATIVE, + PLAYER_CONTROL_NONE, +) +from music_assistant_models.dsp import DSPConfig, DSPConfigPreset +from music_assistant_models.enums import ( + ConfigEntryType, + EventType, + PlayerFeature, + PlayerType, + ProviderFeature, + ProviderType, +) from music_assistant_models.errors import ( ActionUnavailable, InvalidDataError, @@ -34,10 +47,15 @@ from music_assistant_models.helpers import get_global_cache_value from music_assistant.constants import ( CONF_CORE, - CONF_DEPRECATED_CROSSFADE, - CONF_DEPRECATED_EQ_BASS, - CONF_DEPRECATED_EQ_MID, - CONF_DEPRECATED_EQ_TREBLE, + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, + CONF_ENTRY_AUTO_PLAY, + CONF_ENTRY_CROSSFADE_DURATION, + CONF_ENTRY_ENABLE_ICY_METADATA, + CONF_ENTRY_FLOW_MODE, + CONF_ENTRY_HTTP_PROFILE, CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS, CONF_ENTRY_LIBRARY_SYNC_ALBUMS, CONF_ENTRY_LIBRARY_SYNC_ARTISTS, @@ -48,6 +66,11 @@ from music_assistant.constants import ( CONF_ENTRY_LIBRARY_SYNC_PODCASTS, CONF_ENTRY_LIBRARY_SYNC_RADIOS, CONF_ENTRY_LIBRARY_SYNC_TRACKS, + CONF_ENTRY_OUTPUT_CHANNELS, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_OUTPUT_LIMITER, + CONF_ENTRY_PLAYER_ICON, + CONF_ENTRY_PLAYER_ICON_GROUP, CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ALBUMS, CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ARTISTS, CONF_ENTRY_PROVIDER_SYNC_INTERVAL_AUDIOBOOKS, @@ -55,27 +78,41 @@ from music_assistant.constants import ( CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PODCASTS, CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS, CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS, + CONF_ENTRY_SAMPLE_RATES, + CONF_ENTRY_SMART_FADES_MODE, + CONF_ENTRY_TTS_PRE_ANNOUNCE, + CONF_ENTRY_VOLUME_NORMALIZATION, + CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, + CONF_EXPOSE_PLAYER_TO_HA, + CONF_HIDE_IN_UI, + CONF_MUTE_CONTROL, CONF_ONBOARD_DONE, CONF_PLAYER_DSP, CONF_PLAYER_DSP_PRESETS, CONF_PLAYERS, + CONF_POWER_CONTROL, + CONF_PRE_ANNOUNCE_CHIME_URL, CONF_PROVIDERS, CONF_SERVER_ID, CONF_SMART_FADES_MODE, + CONF_VOLUME_CONTROL, CONFIGURABLE_CORE_CONTROLLERS, DEFAULT_CORE_CONFIG_ENTRIES, DEFAULT_PROVIDER_CONFIG_ENTRIES, ENCRYPT_SUFFIX, + NON_HTTP_PROVIDERS, ) +from music_assistant.controllers.players.sync_groups import SyncGroupPlayer from music_assistant.helpers.api import api_command from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, async_json_dumps, async_json_loads -from music_assistant.helpers.util import load_provider_module +from music_assistant.helpers.util import load_provider_module, validate_announcement_chime_url from music_assistant.models import ProviderModuleType from music_assistant.models.music_provider import MusicProvider if TYPE_CHECKING: from music_assistant import MusicAssistant from music_assistant.models.core_controller import CoreController + from music_assistant.models.player import Player LOGGER = logging.getLogger(__name__) DEFAULT_SAVE_DELAY = 5 @@ -496,19 +533,26 @@ class ConfigController: self, provider: str | None = None, include_values: bool = False ) -> list[PlayerConfig]: """Return all known player configurations, optionally filtered by provider id.""" - return [ - await self.get_player_config(raw_conf["player_id"]) - if include_values - else cast("PlayerConfig", PlayerConfig.parse([], raw_conf)) - for raw_conf in list(self.get(CONF_PLAYERS, {}).values()) - # filter out unavailable providers (only if we requested the full info) - if ( - not include_values - or raw_conf["provider"] in get_global_cache_value("available_providers", []) - ) + result: list[PlayerConfig] = [] + for raw_conf in list(self.get(CONF_PLAYERS, {}).values()): + # filter out unavailable providers + if raw_conf["provider"] not in get_global_cache_value("available_providers", []): + continue # optional provider filter - and (provider in (None, raw_conf["provider"])) - ] + if provider is not None and raw_conf["provider"] != provider: + continue + # filter out unavailable players + # (unless disabled, otherwise there is no way to re-enable them) + player = self.mass.players.get(raw_conf["player_id"], False) + if (not player or not player.available) and raw_conf.get("enabled", True): + continue + + if include_values: + result.append(await self.get_player_config(raw_conf["player_id"])) + else: + raw_conf["default_name"] = player.display_name if player else raw_conf.get("name") + result.append(cast("PlayerConfig", PlayerConfig.parse([], raw_conf))) + return result @api_command("config/players/get") async def get_player_config( @@ -526,12 +570,13 @@ class ConfigController: # pass action and values to get_config_entries if values is None: values = raw_conf.get("values", {}) - conf_entries = await player.get_config_entries(action=action, values=values) + conf_entries = await self.get_player_config_entries( + player_id, action=action, values=values + ) else: # handle unavailable player and/or provider conf_entries = [] raw_conf["available"] = False - raw_conf["name"] = raw_conf.get("name") raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"] return cast("PlayerConfig", PlayerConfig.parse(conf_entries, raw_conf)) msg = f"No config found for player id {player_id}" @@ -558,7 +603,14 @@ class ConfigController: if values is None: values = self.get(f"{CONF_PLAYERS}/{player_id}/values", {}) - all_entries = await player.get_config_entries(action=action, values=values) + player_entries = await player.get_config_entries(action=action, values=values) + default_entries = self._get_default_player_config_entries(player) + player_entries_keys = {entry.key for entry in player_entries} + all_entries = [ + *player_entries, + # ignore default entries that were overridden by the player specific ones + *[x for x in default_entries if x.key not in player_entries_keys], + ] # set current value from stored values for entry in all_entries: if entry.value is None: @@ -752,51 +804,8 @@ class ConfigController: return DSPConfig.from_dict(raw_conf) # return default DSP config dsp_config = DSPConfig() - - deprecated_eq_bass = self.mass.config.get_raw_player_config_value( - player_id, CONF_DEPRECATED_EQ_BASS, 0 - ) - deprecated_eq_mid = self.mass.config.get_raw_player_config_value( - player_id, CONF_DEPRECATED_EQ_MID, 0 - ) - deprecated_eq_treble = self.mass.config.get_raw_player_config_value( - player_id, CONF_DEPRECATED_EQ_TREBLE, 0 - ) - if deprecated_eq_bass != 0 or deprecated_eq_mid != 0 or deprecated_eq_treble != 0: - # the user previously used the now deprecated EQ settings: - # add a tone control filter with the old values, reset the deprecated values and - # save this as the new DSP config - # TODO: remove this in a future release - dsp_config.enabled = True - dsp_config.filters.append( - ToneControlFilter( - enabled=True, - bass_level=float(deprecated_eq_bass) - if isinstance(deprecated_eq_bass, (int, float, str)) - else 0.0, - mid_level=float(deprecated_eq_mid) - if isinstance(deprecated_eq_mid, (int, float, str)) - else 0.0, - treble_level=float(deprecated_eq_treble) - if isinstance(deprecated_eq_treble, (int, float, str)) - else 0.0, - ) - ) - - deprecated_eq_keys = [ - CONF_DEPRECATED_EQ_BASS, - CONF_DEPRECATED_EQ_MID, - CONF_DEPRECATED_EQ_TREBLE, - ] - for key in deprecated_eq_keys: - if self.mass.config.get_raw_player_config_value(player_id, key, 0) != 0: - self.mass.config.set_raw_player_config_value(player_id, key, 0) - - self.set(f"{CONF_PLAYER_DSP}/{player_id}", dsp_config.to_dict()) - else: - # The DSP config does not do anything by default, so we disable it - dsp_config.enabled = False - + # The DSP config does not do anything by default, so we disable it + dsp_config.enabled = False return dsp_config @api_command("config/players/dsp/save", required_role="admin") @@ -1076,7 +1085,7 @@ class ConfigController: self.save(immediate=True) try: controller: CoreController = getattr(self.mass, domain) - await controller.reload(config) + await controller.update_config(config, changed_keys) except asyncio.CancelledError: pass except Exception: @@ -1336,18 +1345,6 @@ class ConfigController: self._data[CONF_PROVIDERS]["sendspin"] = provider_config changed = True - # Migrate the crossfade setting into Smart Fade Mode = 'crossfade' - for player_config in self._data.get(CONF_PLAYERS, {}).values(): - if not (values := player_config.get("values")): - continue - if (crossfade := values.pop(CONF_DEPRECATED_CROSSFADE, None)) is None: - continue - # Check if player has old crossfade enabled but no smart fades mode set - if crossfade is True and CONF_SMART_FADES_MODE not in values: - # Set smart fades mode to standard_crossfade - values[CONF_SMART_FADES_MODE] = "standard_crossfade" - changed = True - # Migrate smart_fades mode value to smart_crossfade for player_config in self._data.get(CONF_PLAYERS, {}).values(): if not (values := player_config.get("values")): @@ -1433,7 +1430,8 @@ class ConfigController: """Update ProviderConfig.""" config = await self.get_provider_config(instance_id) changed_keys = config.update(values) - available = prov.available if (prov := self.mass.get_provider(instance_id)) else False + prov_instance = self.mass.get_provider(instance_id) + available = prov_instance.available if prov_instance else False if not changed_keys and (config.enabled == available): # no changes return config @@ -1444,7 +1442,13 @@ class ConfigController: conf_key = f"{CONF_PROVIDERS}/{config.instance_id}" raw_conf = config.to_raw() self.set(conf_key, raw_conf) - if config.enabled: + if config.enabled and prov_instance is None: + await self.mass.load_provider_config(config) + if config.enabled and prov_instance and available: + # update config for existing/loaded provider instance + await prov_instance.update_config(config, changed_keys) + elif config.enabled: + # provider is enabled but not available, try to load it await self.mass.load_provider_config(config) else: # disable provider @@ -1534,3 +1538,194 @@ class ConfigController: # correct any multi-instance provider mappings self.mass.create_task(self.mass.music.correct_multi_instance_provider_mappings()) return config + + def _get_default_player_config_entries(self, player: Player) -> list[ConfigEntry]: + """Return the default player config entries.""" + entries: list[ConfigEntry] = [] + # default protocol-player config entries + if player.type == PlayerType.PROTOCOL: + # bare minimum: only playback related entries + entries += [ + CONF_ENTRY_OUTPUT_CHANNELS, + ] + if not player.requires_flow_mode: + entries.append(CONF_ENTRY_FLOW_MODE) + if player.provider.domain not in NON_HTTP_PROVIDERS: + entries += [ + CONF_ENTRY_SAMPLE_RATES, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_ENABLE_ICY_METADATA, + ] + return entries + + # some base entries for all player types + entries += [ + CONF_ENTRY_SMART_FADES_MODE, + CONF_ENTRY_CROSSFADE_DURATION, + CONF_ENTRY_VOLUME_NORMALIZATION, + CONF_ENTRY_OUTPUT_LIMITER, + CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, + CONF_ENTRY_TTS_PRE_ANNOUNCE, + ConfigEntry( + key=CONF_PRE_ANNOUNCE_CHIME_URL, + type=ConfigEntryType.STRING, + label="Custom (pre)announcement chime URL", + description="URL to a custom audio file to play before announcements.\n" + "Leave empty to use the default chime.\n" + "Supports http:// and https:// URLs pointing to " + "audio files (.mp3, .wav, .flac, .ogg, .m4a, .aac).\n" + "Example: http://homeassistant.local:8123/local/audio/custom_chime.mp3", + category="announcements", + required=False, + depends_on=CONF_ENTRY_TTS_PRE_ANNOUNCE.key, + depends_on_value=True, + validate=lambda val: validate_announcement_chime_url(cast("str", val)), + ), + # add player control entries + *self._create_player_control_config_entries(player), + # add entry to hide player in UI + ConfigEntry( + key=CONF_HIDE_IN_UI, + type=ConfigEntryType.BOOLEAN, + label="Hide this player in the user interface", + default_value=player.hidden_by_default, + category="advanced", + ), + # add entry to expose player to HA + ConfigEntry( + key=CONF_EXPOSE_PLAYER_TO_HA, + type=ConfigEntryType.BOOLEAN, + label="Expose this player to Home Assistant", + description="Expose this player to the Home Assistant integration. \n" + "If disabled, this player will not be imported into Home Assistant.", + category="advanced", + default_value=player.expose_to_ha_by_default, + ), + ] + + # group-player config entries + if player.type == PlayerType.GROUP: + is_dedicated_group_player = ( + not isinstance(player, SyncGroupPlayer) + and player.provider.domain != "universal_group" + ) + entries += [ + CONF_ENTRY_PLAYER_ICON_GROUP, + ] + if is_dedicated_group_player and not player.requires_flow_mode: + entries.append(CONF_ENTRY_FLOW_MODE) + if is_dedicated_group_player and player.provider.domain not in NON_HTTP_PROVIDERS: + entries += [ + CONF_ENTRY_SAMPLE_RATES, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_ENABLE_ICY_METADATA, + ] + return entries + + # normal player (or stereo pair) config entries + entries += [ + CONF_ENTRY_PLAYER_ICON, + CONF_ENTRY_OUTPUT_CHANNELS, + # add default entries for announce feature + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, + ] + # add flow mode config entry for players that not already explicitly enable it + if not player.requires_flow_mode: + entries.append(CONF_ENTRY_FLOW_MODE) + # add HTTP streaming config entries for non-http players + if player.provider.domain not in NON_HTTP_PROVIDERS: + entries += [ + CONF_ENTRY_SAMPLE_RATES, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_ENABLE_ICY_METADATA, + ] + + return entries + + def _create_player_control_config_entries(self, player: Player) -> list[ConfigEntry]: + """Create config entries for player controls.""" + all_controls = self.mass.players.player_controls() + power_controls = [x for x in all_controls if x.supports_power] + volume_controls = [x for x in all_controls if x.supports_volume] + mute_controls = [x for x in all_controls if x.supports_mute] + # work out player supported features + supports_power = PlayerFeature.POWER in player.supported_features + supports_volume = PlayerFeature.VOLUME_SET in player.supported_features + supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features + # create base options per control type (and add defaults like native and fake) + base_power_options: list[ConfigValueOption] = [ + ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), + ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE), + ] + if supports_power: + base_power_options.append( + ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE), + ) + base_volume_options: list[ConfigValueOption] = [ + ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), + ] + if supports_volume: + base_volume_options.append( + ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE), + ) + base_mute_options: list[ConfigValueOption] = [ + ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), + ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE), + ] + if supports_mute: + base_mute_options.append( + ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE), + ) + # return final config entries for all options + return [ + # Power control config entry + ConfigEntry( + key=CONF_POWER_CONTROL, + type=ConfigEntryType.STRING, + label="Power Control", + default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE, + required=True, + options=[ + *base_power_options, + *(ConfigValueOption(x.name, x.id) for x in power_controls), + ], + category="player_controls", + hidden=player.type == PlayerType.GROUP, + ), + # Volume control config entry + ConfigEntry( + key=CONF_VOLUME_CONTROL, + type=ConfigEntryType.STRING, + label="Volume Control", + default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE, + required=True, + options=[ + *base_volume_options, + *(ConfigValueOption(x.name, x.id) for x in volume_controls), + ], + category="player_controls", + hidden=player.type == PlayerType.GROUP, + ), + # Mute control config entry + ConfigEntry( + key=CONF_MUTE_CONTROL, + type=ConfigEntryType.STRING, + label="Mute Control", + default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE, + required=True, + options=[ + *base_mute_options, + *[ConfigValueOption(x.name, x.id) for x in mute_controls], + ], + category="player_controls", + hidden=player.type == PlayerType.GROUP, + ), + # auto-play on power on control config entry + CONF_ENTRY_AUTO_PLAY, + ] diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index c71daa39..2a6b2bd5 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -1785,29 +1785,16 @@ class PlayerController(CoreController): await player.on_config_updated() player.update_state() # if the PlayerQueue was playing, restart playback - # TODO: add restart_stream property to ConfigEntry and use that instead of immediate_apply - # to check if we need to restart playback if not player_disabled and resume_queue and resume_queue.state == PlaybackState.PLAYING: - config_entries = await player.get_config_entries() - has_value_changes = False - all_immediate_apply = True - for key in changed_keys: - if not key.startswith("values/"): - continue # skip root values like "enabled", "name" - has_value_changes = True - actual_key = key.removeprefix("values/") - entry = next((e for e in config_entries if e.key == actual_key), None) - if entry is None or not entry.immediate_apply: - all_immediate_apply = False - break - - if has_value_changes and all_immediate_apply: - # All changed config entries have immediate_apply=True, so no need to restart - # the playback - return - # always stop first to ensure the player uses the new config - await self.mass.player_queues.stop(resume_queue.queue_id) - self.mass.call_later(1, self.mass.player_queues.resume, resume_queue.queue_id, False) + requires_restart = any( + v for v in config.values.values() if v.key in changed_keys and v.requires_reload + ) + if requires_restart: + # always stop first to ensure the player uses the new config + await self.mass.player_queues.stop(resume_queue.queue_id) + self.mass.call_later( + 1, self.mass.player_queues.resume, resume_queue.queue_id, False + ) async def on_player_dsp_change(self, player_id: str) -> None: """Call (by config manager) when the DSP settings of a player change.""" diff --git a/music_assistant/controllers/players/sync_groups.py b/music_assistant/controllers/players/sync_groups.py index d5540e33..9fba2e17 100644 --- a/music_assistant/controllers/players/sync_groups.py +++ b/music_assistant/controllers/players/sync_groups.py @@ -132,16 +132,11 @@ class SyncGroupPlayer(GroupPlayer): return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE return PlaybackState.IDLE - @cached_property - def flow_mode(self) -> bool: - """ - Return if the player needs flow mode. - - Will by default be set to True if the player does not support PlayerFeature.ENQUEUE - or has a flow mode config entry set to True. - """ + @property + def requires_flow_mode(self) -> bool: + """Return if the player needs flow mode.""" if leader := self.sync_leader: - return leader.flow_mode + return leader.requires_flow_mode return False @property @@ -192,9 +187,7 @@ class SyncGroupPlayer(GroupPlayer): ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" entries: list[ConfigEntry] = [ - # default entries for player groups - *await super().get_config_entries(action=action, values=values), - # add syncgroup specific entries + # syncgroup specific entries ConfigEntry( key=CONF_GROUP_MEMBERS, type=ConfigEntryType.STRING, diff --git a/music_assistant/models/core_controller.py b/music_assistant/models/core_controller.py index be91dd25..89332628 100644 --- a/music_assistant/models/core_controller.py +++ b/music_assistant/models/core_controller.py @@ -61,6 +61,22 @@ class CoreController: self._set_logger(log_level) await self.setup(config) + async def update_config(self, config: CoreConfig, changed_keys: set[str]) -> None: + """Handle logic when the config is updated.""" + # default implementation: perform a full reload on any config change + # TODO: only reload when 'requires_reload' keys changed + if changed_keys == {f"values/{CONF_LOG_LEVEL}"}: + # only log level changed, no need to reload + log_value = str(config.get_value(CONF_LOG_LEVEL)) + self._set_logger(log_value) + else: + self.logger.info( + "Config updated, reloading %s core controller", + self.manifest.name, + ) + task_id = f"core_reload_{self.domain}" + self.mass.call_later(1, self.reload, config, task_id=task_id) + def _set_logger(self, log_level: str | None = None) -> None: """Set the logger settings.""" mass_logger = logging.getLogger(MASS_LOGGER_NAME) diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index b1adfb38..98c0b712 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -15,26 +15,13 @@ from collections.abc import Callable from copy import deepcopy from typing import TYPE_CHECKING, Any, cast, final -from music_assistant_models.config_entries import ( - ConfigEntry, - ConfigValueOption, - ConfigValueType, - PlayerConfig, -) from music_assistant_models.constants import ( EXTRA_ATTRIBUTES_TYPES, PLAYER_CONTROL_FAKE, PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE, ) -from music_assistant_models.enums import ( - ConfigEntryType, - HidePlayerOption, - MediaType, - PlaybackState, - PlayerFeature, - PlayerType, -) +from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType from music_assistant_models.errors import UnsupportedFeaturedException from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource from music_assistant_models.player import Player as PlayerState @@ -46,75 +33,21 @@ from music_assistant.constants import ( ATTR_FAKE_MUTE, ATTR_FAKE_POWER, ATTR_FAKE_VOLUME, - CONF_ENTRY_ANNOUNCE_VOLUME, - CONF_ENTRY_ANNOUNCE_VOLUME_MAX, - CONF_ENTRY_ANNOUNCE_VOLUME_MIN, - CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, - CONF_ENTRY_AUTO_PLAY, - CONF_ENTRY_CROSSFADE_DURATION, - CONF_ENTRY_EXPOSE_PLAYER_TO_HA, - CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED, - CONF_ENTRY_FLOW_MODE, - CONF_ENTRY_HIDE_PLAYER_IN_UI, - CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT, - CONF_ENTRY_HIDE_PLAYER_IN_UI_GROUP_PLAYER, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_OUTPUT_CHANNELS, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_OUTPUT_LIMITER, CONF_ENTRY_PLAYER_ICON, - CONF_ENTRY_PLAYER_ICON_GROUP, - CONF_ENTRY_SAMPLE_RATES, - CONF_ENTRY_SMART_FADES_MODE, - CONF_ENTRY_TTS_PRE_ANNOUNCE, - CONF_ENTRY_VOLUME_NORMALIZATION, - CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, CONF_EXPOSE_PLAYER_TO_HA, CONF_FLOW_MODE, - CONF_HIDE_PLAYER_IN_UI, + CONF_HIDE_IN_UI, CONF_MUTE_CONTROL, CONF_POWER_CONTROL, - CONF_PRE_ANNOUNCE_CHIME_URL, + CONF_SMART_FADES_MODE, CONF_VOLUME_CONTROL, ) -from music_assistant.helpers.util import ( - get_changed_dataclass_values, - validate_announcement_chime_url, -) +from music_assistant.helpers.util import get_changed_dataclass_values if TYPE_CHECKING: - from .player_provider import PlayerProvider - - -CONF_ENTRY_PRE_ANNOUNCE_CUSTOM_CHIME_URL = ConfigEntry( - key=CONF_PRE_ANNOUNCE_CHIME_URL, - type=ConfigEntryType.STRING, - label="Custom (pre)announcement chime URL", - description="URL to a custom audio file to play before announcements.\n" - "Leave empty to use the default chime.\n" - "Supports http:// and https:// URLs pointing to " - "audio files (.mp3, .wav, .flac, .ogg, .m4a, .aac).\n" - "Example: http://homeassistant.local:8123/local/audio/custom_chime.mp3", - category="announcements", - required=False, - depends_on=CONF_ENTRY_TTS_PRE_ANNOUNCE.key, - depends_on_value=True, - validate=lambda val: validate_announcement_chime_url(cast("str", val)), -) + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, PlayerConfig -BASE_CONFIG_ENTRIES = [ - # config entries that are valid for all player types - CONF_ENTRY_PLAYER_ICON, - CONF_ENTRY_FLOW_MODE, - CONF_ENTRY_SMART_FADES_MODE, - CONF_ENTRY_CROSSFADE_DURATION, - CONF_ENTRY_VOLUME_NORMALIZATION, - CONF_ENTRY_OUTPUT_LIMITER, - CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, - CONF_ENTRY_TTS_PRE_ANNOUNCE, - CONF_ENTRY_PRE_ANNOUNCE_CUSTOM_CHIME_URL, - CONF_ENTRY_HTTP_PROFILE, -] + from .player_provider import PlayerProvider class Player(ABC): @@ -211,17 +144,22 @@ class Player(ABC): """Return the current playback state of the player.""" return self._attr_playback_state - @cached_property - def flow_mode(self) -> bool: + @property + def requires_flow_mode(self) -> bool: """ Return if the player needs flow mode. Will by default be set to True if the player does not support PlayerFeature.ENQUEUE - or has a flow mode config entry set to True. + or has crossfade enabled without gapless support. """ - if bool(self._config.get_value(CONF_FLOW_MODE)) is True: + if PlayerFeature.ENQUEUE not in self.supported_features: + # without enqueue support, flow mode is required return True - return PlayerFeature.ENQUEUE not in self.supported_features + return ( + # player has crossfade enabled without gapless support - flow mode is required + PlayerFeature.GAPLESS_PLAYBACK not in self.supported_features + and str(self._config.get_value(CONF_SMART_FADES_MODE)) != "disabled" + ) @property def device_info(self) -> DeviceInfo: @@ -588,42 +526,16 @@ class Player(ABC): action: str | None = None, values: dict[str, ConfigValueType] | None = None, ) -> list[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the player. + """ + Return all (provider/player specific) Config Entries for the player. action: [optional] action key called from config entries UI. values: the (intermediate) raw values for config entries sent with the action. """ - # Return all base config entries for a player. - # Feel free to override but ensure to include the base entries by calling super() first. + # Return any (player/provider specific) config entries for a player. # To override the default config entries, simply define an entry with the same key # and it will be used instead of the default one. - return [ - # config entries that are valid for all players - *BASE_CONFIG_ENTRIES, - # add player control entries - *self._create_player_control_config_entries(), - CONF_ENTRY_AUTO_PLAY, - # audio-related config entries - CONF_ENTRY_SAMPLE_RATES, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_OUTPUT_CHANNELS, - # add default entries for announce feature - CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, - CONF_ENTRY_ANNOUNCE_VOLUME, - CONF_ENTRY_ANNOUNCE_VOLUME_MIN, - CONF_ENTRY_ANNOUNCE_VOLUME_MAX, - # add default entries to hide player in UI and expose to HA - ( - CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT - if self.hidden_by_default - else CONF_ENTRY_HIDE_PLAYER_IN_UI - ), - ( - CONF_ENTRY_EXPOSE_PLAYER_TO_HA - if self.expose_to_ha_by_default - else CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED - ), - ] + return [] async def on_config_updated(self) -> None: """ @@ -979,16 +891,13 @@ class Player(ABC): @cached_property @final - def hide_player_in_ui(self) -> set[HidePlayerOption]: + def hide_in_ui(self) -> bool: """ Return the hide player in UI options. This is a convenience property based on the config entry. """ - return { - HidePlayerOption(x) - for x in cast("list[str]", self._config.get_value(CONF_HIDE_PLAYER_IN_UI, [])) - } + return bool(self._config.get_value(CONF_HIDE_IN_UI, self.hidden_by_default)) @cached_property @final @@ -1012,6 +921,19 @@ class Player(ABC): """ return bool(self.mass.players.get_active_queue(self)) + @cached_property + @final + def flow_mode(self) -> bool: + """ + Return if the player needs flow mode. + + Will by default be set to True if the player does not support PlayerFeature.ENQUEUE + or has a flow mode config entry set to True. + """ + if bool(self._config.get_value(CONF_FLOW_MODE)) is True: + return True + return PlayerFeature.ENQUEUE not in self.supported_features + @property @final def state(self) -> PlayerState: @@ -1127,85 +1049,6 @@ class Player(ABC): f"Player {self.display_name} does not support feature {feature.name}" ) - def _create_player_control_config_entries( - self, - ) -> list[ConfigEntry]: - """Create config entries for player controls.""" - all_controls = self.mass.players.player_controls() - power_controls = [x for x in all_controls if x.supports_power] - volume_controls = [x for x in all_controls if x.supports_volume] - mute_controls = [x for x in all_controls if x.supports_mute] - # work out player supported features - supports_power = PlayerFeature.POWER in self.supported_features - supports_volume = PlayerFeature.VOLUME_SET in self.supported_features - supports_mute = PlayerFeature.VOLUME_MUTE in self.supported_features - # create base options per control type (and add defaults like native and fake) - base_power_options: list[ConfigValueOption] = [ - ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), - ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE), - ] - if supports_power: - base_power_options.append( - ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE), - ) - base_volume_options: list[ConfigValueOption] = [ - ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), - ] - if supports_volume: - base_volume_options.append( - ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE), - ) - base_mute_options: list[ConfigValueOption] = [ - ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), - ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE), - ] - if supports_mute: - base_mute_options.append( - ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE), - ) - # return final config entries for all options - return [ - # Power control config entry - ConfigEntry( - key=CONF_POWER_CONTROL, - type=ConfigEntryType.STRING, - label="Power Control", - default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE, - required=True, - options=[ - *base_power_options, - *(ConfigValueOption(x.name, x.id) for x in power_controls), - ], - category="player_controls", - ), - # Volume control config entry - ConfigEntry( - key=CONF_VOLUME_CONTROL, - type=ConfigEntryType.STRING, - label="Volume Control", - default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE, - required=True, - options=[ - *base_volume_options, - *(ConfigValueOption(x.name, x.id) for x in volume_controls), - ], - category="player_controls", - ), - # Mute control config entry - ConfigEntry( - key=CONF_MUTE_CONTROL, - type=ConfigEntryType.STRING, - label="Mute Control", - default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE, - required=True, - options=[ - *base_mute_options, - *[ConfigValueOption(x.name, x.id) for x in mute_controls], - ], - category="player_controls", - ), - ] - def _get_player_media_checksum(self) -> str: """Return a checksum for the current media.""" if not (media := self.current_media): @@ -1254,7 +1097,7 @@ class Player(ABC): current_media=self.current_media, name=self.display_name, enabled=self.enabled, - hide_in_ui="always" in self.hide_player_in_ui, + hide_in_ui=self.hide_in_ui, expose_to_ha=self.expose_to_ha, icon=self.icon, group_volume=self.group_volume, @@ -1553,61 +1396,6 @@ class GroupPlayer(Player): # default implementation: groups can't be synced return None - async def get_config_entries( - self, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, - ) -> list[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the player. - - action: [optional] action key called from config entries UI. - values: the (intermediate) raw values for config entries sent with the action. - """ - # Return all base config entries for a group player. - # Feel free to override but ensure to include the base entries by calling super() first. - # To override the default config entries, simply define an entry with the same key - # and it will be used instead of the default one. - return [ - *BASE_CONFIG_ENTRIES, - CONF_ENTRY_PLAYER_ICON_GROUP, - # add player control entries as hidden entries - ConfigEntry( - key=CONF_POWER_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_POWER_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_VOLUME_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_VOLUME_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_MUTE_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_MUTE_CONTROL, - # disable mute control for group players for now - # TODO: work out if all child players support mute control - default_value=PLAYER_CONTROL_NONE, - hidden=True, - ), - CONF_ENTRY_AUTO_PLAY, - # add default entries to hide player in UI and expose to HA - ( - CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT - if self.hidden_by_default - else CONF_ENTRY_HIDE_PLAYER_IN_UI_GROUP_PLAYER - ), - ( - CONF_ENTRY_EXPOSE_PLAYER_TO_HA - if self.expose_to_ha_by_default - else CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED - ), - ] - async def volume_set(self, volume_level: int) -> None: """ Handle VOLUME_SET command on the player. diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py index 97e4c940..65475efa 100644 --- a/music_assistant/models/provider.py +++ b/music_assistant/models/provider.py @@ -34,17 +34,7 @@ class Provider: self.manifest = manifest self.config = config self._supported_features = supported_features or set() - mass_logger = logging.getLogger(MASS_LOGGER_NAME) - self.logger = mass_logger.getChild(self.domain) - log_level = str(config.get_value(CONF_LOG_LEVEL)) - if log_level == "GLOBAL": - self.logger.setLevel(mass_logger.level) - else: - self.logger.setLevel(log_level) - if logging.getLogger().level > self.logger.level: - # if the root logger's level is higher, we need to adjust that too - logging.getLogger().setLevel(self.logger.level) - self.logger.debug("Log level configured to %s", log_level) + self._set_log_level_from_config(config) self.cache = mass.cache self.available = False @@ -68,6 +58,31 @@ class Provider: is_removed will be set to True when the provider is removed from the configuration. """ + async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None: + """ + Handle logic when the config is updated. + + Override this method in your provider implementation if you need + to perform any additional setup logic after the provider is registered and + the self.config was loaded, and whenever the config changes. + """ + # default implementation: perform a full reload on any config change + # override in your provider if you need more fine-grained control + # such as checking the changed_keys set and only reload when 'requires_reload' keys changed + if changed_keys == {f"values/{CONF_LOG_LEVEL}"}: + # only log level changed, no need to reload + self._set_log_level_from_config(config) + else: + self.logger.info( + "Config updated, reloading provider %s (instance_id=%s)", + self.domain, + self.instance_id, + ) + task_id = f"provider_reload_{self.instance_id}" + self.mass.call_later( + 1, self.mass.load_provider_config, config, self.instance_id, task_id=task_id + ) + async def on_mdns_service_state_change( self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None ) -> None: @@ -128,12 +143,6 @@ class Provider: """Return the stage of this provider.""" return self.manifest.stage - def update_config_value(self, key: str, value: Any, encrypted: bool = False) -> None: - """Update a config value.""" - self.mass.config.set_raw_provider_config_value(self.instance_id, key, value, encrypted) - # also update the cached copy within the provider instance - self.config.values[key].value = value - def unload_with_error(self, error: str) -> None: """Unload provider with error message.""" self.mass.call_later(1, self.mass.unload_provider, self.instance_id, error) @@ -163,3 +172,23 @@ class Provider: raise UnsupportedFeaturedException( f"Provider {self.name} does not support feature {feature.name}" ) + + def _update_config_value(self, key: str, value: Any, encrypted: bool = False) -> None: + """Update a config value.""" + self.mass.config.set_raw_provider_config_value(self.instance_id, key, value, encrypted) + # also update the cached copy within the provider instance + self.config.values[key].value = value + + def _set_log_level_from_config(self, config: ProviderConfig) -> None: + """Set log level from config.""" + mass_logger = logging.getLogger(MASS_LOGGER_NAME) + self.logger = mass_logger.getChild(self.domain) + log_level = str(config.get_value(CONF_LOG_LEVEL)) + if log_level == "GLOBAL": + self.logger.setLevel(mass_logger.level) + else: + self.logger.setLevel(log_level) + if logging.getLogger().level > self.logger.level: + # if the root logger's level is higher, we need to adjust that too + logging.getLogger().setLevel(self.logger.level) + self.logger.debug("Log level configured to %s", log_level) diff --git a/music_assistant/providers/_demo_player_provider/player.py b/music_assistant/providers/_demo_player_provider/player.py index 8f91ffd5..a3da28e6 100644 --- a/music_assistant/providers/_demo_player_provider/player.py +++ b/music_assistant/providers/_demo_player_provider/player.py @@ -93,11 +93,10 @@ class DemoPlayer(Player): # OPTIONAL # this method is optional and should be implemented if you need player specific # configuration entries. If you do not need player specific configuration entries, - # you can leave this method out completely to accept the default implementation. - # Please note that you need to call the super() method to get the default entries. - default_entries = await super().get_config_entries(action=action, values=values) + # you can leave this method out completely. + # note that the config controller will always add a set of default config entries + # if you want, you can override those by specifying the same key as a default entry. return [ - *default_entries, # example of a player specific config entry # you can also override a default entry by specifying the same key # as a default entry, but with a different type or default value. diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index f3b2791f..2193424b 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -9,15 +9,7 @@ from typing import TYPE_CHECKING, cast from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType -from music_assistant.constants import ( - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - CONF_ENTRY_SYNC_ADJUST, - create_sample_rates_config_entry, -) +from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, create_sample_rates_config_entry from music_assistant.models.player import DeviceInfo, Player, PlayerMedia from .constants import ( @@ -131,6 +123,11 @@ class AirPlayPlayer(Player): return False return super().available + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + @property def corrected_elapsed_time(self) -> float: """Return the corrected elapsed time accounting for stream session restarts.""" @@ -149,8 +146,7 @@ class AirPlayPlayer(Player): values: dict[str, ConfigValueType] | None = None, ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_config_entries() - + base_entries: list[ConfigEntry] = [] require_pairing = self._requires_pairing() # Handle pairing actions @@ -159,15 +155,10 @@ class AirPlayPlayer(Player): # Add pairing config entries for Apple TV and macOS devices if require_pairing: - base_entries = [*self._get_pairing_config_entries(values), *base_entries] + base_entries = [*self._get_pairing_config_entries(values)] # Regular AirPlay config entries base_entries += [ - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, ConfigEntry( key=CONF_AIRPLAY_PROTOCOL, type=ConfigEntryType.INTEGER, diff --git a/music_assistant/providers/alexa/__init__.py b/music_assistant/providers/alexa/__init__.py index 752811f7..a5b35164 100644 --- a/music_assistant/providers/alexa/__init__.py +++ b/music_assistant/providers/alexa/__init__.py @@ -21,14 +21,7 @@ from music_assistant_models.enums import ( from music_assistant_models.errors import ActionUnavailable, LoginFailed from music_assistant_models.player import DeviceInfo, PlayerMedia -from music_assistant.constants import ( - CONF_ENTRY_CROSSFADE_DURATION, - CONF_ENTRY_DEPRECATED_CROSSFADE, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE, - CONF_PASSWORD, - CONF_USERNAME, -) +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME from music_assistant.helpers.auth import AuthenticationHelper from music_assistant.models.player import Player from music_assistant.models.player_provider import PlayerProvider @@ -282,6 +275,11 @@ class AlexaPlayer(Player): self._attr_powered = False self._attr_available = True + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + @property def api(self) -> AlexaAPI: """Get the AlexaAPI instance for this player.""" @@ -379,21 +377,6 @@ class AlexaPlayer(Player): self._attr_current_media = media self.update_state() - async def get_config_entries( - self, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, - ) -> list[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_config_entries(action=action, values=values) - return [ - *base_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_DEPRECATED_CROSSFADE, - CONF_ENTRY_CROSSFADE_DURATION, - CONF_ENTRY_HTTP_PROFILE, - ] - class AlexaProvider(PlayerProvider): """Implementation of an Alexa Device Provider.""" diff --git a/music_assistant/providers/ard_audiothek/__init__.py b/music_assistant/providers/ard_audiothek/__init__.py index f9db930a..192137d4 100644 --- a/music_assistant/providers/ard_audiothek/__init__.py +++ b/music_assistant/providers/ard_audiothek/__init__.py @@ -263,10 +263,10 @@ class ARDAudiothek(MusicProvider): self.token, self.user_id, _display_name = await _login( self.mass.http_session, str(_email), str(_password) ) - self.update_config_value(CONF_TOKEN_BEARER, self.token, encrypted=True) - self.update_config_value(CONF_USERID, self.user_id, encrypted=True) - self.update_config_value(CONF_DISPLAY_NAME, _display_name) - self.update_config_value( + self._update_config_value(CONF_TOKEN_BEARER, self.token, encrypted=True) + self._update_config_value(CONF_USERID, self.user_id, encrypted=True) + self._update_config_value(CONF_DISPLAY_NAME, _display_name) + self._update_config_value( CONF_EXPIRY_TIME, str((datetime.now() + timedelta(hours=1)).timestamp()) ) self._client_initialized = False diff --git a/music_assistant/providers/bluesound/player.py b/music_assistant/providers/bluesound/player.py index abad06f0..bd6a5560 100644 --- a/music_assistant/providers/bluesound/player.py +++ b/music_assistant/providers/bluesound/player.py @@ -6,7 +6,6 @@ import asyncio import time from typing import TYPE_CHECKING -from music_assistant_models.config_entries import ConfigEntry, ConfigValueType from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType from music_assistant_models.errors import PlayerCommandFailed from pyblu import Player as BluosPlayer @@ -15,10 +14,8 @@ from pyblu.entities import Input, PairedPlayer, Preset from pyblu.errors import PlayerUnexpectedResponseError, PlayerUnreachableError from music_assistant.constants import ( - CONF_ENTRY_ENABLE_ICY_METADATA, - CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_ENTRY_HTTP_PROFILE_DEFAULT_3, - CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_ICY_METADATA_DEFAULT_FULL, create_sample_rates_config_entry, ) from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource @@ -34,6 +31,8 @@ from music_assistant.providers.bluesound.const import ( ) if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType + from .provider import BluesoundDiscoveryInfo, BluesoundPlayerProvider @@ -76,6 +75,11 @@ class BluesoundPlayer(Player): self._attr_poll_interval = IDLE_POLL_INTERVAL self._attr_can_group_with = {provider.instance_id} + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + async def setup(self) -> None: """Set up the player.""" # Add volume support if available @@ -91,7 +95,6 @@ class BluesoundPlayer(Player): ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the player.""" return [ - *await super().get_config_entries(action=action, values=values), CONF_ENTRY_HTTP_PROFILE_DEFAULT_3, create_sample_rates_config_entry( max_sample_rate=192000, @@ -99,11 +102,7 @@ class BluesoundPlayer(Player): max_bit_depth=24, safe_max_bit_depth=24, ), - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_FLOW_MODE_ENFORCED, - ConfigEntry.from_dict( - {**CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), "default_value": "full"} - ), + CONF_ENTRY_ICY_METADATA_DEFAULT_FULL, ] async def disconnect(self) -> None: diff --git a/music_assistant/providers/builtin/__init__.py b/music_assistant/providers/builtin/__init__.py index b97cdfde..580a95ff 100644 --- a/music_assistant/providers/builtin/__init__.py +++ b/music_assistant/providers/builtin/__init__.py @@ -332,7 +332,7 @@ class BuiltinProvider(MusicProvider): if media_type == MediaType.PLAYLIST and prov_item_id in BUILTIN_PLAYLISTS: # user wants to disable/remove one of our builtin playlists # to prevent it comes back, we mark it as disabled in config - self.update_config_value(prov_item_id, False) + self._update_config_value(prov_item_id, False) return True if media_type == MediaType.TRACK: # regular manual track URL/path diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index ace7a5f1..11575616 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -233,13 +233,6 @@ class ChromecastPlayer(Player): values: dict[str, ConfigValueType] | None = None, ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_config_entries(action=action, values=values) - - # Check if Sendspin provider is available - sendspin_available = any( - prov.domain == "sendspin" for prov in self.mass.get_providers("player") - ) - # Sendspin mode config entry sendspin_config = ConfigEntry( key=CONF_USE_SENDSPIN_MODE, @@ -252,7 +245,7 @@ class ChromecastPlayer(Player): "NOTE: Requires the Sendspin provider to be enabled.", required=False, default_value=False, - hidden=not sendspin_available or self.type == PlayerType.GROUP, + hidden=self.type == PlayerType.GROUP, ) # Sync delay config entry (only visible when sendspin provider is available) @@ -267,7 +260,7 @@ class ChromecastPlayer(Player): required=False, default_value=DEFAULT_SENDSPIN_SYNC_DELAY, range=(-1000, 1000), - hidden=not sendspin_available or self.type == PlayerType.GROUP, + hidden=self.type == PlayerType.GROUP, immediate_apply=True, ) @@ -287,18 +280,16 @@ class ChromecastPlayer(Player): ConfigValueOption("Opus (lossy, experimental)", "opus"), ConfigValueOption("PCM (lossless, uncompressed)", "pcm"), ], - hidden=not sendspin_available or self.type == PlayerType.GROUP, + hidden=self.type == PlayerType.GROUP, ) if self.type == PlayerType.GROUP: return [ - *base_entries, *CAST_PLAYER_CONFIG_ENTRIES, CONF_ENTRY_SAMPLE_RATES_CAST_GROUP, ] return [ - *base_entries, *CAST_PLAYER_CONFIG_ENTRIES, CONF_ENTRY_SAMPLE_RATES_CAST, sendspin_config, diff --git a/music_assistant/providers/dlna/constants.py b/music_assistant/providers/dlna/constants.py index f51d8742..cf8fd27c 100644 --- a/music_assistant/providers/dlna/constants.py +++ b/music_assistant/providers/dlna/constants.py @@ -1,20 +1,13 @@ """Constants for DLNA provider.""" -from music_assistant.constants import ( - CONF_ENTRY_ENABLE_ICY_METADATA, - CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_OUTPUT_CODEC, - create_sample_rates_config_entry, -) +from music_assistant_models.config_entries import ConfigEntry + +from music_assistant.constants import CONF_ENTRY_FLOW_MODE, create_sample_rates_config_entry PLAYER_CONFIG_ENTRIES = [ - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_ENABLE_ICY_METADATA, # enable flow mode by default because # most dlna players do not support enqueueing - CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, + ConfigEntry.from_dict({**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": True}), create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24), ] diff --git a/music_assistant/providers/dlna/player.py b/music_assistant/providers/dlna/player.py index 268bbf41..e230ff08 100644 --- a/music_assistant/providers/dlna/player.py +++ b/music_assistant/providers/dlna/player.py @@ -268,8 +268,7 @@ class DLNAPlayer(Player): values: dict[str, ConfigValueType] | None = None, ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_config_entries(action=action, values=values) - return base_entries + PLAYER_CONFIG_ENTRIES + return [*PLAYER_CONFIG_ENTRIES] # async def on_player_config_change( # self, diff --git a/music_assistant/providers/fully_kiosk/player.py b/music_assistant/providers/fully_kiosk/player.py index d1b30d14..30622d04 100644 --- a/music_assistant/providers/fully_kiosk/player.py +++ b/music_assistant/providers/fully_kiosk/player.py @@ -9,11 +9,7 @@ from typing import TYPE_CHECKING from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, -) +from music_assistant.constants import CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3 from music_assistant.models.player import DeviceInfo, Player, PlayerMedia if TYPE_CHECKING: @@ -51,18 +47,19 @@ class FullyKioskPlayer(Player): self._attr_needs_poll = True self._attr_poll_interval = 10 + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + async def get_config_entries( self, action: str | None = None, values: dict[str, ConfigValueType] | None = None, ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_config_entries(action=action, values=values) return [ - *base_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, - CONF_ENTRY_HTTP_PROFILE, ] def set_attributes(self) -> None: diff --git a/music_assistant/providers/gpodder/__init__.py b/music_assistant/providers/gpodder/__init__.py index 3918fa00..44e80d24 100644 --- a/music_assistant/providers/gpodder/__init__.py +++ b/music_assistant/providers/gpodder/__init__.py @@ -269,7 +269,7 @@ class GPodder(MusicProvider): assert nc_url is not None self._client.init_nc(base_url=nc_url, nc_token=str(nc_token)) else: - self.update_config_value(CONF_USING_GPODDER, True) + self._update_config_value(CONF_USING_GPODDER, True) if _username is None or _password is None or _device_id is None: raise LoginFailed("Must provide username, password and device_id.") username = str(_username) diff --git a/music_assistant/providers/hass_players/player.py b/music_assistant/providers/hass_players/player.py index 2e16aaae..4f3f6f9b 100644 --- a/music_assistant/providers/hass_players/player.py +++ b/music_assistant/providers/hass_players/player.py @@ -10,11 +10,7 @@ from hass_client.exceptions import FailedCommand from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType from music_assistant.constants import ( - CONF_ENTRY_ENABLE_ICY_METADATA, CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN, - CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE, CONF_ENTRY_HTTP_PROFILE_FORCED_2, CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES, @@ -43,12 +39,7 @@ if TYPE_CHECKING: from music_assistant_models.config_entries import ConfigEntry, ConfigValueType -DEFAULT_PLAYER_CONFIG_ENTRIES = ( - CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_ENABLE_ICY_METADATA, - CONF_ENTRY_FLOW_MODE_ENFORCED, -) +DEFAULT_PLAYER_CONFIG_ENTRIES = (CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,) class HomeAssistantPlayer(Player): @@ -106,14 +97,19 @@ class HomeAssistantPlayer(Player): self.extra_data["hass_supported_features"] = hass_supported_features self._update_attributes(hass_state["attributes"]) + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + # hass media players are a hot mess so play it safe and always use flow mode + return True + async def get_config_entries( self, action: str | None = None, values: dict[str, ConfigValueType] | None = None, ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the player.""" - base_entries = await super().get_config_entries(action=action, values=values) - base_entries = [*base_entries, *DEFAULT_PLAYER_CONFIG_ENTRIES] + base_entries = [*DEFAULT_PLAYER_CONFIG_ENTRIES] if self.extra_data.get("esphome_supported_audio_formats"): # optimized config for new ESPHome mediaplayer supported_sample_rates: list[int] = [] @@ -141,7 +137,6 @@ class HomeAssistantPlayer(Player): config_entries = [ *base_entries, # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits - CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_ENTRY_HTTP_PROFILE_FORCED_2, ] @@ -168,10 +163,6 @@ class HomeAssistantPlayer(Player): if self.extra_data.get("hass_domain") in WARN_HASS_INTEGRATIONS: base_entries = [CONF_ENTRY_WARN_HASS_INTEGRATION, *base_entries] - # enable flow mode by default if player does not report enqueue support - if MediaPlayerEntityFeature.MEDIA_ENQUEUE not in self.extra_data["hass_supported_features"]: - base_entries = [*base_entries, CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED] - return base_entries async def play(self) -> None: diff --git a/music_assistant/providers/heos/player.py b/music_assistant/providers/heos/player.py index fb0a878e..783d792e 100644 --- a/music_assistant/providers/heos/player.py +++ b/music_assistant/providers/heos/player.py @@ -10,17 +10,11 @@ from music_assistant_models.errors import SetupFailedError from music_assistant_models.player import DeviceInfo, PlayerSource from pyheos import Heos, const -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - create_sample_rates_config_entry, -) +from music_assistant.constants import create_sample_rates_config_entry from music_assistant.models.player import Player, PlayerMedia from music_assistant.providers.heos.helpers import media_uri_from_now_playing_media -from .constants import ( - HEOS_MEDIA_TYPE_TO_MEDIA_TYPE, - HEOS_PLAY_STATE_TO_PLAYBACK_STATE, -) +from .constants import HEOS_MEDIA_TYPE_TO_MEDIA_TYPE, HEOS_PLAY_STATE_TO_PLAYBACK_STATE if TYPE_CHECKING: from music_assistant_models.config_entries import ConfigEntry, ConfigValueType @@ -45,6 +39,11 @@ class HeosPlayer(Player): _heos: Heos _device: PyHeosPlayer + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + def __init__(self, provider: HeosPlayerProvider, device: PyHeosPlayer) -> None: """Initialize the Player.""" super().__init__(provider, str(device.player_id)) @@ -296,7 +295,6 @@ class HeosPlayer(Player): ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the player.""" return [ - *await super().get_config_entries(action=action, values=values), # Gen 1 devices, like HEOS Link, only support up to 48kHz/16bit create_sample_rates_config_entry( max_sample_rate=192000, @@ -304,5 +302,4 @@ class HeosPlayer(Player): max_bit_depth=24, safe_max_bit_depth=16, ), - CONF_ENTRY_FLOW_MODE_ENFORCED, ] diff --git a/music_assistant/providers/musiccast/constants.py b/music_assistant/providers/musiccast/constants.py index f680ef88..b9aeaede 100644 --- a/music_assistant/providers/musiccast/constants.py +++ b/music_assistant/providers/musiccast/constants.py @@ -1,17 +1,25 @@ """Constants for the MusicCast provider.""" +from music_assistant_models.config_entries import ConfigEntry + from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, + CONF_ENTRY_FLOW_MODE, CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED, - CONF_ENTRY_OUTPUT_CODEC, create_sample_rates_config_entry, ) # Constants for players # both the http profile and icy didn't matter for me testing it. +CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED = ConfigEntry.from_dict( + { + **CONF_ENTRY_FLOW_MODE.to_dict(), + "default_value": False, + "value": False, + "hidden": True, + } +) PLAYER_CONFIG_ENTRIES = [ - CONF_ENTRY_OUTPUT_CODEC, CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, diff --git a/music_assistant/providers/podcast_index/provider.py b/music_assistant/providers/podcast_index/provider.py index b2d02a17..0cf41fba 100644 --- a/music_assistant/providers/podcast_index/provider.py +++ b/music_assistant/providers/podcast_index/provider.py @@ -173,7 +173,7 @@ class PodcastIndexProvider(MusicProvider): self.logger.debug("Adding podcast %s to library", item.name) stored_podcasts.append(feed_url) - self.update_config_value(CONF_STORED_PODCASTS, stored_podcasts) + self._update_config_value(CONF_STORED_PODCASTS, stored_podcasts) return True async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: @@ -205,7 +205,7 @@ class PodcastIndexProvider(MusicProvider): self.logger.debug("Removing podcast %s from library", prov_item_id) stored_podcasts = [x for x in stored_podcasts if x != feed_url] - self.update_config_value(CONF_STORED_PODCASTS, stored_podcasts) + self._update_config_value(CONF_STORED_PODCASTS, stored_podcasts) return True @use_cache(3600 * 24 * 14) # Cache for 14 days diff --git a/music_assistant/providers/radiobrowser/__init__.py b/music_assistant/providers/radiobrowser/__init__.py index c8fd2635..d384d483 100644 --- a/music_assistant/providers/radiobrowser/__init__.py +++ b/music_assistant/providers/radiobrowser/__init__.py @@ -270,7 +270,7 @@ class RadioBrowserProvider(MusicProvider): return False self.logger.debug("Adding radio %s to stored radios", item.item_id) stored_radios = [*stored_radios, item.item_id] - self.update_config_value(CONF_STORED_RADIOS, stored_radios) + self._update_config_value(CONF_STORED_RADIOS, stored_radios) return True async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: @@ -282,7 +282,7 @@ class RadioBrowserProvider(MusicProvider): return False self.logger.debug("Removing radio %s from stored radios", prov_item_id) stored_radios = [x for x in stored_radios if x != prov_item_id] - self.update_config_value(CONF_STORED_RADIOS, stored_radios) + self._update_config_value(CONF_STORED_RADIOS, stored_radios) return True @use_cache(3600 * 6) # Cache for 6 hours diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 6e4625f7..2a64524f 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -29,7 +29,6 @@ from aiosendspin.server.group import ( ) from aiosendspin.server.metadata import Metadata from aiosendspin.server.stream import AudioCodec, MediaStream -from music_assistant_models.config_entries import ConfigEntry from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( ContentType, @@ -43,15 +42,7 @@ from music_assistant_models.media_items import AudioFormat from music_assistant_models.player import DeviceInfo from PIL import Image -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE_HIDDEN, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - CONF_ENTRY_SAMPLE_RATES, - CONF_OUTPUT_CHANNELS, - CONF_OUTPUT_CODEC, - INTERNAL_PCM_FORMAT, -) +from music_assistant.constants import CONF_OUTPUT_CHANNELS, CONF_OUTPUT_CODEC, INTERNAL_PCM_FORMAT from music_assistant.helpers.audio import get_player_filter_params from music_assistant.models.player import Player, PlayerMedia @@ -73,7 +64,6 @@ SUPPORTED_GROUP_COMMANDS = [ if TYPE_CHECKING: from aiosendspin.server.client import SendspinClient - from music_assistant_models.config_entries import ConfigValueType from music_assistant_models.player_queue import PlayerQueue from music_assistant_models.queue_item import QueueItem @@ -114,6 +104,11 @@ class MusicAssistantMediaStream(MediaStream): self.internal_format = internal_format self.output_format = output_format + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + async def player_channel( self, player_id: str, @@ -191,6 +186,11 @@ class SendspinPlayer(Player): timed_client_stream: TimedClientStream | None = None is_web_player: bool = False + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + def __init__(self, provider: SendspinProvider, player_id: str) -> None: """Initialize the Player.""" super().__init__(provider, player_id) @@ -595,21 +595,6 @@ class SendspinPlayer(Player): # Send metadata to the group self.api.group.set_metadata(metadata) - async def get_config_entries( - self, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, - ) -> list[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the player.""" - default_entries = await super().get_config_entries(action=action, values=values) - return [ - *default_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - CONF_ENTRY_HTTP_PROFILE_HIDDEN, - ConfigEntry.from_dict({**CONF_ENTRY_SAMPLE_RATES.to_dict(), "hidden": True}), - ] - async def on_unload(self) -> None: """Handle logic when the player is unloaded from the Player controller.""" await super().on_unload() diff --git a/music_assistant/providers/snapcast/player.py b/music_assistant/providers/snapcast/player.py index 5e2ef7d0..221a3f74 100644 --- a/music_assistant/providers/snapcast/player.py +++ b/music_assistant/providers/snapcast/player.py @@ -14,11 +14,7 @@ from snapcast.control.client import Snapclient from snapcast.control.group import Snapgroup from snapcast.control.stream import Snapstream -from music_assistant.constants import ( - ATTR_ANNOUNCEMENT_IN_PROGRESS, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, -) +from music_assistant.constants import ATTR_ANNOUNCEMENT_IN_PROGRESS from music_assistant.helpers.audio import get_player_filter_params from music_assistant.helpers.compare import create_safe_string from music_assistant.helpers.ffmpeg import FFMpeg @@ -52,6 +48,11 @@ class SnapCastPlayer(Player): super().__init__(provider, player_id) self._stream_task: asyncio.Task[None] | None = None + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + @property def synced_to(self) -> str | None: """ @@ -292,12 +293,8 @@ class SnapCastPlayer(Player): values: dict[str, ConfigValueType] | None = None, ) -> list[ConfigEntry]: """Player config.""" - base_entries = await super().get_config_entries(action=action, values=values) return [ - *base_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_ENTRY_SAMPLE_RATES_SNAPCAST, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, ] def _handle_player_update(self, snap_client: Snapclient) -> None: diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 02db85f8..4417fc6c 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -34,7 +34,6 @@ from music_assistant_models.player import PlayerMedia from music_assistant.constants import ( CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, - CONF_ENTRY_OUTPUT_CODEC, create_sample_rates_config_entry, ) from music_assistant.helpers.tags import async_parse_tags @@ -197,8 +196,6 @@ class SonosPlayer(Player): ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the player.""" base_entries = [ - *await super().get_config_entries(action=action, values=values), - CONF_ENTRY_OUTPUT_CODEC, CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, create_sample_rates_config_entry( # set safe max bit depth to 16 bits because the older Sonos players diff --git a/music_assistant/providers/sonos_s1/player.py b/music_assistant/providers/sonos_s1/player.py index 6cf87fd4..1e4e57e2 100644 --- a/music_assistant/providers/sonos_s1/player.py +++ b/music_assistant/providers/sonos_s1/player.py @@ -20,13 +20,7 @@ from soco import SoCoException from soco.core import MUSIC_SRC_RADIO, SoCo from soco.data_structures import DidlAudioBroadcast -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, - CONF_ENTRY_HTTP_PROFILE_DEFAULT_1, - CONF_ENTRY_OUTPUT_CODEC, - VERBOSE_LOG_LEVEL, - create_sample_rates_config_entry, -) +from music_assistant.constants import VERBOSE_LOG_LEVEL, create_sample_rates_config_entry from music_assistant.helpers.upnp import create_didl_metadata from music_assistant.models.player import DeviceInfo, Player, PlayerMedia @@ -131,14 +125,10 @@ class SonosPlayer(Player): ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the player.""" return [ - *await super().get_config_entries(action=action, values=values), - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, - CONF_ENTRY_HTTP_PROFILE_DEFAULT_1, - CONF_ENTRY_OUTPUT_CODEC, create_sample_rates_config_entry( supported_sample_rates=[44100, 48000], supported_bit_depths=[16], - hidden=False, + hidden=True, ), ] diff --git a/music_assistant/providers/spotify/provider.py b/music_assistant/providers/spotify/provider.py index f74cc79f..bc1ddeb4 100644 --- a/music_assistant/providers/spotify/provider.py +++ b/music_assistant/providers/spotify/provider.py @@ -830,7 +830,7 @@ class SpotifyProvider(MusicProvider): except LoginFailed as err: if "revoked" in str(err): # clear refresh token if it's invalid - self.update_config_value(CONF_REFRESH_TOKEN_GLOBAL, None) + self._update_config_value(CONF_REFRESH_TOKEN_GLOBAL, None) if self.available: self.unload_with_error(str(err)) elif self.available: @@ -841,7 +841,7 @@ class SpotifyProvider(MusicProvider): # make sure that our updated creds get stored in memory + config self._auth_info_global = auth_info - self.update_config_value( + self._update_config_value( CONF_REFRESH_TOKEN_GLOBAL, auth_info["refresh_token"], encrypted=True ) @@ -889,8 +889,8 @@ class SpotifyProvider(MusicProvider): except LoginFailed as err: if "revoked" in str(err): # clear refresh token if it's invalid - self.update_config_value(CONF_REFRESH_TOKEN_DEV, None) - self.update_config_value(CONF_CLIENT_ID, None) + self._update_config_value(CONF_REFRESH_TOKEN_DEV, None) + self._update_config_value(CONF_CLIENT_ID, None) # Don't unload - we can still use the global session self.dev_session_active = False self.logger.warning(str(err)) @@ -898,7 +898,9 @@ class SpotifyProvider(MusicProvider): # make sure that our updated creds get stored in memory + config self._auth_info_dev = auth_info - self.update_config_value(CONF_REFRESH_TOKEN_DEV, auth_info["refresh_token"], encrypted=True) + self._update_config_value( + CONF_REFRESH_TOKEN_DEV, auth_info["refresh_token"], encrypted=True + ) # Setup librespot with dev token (preferred over global token) await self._setup_librespot_auth(auth_info["access_token"]) diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py index 9e3d7c35..2a7287e2 100644 --- a/music_assistant/providers/squeezelite/player.py +++ b/music_assistant/providers/squeezelite/player.py @@ -27,11 +27,7 @@ from music_assistant_models.errors import InvalidCommand, MusicAssistantError from music_assistant_models.media_items import AudioFormat from music_assistant.constants import ( - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, CONF_ENTRY_HTTP_PROFILE_FORCED_2, - CONF_ENTRY_OUTPUT_CODEC, CONF_ENTRY_SUPPORT_GAPLESS_DIFFERENT_SAMPLE_RATES, CONF_ENTRY_SYNC_ADJUST, INTERNAL_PCM_FORMAT, @@ -158,10 +154,6 @@ class SqueezelitePlayer(Player): return [ *base_entries, *preset_entries, - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, - CONF_ENTRY_OUTPUT_CODEC, CONF_ENTRY_SYNC_ADJUST, CONF_ENTRY_DISPLAY, CONF_ENTRY_VISUALIZATION, diff --git a/music_assistant/providers/tidal/provider.py b/music_assistant/providers/tidal/provider.py index d263c478..5ab39c9d 100644 --- a/music_assistant/providers/tidal/provider.py +++ b/music_assistant/providers/tidal/provider.py @@ -88,10 +88,10 @@ class TidalProvider(MusicProvider): def _update_auth_config(self, auth_info: dict[str, Any]) -> None: """Update auth config with new auth info.""" - self.update_config_value(CONF_AUTH_TOKEN, auth_info["access_token"], encrypted=True) - self.update_config_value(CONF_REFRESH_TOKEN, auth_info["refresh_token"], encrypted=True) - self.update_config_value(CONF_EXPIRY_TIME, auth_info["expires_at"]) - self.update_config_value(CONF_USER_ID, auth_info["userId"]) + self._update_config_value(CONF_AUTH_TOKEN, auth_info["access_token"], encrypted=True) + self._update_config_value(CONF_REFRESH_TOKEN, auth_info["refresh_token"], encrypted=True) + self._update_config_value(CONF_EXPIRY_TIME, auth_info["expires_at"]) + self._update_config_value(CONF_USER_ID, auth_info["userId"]) async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" @@ -107,7 +107,7 @@ class TidalProvider(MusicProvider): try: dt = datetime.fromisoformat(expires_at) expires_at = dt.timestamp() - self.update_config_value(CONF_EXPIRY_TIME, expires_at) + self._update_config_value(CONF_EXPIRY_TIME, expires_at) except ValueError: expires_at = 0 diff --git a/music_assistant/providers/universal_group/player.py b/music_assistant/providers/universal_group/player.py index b1158820..de9d370c 100644 --- a/music_assistant/providers/universal_group/player.py +++ b/music_assistant/providers/universal_group/player.py @@ -24,7 +24,6 @@ from propcache import under_cached_property as cached_property from music_assistant.constants import ( CONF_DYNAMIC_GROUP_MEMBERS, - CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_GROUP_MEMBERS, CONF_HTTP_PROFILE, DEFAULT_STREAM_HEADERS, @@ -85,6 +84,11 @@ class UniversalGroupPlayer(GroupPlayer): } self._set_attributes() + @property + def requires_flow_mode(self) -> bool: + """Return if the player requires flow mode.""" + return True + async def on_config_updated(self) -> None: """Handle logic when the player is loaded or updated.""" static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) @@ -108,8 +112,6 @@ class UniversalGroupPlayer(GroupPlayer): ) -> list[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" return [ - # default entries for player groups - *await super().get_config_entries(action=action, values=values), # add universal group specific entries CONFIG_ENTRY_UGP_NOTE, ConfigEntry( @@ -135,7 +137,6 @@ class UniversalGroupPlayer(GroupPlayer): required=False, ), CONF_ENTRY_SAMPLE_RATES_UGP, - CONF_ENTRY_FLOW_MODE_ENFORCED, ] async def stop(self) -> None: