/
/
1"""Settings (settings.json) migration logic for the config controller."""
2
3from __future__ import annotations
4
5import logging
6import re
7from pathlib import Path, PurePosixPath
8from typing import TYPE_CHECKING, Any
9
10from music_assistant_models.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE
11from music_assistant_models.enums import CrossfadeMode
12from music_assistant_models.errors import InvalidDataError
13
14from music_assistant.constants import (
15 CONF_CORE,
16 CONF_CROSSFADE_DURATION,
17 CONF_CROSSFADE_MODE,
18 CONF_HTTP_PROFILE,
19 CONF_ICON,
20 CONF_LINKED_PROTOCOL_IDS,
21 CONF_NFS_SUBFOLDER_MIGRATED,
22 CONF_PLAYER_DSP,
23 CONF_PLAYER_QUEUES,
24 CONF_PLAYERS,
25 CONF_PROTOCOL_PARENT_ID,
26 CONF_PROVIDERS,
27 CONF_SMART_FADES_MODE,
28 CONF_VALUE_DISABLED,
29 CONF_VALUE_ENABLED,
30 CONF_VOLUME_NORMALIZATION,
31 CONF_VOLUME_NORMALIZATION_TARGET,
32)
33from music_assistant.controllers.player_queues.constants import (
34 CONF_SMART_SHUFFLE_ARTIST_RECENCY,
35 CONF_SMART_SHUFFLE_DUPLICATE_GAP,
36 CONF_SMART_SHUFFLE_ENABLED,
37 CONF_SMART_SHUFFLE_SONG_RECENCY,
38)
39from music_assistant.helpers.config_entries import CONF_CONNECTED_PLAYERS
40
41if TYPE_CHECKING:
42 from collections.abc import Callable
43
44 from music_assistant_models.config_entries import ConfigValueType
45
46LOGGER = logging.getLogger(__name__)
47
48# removed player config key, only referenced by its migration
49LEGACY_CONF_OUTPUT_LIMITER = "output_limiter"
50
51# removed automatic-player-selection sentinel of the connected-player plugins, only
52# referenced by their migration
53LEGACY_PLAYER_ID_AUTO = "__auto__"
54
55# shared prefix of the removed per-player Bose SoundTouch preset keys
56LEGACY_BOSE_PRESET_KEY_PREFIX = "preset_"
57
58# removed hass provider config keys, only referenced by their migration
59LEGACY_CONF_TTS_ENTITY = "tts_entity"
60LEGACY_CONF_AI_TASK_ENTITY = "ai_task_entity"
61
62# engine selection keys of the providers that consume the plugin engines
63CONF_AI_ENGINE = "ai_engine"
64CONF_TTS_ENGINE = "tts_engine"
65
66
67# Canonical ids of the shared icon set (music-assistant/shared-icons v0.3.0);
68# stored icon values already in this set are never touched by the icon migration.
69_CANONICAL_ICON_IDS: frozenset[str] = frozenset(
70 (
71 "homepod-mini",
72 "sonos",
73 "mac",
74 "apple-tv",
75 "google-nest",
76 "voice-pe",
77 "wiim",
78 "speaker",
79 "speakers",
80 "soundbar",
81 "radio",
82 "tv",
83 "monitor",
84 "laptop",
85 "smartphone",
86 "tablet",
87 "headphones",
88 "bluetooth",
89 "airplay",
90 "cast",
91 "car",
92 "music",
93 "vinyl",
94 "mic",
95 "volume",
96 "living-room",
97 "bedroom",
98 "bathroom",
99 "toilet",
100 "kitchen",
101 "office",
102 "hallway",
103 "garden",
104 "outdoor",
105 "sun",
106 "home",
107 "building",
108 )
109)
110
111# Legacy stored player icon values (mdi-* names and pre-1.0 picker names) mapped to
112# the closest canonical id of the shared icon set. Sourced from
113# https://github.com/music-assistant/shared-icons/blob/main/migration/legacy-map.json
114_LEGACY_ICON_MAP: dict[str, str] = {
115 "apple-homepod-mini": "homepod-mini",
116 "appletv": "apple-tv",
117 "armchair": "living-room",
118 "audio-lines": "volume",
119 "bath": "bathroom",
120 "bed": "bedroom",
121 "bed-double": "bedroom",
122 "bed-single": "bedroom",
123 "bluetooth-speaker": "bluetooth",
124 "boom-box": "radio",
125 "boombox": "radio",
126 "briefcase": "office",
127 "building-2": "building",
128 "cassette-tape": "music",
129 "chef-hat": "kitchen",
130 "cooking-pot": "kitchen",
131 "disc": "vinyl",
132 "disc-2": "vinyl",
133 "disc-3": "vinyl",
134 "disc-album": "vinyl",
135 "door-closed": "hallway",
136 "door-open": "hallway",
137 "drum": "music",
138 "flower": "garden",
139 "flower-2": "garden",
140 "guitar": "music",
141 "headset": "headphones",
142 "homepod": "homepod-mini",
143 "hotel": "building",
144 "house": "home",
145 "lamp-desk": "office",
146 "lamp-floor": "living-room",
147 "laptop-2": "laptop",
148 "laptop-minimal": "laptop",
149 "leaf": "garden",
150 "mdi-airplay": "airplay",
151 "mdi-album": "vinyl",
152 "mdi-amplifier": "speaker",
153 "mdi-antenna": "radio",
154 "mdi-apple": "apple-tv",
155 "mdi-apple-airplay": "airplay",
156 "mdi-audio-video": "speaker",
157 "mdi-audio-video-remote": "speaker",
158 "mdi-balcony": "outdoor",
159 "mdi-bathtub": "bathroom",
160 "mdi-bathtub-outline": "bathroom",
161 "mdi-bed": "bedroom",
162 "mdi-bed-empty": "bedroom",
163 "mdi-bed-king": "bedroom",
164 "mdi-bed-queen": "bedroom",
165 "mdi-bluetooth": "bluetooth",
166 "mdi-bluetooth-audio": "bluetooth",
167 "mdi-bookshelf": "office",
168 "mdi-boombox": "radio",
169 "mdi-briefcase": "office",
170 "mdi-bullhorn": "volume",
171 "mdi-bunk-bed": "bedroom",
172 "mdi-car": "car",
173 "mdi-car-estate": "car",
174 "mdi-car-hatchback": "car",
175 "mdi-car-side": "car",
176 "mdi-cast": "cast",
177 "mdi-cast-audio": "cast",
178 "mdi-cast-connected": "cast",
179 "mdi-cast-variant": "airplay",
180 "mdi-cellphone": "smartphone",
181 "mdi-cellphone-sound": "smartphone",
182 "mdi-cellphone-wireless": "smartphone",
183 "mdi-chair-rolling": "office",
184 "mdi-chef-hat": "kitchen",
185 "mdi-city": "building",
186 "mdi-coat-rack": "hallway",
187 "mdi-coffee": "kitchen",
188 "mdi-coffee-maker": "kitchen",
189 "mdi-countertop": "kitchen",
190 "mdi-desk": "office",
191 "mdi-desk-lamp": "office",
192 "mdi-desktop-classic": "monitor",
193 "mdi-desktop-mac": "mac",
194 "mdi-desktop-tower": "monitor",
195 "mdi-desktop-tower-monitor": "monitor",
196 "mdi-disc": "vinyl",
197 "mdi-disc-player": "vinyl",
198 "mdi-domain": "building",
199 "mdi-door": "hallway",
200 "mdi-door-closed": "hallway",
201 "mdi-door-open": "hallway",
202 "mdi-earbuds": "headphones",
203 "mdi-earbuds-outline": "headphones",
204 "mdi-flower": "garden",
205 "mdi-flower-outline": "garden",
206 "mdi-flower-tulip": "garden",
207 "mdi-forest": "outdoor",
208 "mdi-fridge": "kitchen",
209 "mdi-fridge-outline": "kitchen",
210 "mdi-garage": "car",
211 "mdi-garage-variant": "car",
212 "mdi-google-assistant": "google-nest",
213 "mdi-google-home": "google-nest",
214 "mdi-grass": "garden",
215 "mdi-grill": "outdoor",
216 "mdi-guitar-acoustic": "music",
217 "mdi-guitar-electric": "music",
218 "mdi-headphones": "headphones",
219 "mdi-headset": "headphones",
220 "mdi-home": "home",
221 "mdi-home-city": "building",
222 "mdi-home-modern": "home",
223 "mdi-home-outline": "home",
224 "mdi-home-variant": "home",
225 "mdi-hot-tub": "bathroom",
226 "mdi-karaoke": "mic",
227 "mdi-laptop": "laptop",
228 "mdi-laptop-mac": "mac",
229 "mdi-microphone": "mic",
230 "mdi-microphone-variant": "mic",
231 "mdi-monitor": "monitor",
232 "mdi-monitor-speaker": "speaker",
233 "mdi-music": "music",
234 "mdi-music-box": "music",
235 "mdi-music-circle": "music",
236 "mdi-music-clef-treble": "music",
237 "mdi-music-note": "music",
238 "mdi-nature": "outdoor",
239 "mdi-office-building": "building",
240 "mdi-office-building-outline": "building",
241 "mdi-palm-tree": "outdoor",
242 "mdi-patio-heater": "outdoor",
243 "mdi-piano": "music",
244 "mdi-pine-tree": "outdoor",
245 "mdi-podcast": "mic",
246 "mdi-pool": "outdoor",
247 "mdi-pot-steam": "kitchen",
248 "mdi-projector": "tv",
249 "mdi-projector-screen": "tv",
250 "mdi-radio": "radio",
251 "mdi-radio-tower": "radio",
252 "mdi-record": "vinyl",
253 "mdi-record-player": "vinyl",
254 "mdi-saxophone": "music",
255 "mdi-shower": "bathroom",
256 "mdi-shower-head": "bathroom",
257 "mdi-silverware": "kitchen",
258 "mdi-silverware-fork": "kitchen",
259 "mdi-silverware-fork-knife": "kitchen",
260 "mdi-silverware-variant": "kitchen",
261 "mdi-sofa": "living-room",
262 "mdi-sofa-outline": "living-room",
263 "mdi-sofa-single": "living-room",
264 "mdi-soundbar": "soundbar",
265 "mdi-speaker": "speaker",
266 "mdi-speaker-bluetooth": "bluetooth",
267 "mdi-speaker-multiple": "speakers",
268 "mdi-speaker-wireless": "speaker",
269 "mdi-sprout": "garden",
270 "mdi-stairs": "hallway",
271 "mdi-stove": "kitchen",
272 "mdi-surround-sound": "speakers",
273 "mdi-tablet": "tablet",
274 "mdi-television": "tv",
275 "mdi-television-box": "tv",
276 "mdi-television-classic": "tv",
277 "mdi-television-guide": "tv",
278 "mdi-theater": "tv",
279 "mdi-toilet": "toilet",
280 "mdi-tree": "outdoor",
281 "mdi-tree-outline": "outdoor",
282 "mdi-truck": "car",
283 "mdi-trumpet": "music",
284 "mdi-turntable": "vinyl",
285 "mdi-violin": "music",
286 "mdi-volume-high": "volume",
287 "mdi-volume-low": "volume",
288 "mdi-volume-medium": "volume",
289 "mdi-watering-can": "garden",
290 "mdi-weather-sunny": "sun",
291 "mdi-white-balance-sunny": "sun",
292 "megaphone": "volume",
293 "mic-vocal": "mic",
294 "microphone": "mic",
295 "microwave": "kitchen",
296 "monitor-speaker": "speaker",
297 "music-2": "music",
298 "music-3": "music",
299 "music-4": "music",
300 "piano": "music",
301 "podcast": "mic",
302 "projector": "tv",
303 "radio-receiver": "radio",
304 "radio-tower": "radio",
305 "refrigerator": "kitchen",
306 "screen-share": "cast",
307 "shower-head": "bathroom",
308 "sofa": "living-room",
309 "speaker-group": "speakers",
310 "speaker-loud": "volume",
311 "speaker-multiple": "speakers",
312 "sprout": "garden",
313 "television": "tv",
314 "tent-tree": "outdoor",
315 "toilet": "toilet",
316 "tree": "outdoor",
317 "tree-deciduous": "outdoor",
318 "tree-pine": "outdoor",
319 "trees": "outdoor",
320 "tv-2": "tv",
321 "tv-minimal": "tv",
322 "tv-minimal-play": "tv",
323 "utensils": "kitchen",
324 "utensils-crossed": "kitchen",
325 "volume-1": "volume",
326 "volume-2": "volume",
327}
328
329# Config keys each provider's setup flow owns: the keys it reads back with
330# get_setup_value / get_provider_setup_value (and rotates via _update_setup_data) from
331# `setup_data` rather than `values`. The one-off migrate_provider_setup_data step below
332# moves these keys from the raw `values` dict into `setup_data` (encrypting string values
333# at rest) for installs that were configured before setup flows existed. Keys that stayed
334# regular options (quality, sync toggles, output settings, ...) are intentionally absent:
335# they keep being read via get_config_value from `values`.
336# The literal strings are the persisted config keys, not the CONF_* symbol names; some
337# providers redefine common names (e.g. the Hue bridge stores its user under
338# "hue_username", the scrobblers under "_username", Open Subsonic its url under "baseURL").
339# Notes on the non-obvious entries:
340# - the filesystem providers' "content_type" is also surfaced by get_config_entries, but
341# only as a read-only mirror that carries the setup value as its default (so it is never
342# persisted back to values), so moving it to setup_data is safe and required (it is read
343# via get_provider_setup_value).
344# - hass "url"/"token"/"verify_ssl": on a Home Assistant add-on these come from fixed
345# (hidden) config entries whose values equal what a stored copy would hold, so moving a
346# stored copy is a harmless no-op there while restoring normal installs.
347# - plex_connect's "plex_provider_id"/"mass_player_id" are not secrets, but its setup flow
348# collects them (the options are only known from live server state), so they live in
349# setup_data like any other flow-collected value.
350# - spotify is deliberately absent: its own _migrate_legacy_token still reads the legacy
351# "refresh_token" and "client_id" from `values`, so this migration must not move them.
352# TODO: remove after 2.13 release
353PROVIDER_SETUP_FLOW_KEYS: dict[str, tuple[str, ...]] = {
354 "alexa": ("url", "username", "password", "api_url", "api_username", "api_password"),
355 "amplipi": ("host",),
356 "apple_music": ("music_app_token", "music_user_token", "music_user_manual_token"),
357 "airplay_receiver": ("mass_player_id", "airplay_name"),
358 "ard_audiothek": ("email", "password", "token", "user_id", "token_expiry", "display_name"),
359 "ariacast_receiver": ("mass_player_id",),
360 "audible": ("auth_file", "locale"),
361 "audiobookshelf": ("url", "username", "password", "token", "api_token", "verify_ssl"),
362 "bandcamp": ("identity",),
363 "bbc_sounds": ("username", "password"),
364 "bose_soundtouch": ("app_key",),
365 "deezer": ("arl_token",),
366 "digitally_incorporated": ("listen_key",),
367 "emby": ("ip_address", "username", "password"),
368 "filesystem_google_drive": (
369 "content_type",
370 "client_id",
371 "client_secret",
372 "folder_id",
373 "refresh_token",
374 ),
375 "filesystem_local": ("content_type", "path"),
376 "filesystem_nfs": ("content_type", "host", "export_path", "subfolder", "nfs_version"),
377 "filesystem_onedrive": (
378 "content_type",
379 "client_id",
380 "client_secret",
381 "folder_id",
382 "refresh_token",
383 ),
384 "filesystem_smb": (
385 "content_type",
386 "host",
387 "share",
388 "username",
389 "password",
390 "subfolder",
391 "smb_version",
392 ),
393 "gpodder": ("url", "username", "password", "device_id", "token", "url_nc", "verify_ssl"),
394 "hass": ("url", "token", "verify_ssl"),
395 "hue_entertainment": ("bridge_host", "hue_username", "hue_clientkey", "bridge_id"),
396 "ibroadcast": ("username", "password"),
397 "jellyfin": ("url", "username", "password", "verify_ssl"),
398 "kion_music": ("token",),
399 "lastfm_scrobble": ("_provider", "_api_session_key", "_username", "_api_key", "_api_secret"),
400 "listenbrainz_scrobble": ("_user_token", "api_base_url"),
401 "musicme": ("username", "password"),
402 "neteasecloudmusic": ("api_base_url", "cookie", "uid"),
403 "nicovideo": ("mail", "password", "user_session"),
404 "nugs": ("username", "password"),
405 "opensubsonic": ("username", "password", "baseURL", "port", "path"),
406 "pandora": ("username", "password"),
407 "plex": (
408 "token",
409 "local_server_ip",
410 "local_server_port",
411 "local_server_ssl",
412 "local_server_verify_cert",
413 "library_id",
414 "library_type",
415 ),
416 "plex_connect": ("plex_provider_id", "mass_player_id"),
417 "pocketcasts": ("username", "password"),
418 "podcast_index": ("api_key", "api_secret"),
419 "podcastfeed": ("feed_url",),
420 "qobuz": ("username", "password"),
421 "qqmusic": ("uin", "musicid", "musickey", "login_type", "credential_json"),
422 "siriusxm": ("sxm_email_address", "sxm_password", "sxm_region"),
423 "soundcloud": ("client_id", "authorization"),
424 "spotify_connect": ("mass_player_id", "publish_name"),
425 "teddycloud": ("url",),
426 "tidal": ("auth_token", "refresh_token", "expiry_time", "user_id"),
427 "tunein": ("username",),
428 "vban_receiver": (
429 "bind_ip",
430 "bind_port",
431 "sender_host",
432 "vban_stream_name",
433 "audio_format",
434 "sample_rate",
435 "audio_channels",
436 ),
437 "webdav": ("content_type", "url", "username", "password", "verify_ssl"),
438 "yandex_music": ("token", "x_token", "refresh_token"),
439 "yandex_smarthome": (
440 "connection_type",
441 "cloud_instance_id",
442 "cloud_instance_password",
443 "cloud_connection_token",
444 "skill_id",
445 "skill_token",
446 "direct_access_token",
447 "direct_client_secret",
448 ),
449 "yandex_station": (
450 "ym_instance",
451 "music_token",
452 "x_token",
453 "refresh_token",
454 "remember_session",
455 ),
456 "yandex_ynison": ("ym_instance", "token", "x_token", "mass_player_id", "publish_name"),
457 "yousee": ("username", "password"),
458 "ytmusic": ("username", "cookie", "po_token_server_url"),
459 "zvuk_music": ("token",),
460}
461
462
463# Fallback defaults for setup-flow keys that were never stored in the first place.
464# A config value that matches its entry default is not persisted, so a key left at its
465# default had nothing in `values` for the migration below to move and now reads back as
466# None. These are the defaults those keys carried as config entries before the setup
467# flows landed. Only keys whose read site has no fallback of its own are listed; the
468# others already resolve their default at runtime. This runs on every startup, so only
469# keys their setup flow persists unconditionally belong here - a key a flow may
470# legitimately omit would be re-injected forever.
471# TODO: remove after 2.13 release
472PROVIDER_SETUP_FLOW_DEFAULTS: dict[str, dict[str, ConfigValueType]] = {
473 "alexa": {"url": "amazon.com", "api_url": "http://localhost:5000"},
474 "audiobookshelf": {"verify_ssl": True},
475 "filesystem_local": {"path": "/media"},
476 "filesystem_smb": {"subfolder": "", "smb_version": "3.0"},
477 "jellyfin": {"verify_ssl": True},
478 "lastfm_scrobble": {"_provider": "lastfm"},
479 "plex": {
480 "local_server_port": 32400,
481 "local_server_ssl": False,
482 "local_server_verify_cert": True,
483 },
484 "siriusxm": {"sxm_region": "US"},
485}
486
487
488async def migrate(data: dict[str, Any]) -> bool: # noqa: PLR0915
489 """Migrate the persistent settings data in-place; return True if anything changed."""
490 changed = False
491
492 # The background tasks controller originally persisted runtime state directly under
493 # core/tasks, which could create a CoreConfig object without the required domain field.
494 # Repair that single known corruption case on load.
495 # TODO: remove after 2.9 release
496 tasks_core_config = data.get(CONF_CORE, {}).get("tasks")
497 if isinstance(tasks_core_config, dict) and "domain" not in tasks_core_config:
498 tasks_core_config["domain"] = "tasks"
499 LOGGER.warning("Repaired corrupt tasks core configuration")
500 changed = True
501
502 # Drop orphaned provider config stubs: a load failure could write last_error back to a
503 # provider key whose config had already been removed (e.g. removing an unsupported provider
504 # while a load/retry was still in flight), leaving an entry with only a last_error and no
505 # 'domain'. Such stubs are dead data and crash get_provider_configs on startup.
506 # TODO: remove after 2.11 release
507 all_provider_configs = data.get(CONF_PROVIDERS, {})
508 if isinstance(all_provider_configs, dict):
509 orphaned = [
510 instance_id
511 for instance_id, cfg in all_provider_configs.items()
512 if isinstance(cfg, dict) and "domain" not in cfg
513 ]
514 for instance_id in orphaned:
515 del all_provider_configs[instance_id]
516 LOGGER.warning("Removed orphaned provider config stub %s", instance_id)
517 changed = True
518
519 # Collapse legacy multi-instance Fully Kiosk provider configs into a single
520 # provider instance with a list of devices (matching the MPD provider pattern).
521 # TODO: remove after 2.10 release
522 if _migrate_fully_kiosk_multi_instance(data):
523 changed = True
524 # Migrate default_enqueue_option_radio -> default_enqueue_option_live_sources.
525 # The same setting now covers both radio stations and plugin AudioSources
526 # (Spotify Connect, AirPlay receiver, etc.); preserves the user's customised
527 # value if they set one.
528 # TODO: remove after 2.10 release
529 player_queues_cfg = data.get(CONF_CORE, {}).get("player_queues")
530 if isinstance(player_queues_cfg, dict):
531 values = player_queues_cfg.get("values")
532 if isinstance(values, dict) and "default_enqueue_option_radio" in values:
533 radio_value = values.pop("default_enqueue_option_radio")
534 values.setdefault("default_enqueue_option_live_sources", radio_value)
535 LOGGER.info(
536 "Migrated default_enqueue_option_radio -> default_enqueue_option_live_sources"
537 )
538 changed = True
539
540 # Migrate sync_group members_filter (exclusion) -> allowed_members (inclusion).
541 # Inversion freezes the universe at migration time; speakers added after this
542 # point must be added by the user explicitly, which matches the new design's
543 # "limit to these" intent.
544 # TODO: remove after 2.10 release
545 all_player_configs = data.get(CONF_PLAYERS, {})
546 if isinstance(all_player_configs, dict):
547 group_provider_domains = {"sync_group", "universal_group"}
548 universe = {
549 pid
550 for pid, cfg in all_player_configs.items()
551 if isinstance(cfg, dict) and cfg.get("provider") not in group_provider_domains
552 }
553 for player_id, player_cfg in all_player_configs.items():
554 if not isinstance(player_cfg, dict):
555 continue
556 if player_cfg.get("provider") != "sync_group":
557 continue
558 values = player_cfg.setdefault("values", {})
559 old_exclude = values.get("members_filter") or []
560 if not old_exclude or values.get("allowed_members") is not None:
561 continue
562 values["allowed_members"] = sorted(universe - set(old_exclude))
563 values["members_filter"] = []
564 LOGGER.info(
565 "Migrated sync_group %s: members_filter (exclusion) -> allowed_members (inclusion)",
566 player_id,
567 )
568 changed = True
569
570 # Clear self-referential protocol links: a player whose protocol_parent_id or
571 # linked_protocol_ids pointed at its own id was hidden as its own protocol child.
572 # TODO: remove after 2.10 release
573 if _migrate_self_referential_protocol_links(data):
574 changed = True
575
576 # Drop the persisted schedule for the metadata maintenance tasks that were hardcoded
577 # to run at 04:00 local. They are now registered under new ("_v2") task ids with a
578 # randomized full-day schedule (to avoid spiking the shared MusicBrainz mirror), so the
579 # old persisted state is orphaned and can be removed.
580 # TODO: remove after 2.9 release
581 if _migrate_metadata_maintenance_schedule(data):
582 changed = True
583
584 # TODO: remove after 2.10 release
585 if _migrate_volume_normalization_target(data):
586 changed = True
587
588 # Move queue-scoped settings (crossfade duration, volume normalization) from the per-player
589 # config to the new per-queue config (queue_id == player_id, so the id maps 1:1).
590 # TODO: remove after 2.11 release
591 if _migrate_player_queue_settings(data):
592 changed = True
593
594 # Adopt the global-with-override model for queue settings: convert the former boolean toggles to
595 # their select strings, and promote the now global-only settings (crossfade duration, smart
596 # shuffle recency windows) to the Player Queues core config. Runs after the player->queue move
597 # above so any values it just landed are picked up here.
598 # TODO: remove after 2.10 release
599 if _migrate_global_queue_settings(data):
600 changed = True
601
602 # Promote local_audio attribution stubs to regular players and fold the settings of
603 # their (now obsolete) universal player wrappers back onto them.
604 # TODO: remove after 2.11 release
605 if _migrate_local_audio_attribution_stubs(data):
606 changed = True
607
608 # Drop ghost players that were discovered from this server's own AirPlay Receiver
609 # (shairport-sync) advertisements before discovery learned to filter them out.
610 # TODO: remove after 2.10 release
611 if _migrate_airplay_receiver_ghost_players(data):
612 changed = True
613
614 # Give Apple TVs paired before native power control existed the current
615 # default ("native") instead of the stale "none" that hid their power button.
616 # TODO: remove after 2.11 release
617 if _migrate_airplay_apple_power_control(data):
618 changed = True
619
620 # Drop the stored value of the removed output limiter player setting; clipping protection
621 # is now an explicit Safety Limiter DSP filter instead of a fixed output stage.
622 # TODO: remove after 2.10 release
623 if _migrate_output_limiter(data):
624 changed = True
625
626 # Move player-owned credential/pairing keys (AirPlay creds, Fully Kiosk / MPD password)
627 # from the player config `values` into the player's encrypted `setup_data`, so those reads
628 # switch to Player.get_setup_value now that pairing/credentials are owned by the setup flows.
629 # TODO: remove after 2.11 release
630 if _migrate_player_setup_data(data):
631 changed = True
632
633 # Drop the per-player Bose SoundTouch preset mappings; presets are now mapped once on
634 # the provider config and shared by all its speakers.
635 # TODO: remove after 2.11 release
636 if _migrate_bose_soundtouch_presets(data):
637 changed = True
638
639 # Rewrite stored player icons from legacy values (mdi-* names and pre-1.0 picker
640 # names) to canonical ids of the shared icon set; unmappable mdi-* picks drop
641 # back to the player-type default.
642 # TODO: remove after 2.12 release
643 if _migrate_player_icons(data):
644 changed = True
645
646 # Drop the stored HTTP profile of Bluesound players; the setting is no longer offered
647 # because BluOS only plays back correctly on the forced content length profile.
648 # TODO: remove after 2.12 release
649 if _migrate_bluesound_http_profile(data):
650 changed = True
651
652 # Drop disabled protocol player configs that lost their parent player: the device they
653 # belong to can never register again while such a config lingers, and it is not shown
654 # in the UI so there is no way to enable it again.
655 # TODO: remove after 2.12 release
656 if _migrate_orphaned_disabled_protocol_configs(data):
657 changed = True
658
659 # Clear the stored name of players that were never renamed, so an updated default
660 # name is no longer shadowed by the auto-generated name stored at creation time.
661 # TODO: remove after 2.12 release
662 if _migrate_unrenamed_player_names(data):
663 changed = True
664
665 return changed
666
667
668def migrate_provider_setup_data(data: dict[str, Any], encrypt: Callable[[str], str]) -> bool:
669 """
670 Move each provider's setup-flow-owned keys from `values` to `setup_data` in-place.
671
672 Also restores the keys listed in PROVIDER_SETUP_FLOW_DEFAULTS that are absent from
673 `setup_data`, which covers the installs whose values were already moved by an
674 earlier run of this step.
675
676 Runs after encryption is initialized (unlike migrate()), so string values are
677 encrypted at rest with the given callback - matching how the setup flows persist
678 collected values. Returns True if anything changed.
679
680 :param data: The persistent settings data to migrate in-place.
681 :param encrypt: Callback that encrypts a string value (idempotent for already
682 encrypted values), used to encrypt migrated string values at rest.
683 """
684 all_provider_configs = data.get(CONF_PROVIDERS, {})
685 if not isinstance(all_provider_configs, dict):
686 return False
687 changed = False
688 for provider_cfg in all_provider_configs.values():
689 if not isinstance(provider_cfg, dict):
690 continue
691 domain = provider_cfg.get("domain", "")
692 owned_keys = PROVIDER_SETUP_FLOW_KEYS.get(domain)
693 if not owned_keys:
694 continue
695 values = provider_cfg.get("values")
696 if not isinstance(values, dict):
697 # a config without stored values has nothing to move, but may still
698 # be missing a default
699 values = {}
700 movable_keys = [key for key in owned_keys if key in values]
701 setup_data = provider_cfg.get("setup_data")
702 if not isinstance(setup_data, dict):
703 setup_data = {}
704 # a key that is about to be moved carries the user's own value and is left alone
705 missing_defaults = {
706 key: value
707 for key, value in PROVIDER_SETUP_FLOW_DEFAULTS.get(domain, {}).items()
708 if key not in setup_data and key not in movable_keys
709 }
710 if not movable_keys and not missing_defaults:
711 continue
712 provider_cfg["setup_data"] = setup_data
713 for key in movable_keys:
714 # a value already collected into setup_data wins; only drop the stale copy
715 if key not in setup_data:
716 value = values[key]
717 setup_data[key] = encrypt(value) if isinstance(value, str) else value
718 del values[key]
719 for key, value in missing_defaults.items():
720 setup_data[key] = encrypt(value) if isinstance(value, str) else value
721 changed = True
722 if changed:
723 LOGGER.info("Migrated provider setup values into setup_data")
724 return changed
725
726
727# TODO: remove after 2.10 release
728def migrate_nfs_subfolder_into_export_path(
729 data: dict[str, Any],
730 encrypt: Callable[[str], str],
731 decrypt: Callable[[str], str],
732) -> bool:
733 """
734 Fold a stored NFS `subfolder` into its `export_path`, once.
735
736 The provider mounts the export as configured and scans the subfolder inside that mount, so
737 folding the two keys into one keeps an existing instance mounting what it already mounts.
738 Runs after encryption is initialized, like migrate_provider_setup_data, because both keys
739 live encrypted in `setup_data`.
740
741 Guarded by CONF_NFS_SUBFOLDER_MIGRATED so it cannot run twice: a subfolder stored
742 afterwards means "scan this path inside the mount" and must never be folded. Returns True
743 when the settings were modified, including the first run's marker.
744
745 :param data: The persistent settings data to migrate in-place.
746 :param encrypt: Callback that encrypts a string value at rest.
747 :param decrypt: Callback that decrypts a stored string value (a no-op for plain values).
748 """
749 if data.get(CONF_NFS_SUBFOLDER_MIGRATED):
750 return False
751 all_provider_configs = data.get(CONF_PROVIDERS, {})
752 if not isinstance(all_provider_configs, dict):
753 return False
754 changed = False
755 for instance_id, provider_cfg in all_provider_configs.items():
756 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != "filesystem_nfs":
757 continue
758 setup_data = provider_cfg.get("setup_data")
759 if not isinstance(setup_data, dict):
760 continue
761 stored_subfolder = setup_data.get("subfolder")
762 stored_export_path = setup_data.get("export_path")
763 if not isinstance(stored_subfolder, str) or not isinstance(stored_export_path, str):
764 continue
765 try:
766 subfolder = decrypt(stored_subfolder).strip()
767 export_path = decrypt(stored_export_path)
768 except InvalidDataError:
769 # one unreadable instance must not fail config setup for the whole server; it
770 # still surfaces the problem at its own setup. Name it without its values.
771 LOGGER.warning(
772 "Could not read the stored NFS paths of %s; skipping its subfolder migration",
773 instance_id,
774 )
775 continue
776 if not subfolder or not export_path:
777 # an empty export path is broken either way and must not become a relative one
778 continue
779 # must come out as <export_path>/<subfolder> so the mount source is unchanged
780 setup_data["export_path"] = encrypt(str(PurePosixPath(export_path) / subfolder.lstrip("/")))
781 del setup_data["subfolder"]
782 changed = True
783 if changed:
784 LOGGER.info("Migrated NFS provider subfolder into the export path")
785 # claim the marker even when nothing was folded, so a subfolder stored later is safe
786 data[CONF_NFS_SUBFOLDER_MIGRATED] = True
787 return True
788
789
790# TODO: remove after 2.12 release
791def migrate_connected_player_plugins(
792 data: dict[str, Any],
793 decrypt: Callable[[str], str],
794 storage_path: str,
795) -> bool:
796 """
797 Move the connected-player plugins to the player-bound configuration model, once.
798
799 spotify_connect and airplay_receiver are single-instance providers now, driven by a
800 connected-players multi-select: existing instances collapse into one keyed by the
801 domain, the explicitly configured players carry over into the multi-select and the
802 per-instance device names are dropped (the advertised name follows the player now).
803 For ariacast_receiver and yandex_ynison the connected player became mandatory: the
804 removed automatic selection and players that no longer exist are cleared (so the
805 provider fails into reconfigure) and their free-form device name keys are dropped.
806
807 Runs after encryption is initialized, and after migrate_provider_setup_data so the
808 pre-setup-flow values have landed in setup_data by now.
809
810 :param data: The persistent settings data to migrate in-place.
811 :param decrypt: Callback that decrypts a stored string value (a no-op for plain values).
812 :param storage_path: The server storage path holding per-instance provider data dirs.
813 """
814 all_provider_configs = data.get(CONF_PROVIDERS, {})
815 if not isinstance(all_provider_configs, dict):
816 return False
817 stored_players = data.get(CONF_PLAYERS, {})
818 known_player_ids = set(stored_players) if isinstance(stored_players, dict) else set()
819 changed = False
820 for domain in ("spotify_connect", "airplay_receiver"):
821 if _collapse_connected_player_instances(
822 all_provider_configs, domain, known_player_ids, decrypt, storage_path
823 ):
824 changed = True
825 if _clear_invalid_connected_players(all_provider_configs, known_player_ids, decrypt):
826 changed = True
827 return changed
828
829
830def _collapse_connected_player_instances(
831 all_provider_configs: dict[str, Any],
832 domain: str,
833 known_player_ids: set[str],
834 decrypt: Callable[[str], str],
835 storage_path: str,
836) -> bool:
837 """
838 Collapse the instances of one per-player plugin domain into a single instance.
839
840 :param all_provider_configs: The stored provider configurations, modified in-place.
841 :param domain: The plugin domain to collapse (spotify_connect or airplay_receiver).
842 :param known_player_ids: The player ids present in the stored player configurations.
843 :param decrypt: Callback that decrypts a stored string value.
844 :param storage_path: The server storage path holding per-instance provider data dirs.
845 """
846 instances = {
847 instance_id: provider_cfg
848 for instance_id, provider_cfg in all_provider_configs.items()
849 if isinstance(provider_cfg, dict) and provider_cfg.get("domain") == domain
850 }
851 if not instances:
852 return False
853 # already collapsed (or created post-change): a single instance keyed by the bare
854 # domain can only originate from the new single-instance model — the legacy
855 # multi-instance era always minted suffixed ids. This check is durable, unlike the
856 # connected_players marker below, which the config store drops again when the
857 # stored value equals the entry default (an empty selection).
858 if len(instances) == 1 and domain in instances:
859 return False
860 if any(
861 isinstance(cfg.get("values"), dict) and CONF_CONNECTED_PLAYERS in cfg["values"]
862 for cfg in instances.values()
863 ):
864 # already collapsed by an earlier run
865 return False
866 # decrypt each instance's stored setup so the configured player and (for spotify)
867 # the backend can be read; an unreadable instance contributes nothing. Ordering
868 # follows the stored configs, so "first instance" ties resolve deterministically.
869 decrypted: dict[str, dict[str, Any]] = {}
870 for instance_id, provider_cfg in instances.items():
871 setup_data = provider_cfg.get("setup_data")
872 if not isinstance(setup_data, dict):
873 decrypted[instance_id] = {}
874 continue
875 try:
876 decrypted[instance_id] = {
877 key: decrypt(value) if isinstance(value, str) else value
878 for key, value in setup_data.items()
879 }
880 except InvalidDataError:
881 LOGGER.warning(
882 "Could not read the stored setup of %s; its configured player is not carried over",
883 instance_id,
884 )
885 # a disabled instance must not decide the surviving config or contribute players:
886 # its enabled flag becomes the whole provider's after the collapse
887 enabled_ids = [iid for iid, cfg in instances.items() if cfg.get("enabled", True)]
888 survivor_pool = enabled_ids or list(instances)
889 # the soloist instance carries the API key and ToS consent, so it must be the one
890 # that survives the collapse
891 survivor_id = survivor_pool[0]
892 if domain == "spotify_connect":
893 survivor_id = next(
894 (iid for iid in survivor_pool if decrypted.get(iid, {}).get("backend") == "soloist"),
895 survivor_id,
896 )
897 # ordered de-duped carry-over of the explicitly configured players; the removed
898 # automatic selection and vanished players contribute nothing
899 connected_players: list[str] = []
900 soloist_player_ids: dict[str, str] = {}
901 for instance_id in enabled_ids:
902 player_id = decrypted.get(instance_id, {}).get("mass_player_id")
903 if (
904 not isinstance(player_id, str)
905 or player_id == LEGACY_PLAYER_ID_AUTO
906 or player_id not in known_player_ids
907 ):
908 continue
909 if player_id not in connected_players:
910 connected_players.append(player_id)
911 if decrypted[instance_id].get("backend") == "soloist":
912 soloist_player_ids[instance_id] = player_id
913 survivor = instances[survivor_id]
914 setup_data = survivor.get("setup_data")
915 setup_data = setup_data if isinstance(setup_data, dict) else {}
916 dropped_keys = [
917 key
918 for key in ("mass_player_id", "publish_name", "airplay_name")
919 if setup_data.pop(key, None) is not None
920 ]
921 survivor["setup_data"] = setup_data
922 values = survivor.get("values")
923 values = values if isinstance(values, dict) else {}
924 # always stored (even empty): doubles as this migration's idempotency marker
925 values[CONF_CONNECTED_PLAYERS] = connected_players
926 survivor["values"] = values
927 survivor["instance_id"] = domain
928 for instance_id in instances:
929 del all_provider_configs[instance_id]
930 all_provider_configs[domain] = survivor
931 if len(instances) > 1 or connected_players or dropped_keys:
932 LOGGER.warning(
933 "Migrated %d %s configuration(s) into a single instance connected to %d "
934 "player(s). The advertised device name now follows the connected player's name.",
935 len(instances),
936 domain,
937 len(connected_players),
938 )
939 if domain == "spotify_connect":
940 _move_soloist_data_dirs(storage_path, soloist_player_ids)
941 return True
942
943
944def _move_soloist_data_dirs(storage_path: str, soloist_player_ids: dict[str, str]) -> None:
945 """
946 Move per-instance soloist data dirs to their per-player location, best effort.
947
948 A moved dir keeps the Spotify pairing of that player's device; a failed move only
949 costs the user a re-pair in the Spotify app and never fails startup.
950
951 :param storage_path: The server storage path.
952 :param soloist_player_ids: Old soloist instance id mapped to its carried-over player id.
953 """
954 base_path = Path(storage_path) / "spotify_connect"
955 for old_instance_id, player_id in soloist_player_ids.items():
956 src = base_path / old_instance_id / "soloist-data"
957 # matches the per-player identity key the provider derives its data dir from
958 safe_player_id = re.sub(r"[^A-Za-z0-9_.-]", "_", player_id)
959 dst = base_path / f"spotify_connect_{safe_player_id}" / "soloist-data"
960 if not src.is_dir() or dst.exists():
961 continue
962 try:
963 dst.parent.mkdir(parents=True, exist_ok=True)
964 src.rename(dst)
965 except OSError as err:
966 LOGGER.warning(
967 "Could not move the Spotify Connect (soloist) data of %s to %s: %s",
968 old_instance_id,
969 dst,
970 err,
971 )
972
973
974def _clear_invalid_connected_players(
975 all_provider_configs: dict[str, Any],
976 known_player_ids: set[str],
977 decrypt: Callable[[str], str],
978) -> bool:
979 """
980 Enforce the now-mandatory connected player on the single-player plugins.
981
982 :param all_provider_configs: The stored provider configurations, modified in-place.
983 :param known_player_ids: The player ids present in the stored player configurations.
984 :param decrypt: Callback that decrypts a stored string value.
985 """
986 changed = False
987 for instance_id, provider_cfg in all_provider_configs.items():
988 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") not in (
989 "ariacast_receiver",
990 "yandex_ynison",
991 ):
992 continue
993 setup_data = provider_cfg.get("setup_data")
994 if not isinstance(setup_data, dict):
995 continue
996 # the free-form device names are gone; the advertised name follows the player now
997 for key in ("ariacast_name", "publish_name"):
998 if key in setup_data:
999 del setup_data[key]
1000 changed = True
1001 stored_player_id = setup_data.get("mass_player_id")
1002 if not isinstance(stored_player_id, str):
1003 continue
1004 try:
1005 player_id = decrypt(stored_player_id)
1006 except InvalidDataError:
1007 LOGGER.warning(
1008 "Could not read the configured player of %s; leaving it in place", instance_id
1009 )
1010 continue
1011 if player_id == LEGACY_PLAYER_ID_AUTO or player_id not in known_player_ids:
1012 del setup_data["mass_player_id"]
1013 changed = True
1014 LOGGER.warning(
1015 "The connected player of %s is no longer valid; open its settings and run "
1016 "the setup again to select a player",
1017 instance_id,
1018 )
1019 return changed
1020
1021
1022# TODO: remove after 2.10 release
1023def migrate_hass_engine_selection(data: dict[str, Any], encrypt: Callable[[str], str]) -> bool:
1024 """
1025 Hand the removed Home Assistant TTS/AI entity choice over to the providers consuming it.
1026
1027 The Home Assistant plugin exposes every TTS/AI entity as a selectable engine now and each
1028 consuming provider picks one itself, so the single choice that used to live on the plugin
1029 is copied to the installed consumers that have no choice of their own yet. Providers
1030 installed later pick an engine themselves at load. Returns True if anything changed.
1031
1032 Runs after encryption is initialized (like migrate_provider_setup_data), since the ai_radio
1033 selection belongs in its encrypted `setup_data`.
1034
1035 :param data: The persistent settings data to migrate in-place.
1036 :param encrypt: Callback that encrypts a string value, used for the values that land in
1037 `setup_data`.
1038 """
1039 all_provider_configs = data.get(CONF_PROVIDERS, {})
1040 if not isinstance(all_provider_configs, dict):
1041 return False
1042 hass_configs = {
1043 instance_id: provider_cfg
1044 for instance_id, provider_cfg in all_provider_configs.items()
1045 if isinstance(provider_cfg, dict) and provider_cfg.get("domain") == "hass"
1046 }
1047 if len(hass_configs) > 1:
1048 # there is no correct winner between several choices, so let the user pick per provider
1049 LOGGER.warning(
1050 "Skipped migrating the Home Assistant TTS/AI entity selection: "
1051 "%s Home Assistant configurations found, select the engines manually",
1052 len(hass_configs),
1053 )
1054 return False
1055 changed = False
1056 for instance_id, hass_cfg in hass_configs.items():
1057 values = hass_cfg.get("values")
1058 if not isinstance(values, dict):
1059 continue
1060 if not any(key in values for key in (LEGACY_CONF_TTS_ENTITY, LEGACY_CONF_AI_TASK_ENTITY)):
1061 continue
1062 tts_entity = values.pop(LEGACY_CONF_TTS_ENTITY, None)
1063 ai_task_entity = values.pop(LEGACY_CONF_AI_TASK_ENTITY, None)
1064 changed = True
1065 if isinstance(ai_task_entity, str) and ai_task_entity:
1066 ai_engine = f"{instance_id}/{ai_task_entity}"
1067 _set_engine_selection(
1068 all_provider_configs, "music_quiz", "values", CONF_AI_ENGINE, ai_engine
1069 )
1070 _set_engine_selection(
1071 all_provider_configs, "smart_playlist", "values", CONF_AI_ENGINE, ai_engine
1072 )
1073 _set_engine_selection(
1074 all_provider_configs, "ai_radio", "setup_data", CONF_AI_ENGINE, encrypt(ai_engine)
1075 )
1076 if isinstance(tts_entity, str) and tts_entity:
1077 _set_engine_selection(
1078 all_provider_configs,
1079 "ai_radio",
1080 "setup_data",
1081 CONF_TTS_ENGINE,
1082 encrypt(f"{instance_id}/{tts_entity}"),
1083 )
1084 LOGGER.info("Migrated the Home Assistant TTS/AI entity selection to the plugin engines")
1085 return changed
1086
1087
1088def _set_engine_selection(
1089 all_provider_configs: dict[str, Any], domain: str, section: str, key: str, value: str
1090) -> None:
1091 """Store an engine selection on each config of the given domain that has none of its own."""
1092 for provider_cfg in all_provider_configs.values():
1093 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != domain:
1094 continue
1095 section_values = provider_cfg.get(section)
1096 if not isinstance(section_values, dict):
1097 section_values = {}
1098 provider_cfg[section] = section_values
1099 section_values.setdefault(key, value)
1100
1101
1102def _migrate_player_queue_settings(data: dict[str, Any]) -> bool:
1103 """Move queue-scoped settings from the per-player config to the per-queue config."""
1104 moved_keys = (
1105 CONF_CROSSFADE_DURATION,
1106 CONF_VOLUME_NORMALIZATION,
1107 )
1108 all_player_configs = data.get(CONF_PLAYERS, {})
1109 if not isinstance(all_player_configs, dict):
1110 return False
1111 changed = False
1112 for player_id, player_cfg in all_player_configs.items():
1113 if not isinstance(player_cfg, dict):
1114 continue
1115 player_values = player_cfg.get("values")
1116 if not isinstance(player_values, dict):
1117 continue
1118 to_move = {key: player_values[key] for key in moved_keys if key in player_values}
1119 # the legacy smart_fades_mode encoded both on/off and standard-vs-smart; the on/off is
1120 # now a runtime queue toggle, and standard/smart carries over to the crossfade_mode
1121 # select ("disabled" just means crossfade is off -> nothing to carry). Consume the key.
1122 legacy_mode = player_values.pop(CONF_SMART_FADES_MODE, None)
1123 migrated_mode = (
1124 legacy_mode
1125 if legacy_mode in (CrossfadeMode.STANDARD_CROSSFADE, CrossfadeMode.SMART_CROSSFADE)
1126 else None
1127 )
1128 if not to_move and legacy_mode is None:
1129 continue
1130 if to_move or migrated_mode is not None:
1131 queue_cfg = data.setdefault(CONF_PLAYER_QUEUES, {}).setdefault(
1132 player_id, {"queue_id": player_id}
1133 )
1134 queue_values = queue_cfg.setdefault("values", {})
1135 for key, value in to_move.items():
1136 # don't clobber an existing queue value if one was already stored
1137 queue_values.setdefault(key, value)
1138 del player_values[key]
1139 if migrated_mode is not None:
1140 queue_values.setdefault(CONF_CROSSFADE_MODE, migrated_mode)
1141 LOGGER.info("Migrated queue settings for %s", player_id)
1142 changed = True
1143 return changed
1144
1145
1146def _migrate_global_queue_settings(data: dict[str, Any]) -> bool:
1147 """
1148 Adopt the global-with-override model for the per-queue settings.
1149
1150 The two former boolean toggles become their select strings (so a queue can also follow the
1151 global value), and the settings that are now global-only are promoted to the Player Queues core
1152 config. Queues that stored nothing keep nothing and therefore fall back to the new "global"
1153 default. Idempotent: a second run finds only select strings and no per-queue global-only values.
1154 """
1155 all_queue_configs = data.get(CONF_PLAYER_QUEUES, {})
1156 if not isinstance(all_queue_configs, dict):
1157 return False
1158 changed = False
1159 # 1. convert the former booleans (True/False) to their select strings (enabled/disabled)
1160 bool_to_select = {True: CONF_VALUE_ENABLED, False: CONF_VALUE_DISABLED}
1161 for queue_cfg in all_queue_configs.values():
1162 if not isinstance(queue_cfg, dict):
1163 continue
1164 values = queue_cfg.get("values")
1165 if not isinstance(values, dict):
1166 continue
1167 for key in (CONF_VOLUME_NORMALIZATION, CONF_SMART_SHUFFLE_ENABLED):
1168 if isinstance(values.get(key), bool):
1169 values[key] = bool_to_select[values[key]]
1170 changed = True
1171 # 2. promote the now global-only settings to the Player Queues core config
1172 global_only_keys = (
1173 CONF_CROSSFADE_DURATION,
1174 CONF_SMART_SHUFFLE_SONG_RECENCY,
1175 CONF_SMART_SHUFFLE_ARTIST_RECENCY,
1176 CONF_SMART_SHUFFLE_DUPLICATE_GAP,
1177 )
1178 for key in global_only_keys:
1179 if _promote_queue_setting_to_global(data, key):
1180 changed = True
1181 return changed
1182
1183
1184def _promote_queue_setting_to_global(data: dict[str, Any], key: str) -> bool:
1185 """
1186 Promote a (now global-only) per-queue setting to global config and drop the per-queue copies.
1187
1188 A single value shared by every queue that set it is promoted so the user's preference is kept;
1189 mixed values fall back to the new default. Mirrors _migrate_volume_normalization_target.
1190 """
1191 all_queue_configs = data.get(CONF_PLAYER_QUEUES, {})
1192 if not isinstance(all_queue_configs, dict):
1193 return False
1194 stored_values: set[Any] = set()
1195 for queue_cfg in all_queue_configs.values():
1196 if not isinstance(queue_cfg, dict):
1197 continue
1198 values = queue_cfg.get("values")
1199 if isinstance(values, dict) and key in values:
1200 stored_values.add(values[key])
1201 if not stored_values:
1202 return False
1203 # promote only a single consistent value (mixed values fall back to the new default), and never
1204 # clobber a value the user already set globally; only touch the core config when promoting
1205 existing_core = data.get(CONF_CORE, {}).get(CONF_PLAYER_QUEUES, {})
1206 existing_values = existing_core.get("values", {}) if isinstance(existing_core, dict) else {}
1207 if len(stored_values) == 1 and key not in existing_values:
1208 core_values = (
1209 data.setdefault(CONF_CORE, {})
1210 .setdefault(CONF_PLAYER_QUEUES, {"domain": CONF_PLAYER_QUEUES})
1211 .setdefault("values", {})
1212 )
1213 core_values[key] = next(iter(stored_values))
1214 LOGGER.info("Promoted per-queue %s to the global Player Queues config", key)
1215 # the setting is global-only now, so drop every per-queue copy
1216 for queue_cfg in all_queue_configs.values():
1217 if not isinstance(queue_cfg, dict):
1218 continue
1219 values = queue_cfg.get("values")
1220 if isinstance(values, dict):
1221 values.pop(key, None)
1222 return True
1223
1224
1225def _migrate_volume_normalization_target(data: dict[str, Any]) -> bool:
1226 """
1227 Migrate volume_normalization_target from per-player to the global streams setting.
1228
1229 Collects all explicitly stored per-player values; if they all agree on a single value,
1230 that value is promoted to the streams core config so the user's preference is preserved.
1231 """
1232 all_player_configs = data.get(CONF_PLAYERS, {})
1233 if not isinstance(all_player_configs, dict):
1234 return False
1235 per_player_values: set[int] = set()
1236 for player_cfg in all_player_configs.values():
1237 if not isinstance(player_cfg, dict):
1238 continue
1239 values = player_cfg.get("values")
1240 if not isinstance(values, dict):
1241 continue
1242 if CONF_VOLUME_NORMALIZATION_TARGET in values:
1243 per_player_values.add(int(values[CONF_VOLUME_NORMALIZATION_TARGET]))
1244
1245 if not per_player_values:
1246 return False
1247
1248 streams_core = data.setdefault(CONF_CORE, {}).setdefault("streams", {})
1249 streams_values = streams_core.setdefault("values", {})
1250 # only promote when not already globally configured
1251 if CONF_VOLUME_NORMALIZATION_TARGET not in streams_values:
1252 # single consistent value across all players → promote it; mixed → use new default
1253 promoted = per_player_values.pop() if len(per_player_values) == 1 else None
1254 if promoted is not None:
1255 streams_values[CONF_VOLUME_NORMALIZATION_TARGET] = promoted
1256 LOGGER.info(
1257 "Promoted volume_normalization_target %s LUFS to global streams setting",
1258 promoted,
1259 )
1260
1261 for player_id, player_cfg in all_player_configs.items():
1262 if not isinstance(player_cfg, dict):
1263 continue
1264 values = player_cfg.get("values")
1265 if not isinstance(values, dict):
1266 continue
1267 if CONF_VOLUME_NORMALIZATION_TARGET in values:
1268 del values[CONF_VOLUME_NORMALIZATION_TARGET]
1269 LOGGER.info(
1270 "Removed per-player volume_normalization_target for player %s",
1271 player_id,
1272 )
1273 return True
1274
1275
1276def _migrate_local_audio_attribution_stubs(data: dict[str, Any]) -> bool:
1277 """
1278 Promote local_audio attribution-stub players to regular players.
1279
1280 The local_audio provider used to register a hidden PROTOCOL "attribution stub"
1281 per audio device, which got wrapped (together with the Sendspin bridge player)
1282 in an auto-created universal player. The stub is now a regular, visible player
1283 that parents the Sendspin bridge directly, making the universal player wrapper
1284 obsolete: its user settings move onto the stub's config (same bare device-uuid
1285 player_id) and the wrapper is removed.
1286 """
1287 all_player_configs = data.get(CONF_PLAYERS, {})
1288 if not isinstance(all_player_configs, dict):
1289 return False
1290 changed = False
1291 for player_id, player_cfg in list(all_player_configs.items()):
1292 if not isinstance(player_cfg, dict):
1293 continue
1294 if player_cfg.get("provider") != "local_audio":
1295 continue
1296 if player_cfg.get("player_type") != "protocol":
1297 continue
1298 player_cfg["player_type"] = "player"
1299 values = player_cfg.setdefault("values", {})
1300 values.pop(CONF_PROTOCOL_PARENT_ID, None)
1301 changed = True
1302
1303 # the universal player wrapper was keyed on the stub's player_id
1304 # (the stub had no device identifiers to derive a device key from)
1305 universal_id = f"up{player_id.replace('-', '').lower()}"
1306 universal_cfg = all_player_configs.get(universal_id)
1307 if isinstance(universal_cfg, dict) and universal_cfg.get("provider") == "universal_player":
1308 del all_player_configs[universal_id]
1309 _absorb_universal_player_config(
1310 data, player_id, player_cfg, universal_id, universal_cfg
1311 )
1312 LOGGER.info(
1313 "Migrated universal player %s settings to local_audio player %s",
1314 universal_id,
1315 player_id,
1316 )
1317 LOGGER.info("Promoted local_audio player %s to a regular player", player_id)
1318 return changed
1319
1320
1321def _absorb_universal_player_config(
1322 data: dict[str, Any],
1323 player_id: str,
1324 player_cfg: dict[str, Any],
1325 universal_id: str,
1326 universal_cfg: dict[str, Any],
1327) -> None:
1328 """
1329 Fold the user settings of an obsolete universal player onto its replacement.
1330
1331 The universal player was the visible device the user configured, so its
1332 settings win over anything stored on the (hidden) stub. Everything keyed on
1333 the old universal player_id (protocol parent links, queue settings, DSP
1334 config, group memberships) is re-pointed to the new player_id.
1335 """
1336 player_cfg["enabled"] = universal_cfg.get("enabled", True)
1337 # only carry an actual user rename, not the auto-generated default name
1338 if universal_cfg.get("name") and universal_cfg.get("name") != universal_cfg.get("default_name"):
1339 player_cfg["name"] = universal_cfg["name"]
1340
1341 values = player_cfg.setdefault("values", {})
1342 universal_values = universal_cfg.get("values")
1343 universal_values = universal_values if isinstance(universal_values, dict) else {}
1344 # bookkeeping only relevant to the universal player wrapper itself
1345 internal_keys = (
1346 CONF_LINKED_PROTOCOL_IDS,
1347 CONF_PROTOCOL_PARENT_ID,
1348 "device_identifiers",
1349 "device_info",
1350 )
1351 for key, value in universal_values.items():
1352 if key in internal_keys:
1353 continue
1354 values[key] = value
1355
1356 # carry the linked protocols (minus the stub itself, it is the parent now)
1357 # and re-point their cached parent so they restore fast on the next start
1358 linked_ids = [
1359 pid for pid in (universal_values.get(CONF_LINKED_PROTOCOL_IDS) or []) if pid != player_id
1360 ]
1361 if linked_ids:
1362 existing_ids = list(values.get(CONF_LINKED_PROTOCOL_IDS) or [])
1363 values[CONF_LINKED_PROTOCOL_IDS] = existing_ids + [
1364 pid for pid in linked_ids if pid not in existing_ids
1365 ]
1366 all_player_configs = data.get(CONF_PLAYERS, {})
1367 for protocol_id in linked_ids:
1368 protocol_cfg = all_player_configs.get(protocol_id)
1369 if not isinstance(protocol_cfg, dict):
1370 continue
1371 protocol_values = protocol_cfg.setdefault("values", {})
1372 if protocol_values.get(CONF_PROTOCOL_PARENT_ID) == universal_id:
1373 protocol_values[CONF_PROTOCOL_PARENT_ID] = player_id
1374
1375 # move per-queue settings and DSP configuration to the new player_id
1376 for tree_key in (CONF_PLAYER_QUEUES, CONF_PLAYER_DSP):
1377 tree = data.get(tree_key)
1378 if isinstance(tree, dict) and universal_id in tree and player_id not in tree:
1379 tree[player_id] = tree.pop(universal_id)
1380 if tree_key == CONF_PLAYER_QUEUES and isinstance(tree[player_id], dict):
1381 tree[player_id]["queue_id"] = player_id
1382
1383 # re-point group memberships that referenced the universal player
1384 for other_cfg in all_player_configs.values():
1385 if not isinstance(other_cfg, dict):
1386 continue
1387 other_values = other_cfg.get("values")
1388 if not isinstance(other_values, dict):
1389 continue
1390 for key in ("group_members", "allowed_members"):
1391 members = other_values.get(key)
1392 if isinstance(members, list) and universal_id in members:
1393 other_values[key] = [player_id if pid == universal_id else pid for pid in members]
1394
1395
1396def _migrate_self_referential_protocol_links(data: dict[str, Any]) -> bool:
1397 """Clear protocol links that point a player at its own id."""
1398 all_player_configs = data.get(CONF_PLAYERS, {})
1399 if not isinstance(all_player_configs, dict):
1400 return False
1401 changed = False
1402 for player_id, player_cfg in all_player_configs.items():
1403 if not isinstance(player_cfg, dict):
1404 continue
1405 values = player_cfg.get("values")
1406 if not isinstance(values, dict):
1407 continue
1408 repaired = False
1409 if values.get(CONF_PROTOCOL_PARENT_ID) == player_id:
1410 values[CONF_PROTOCOL_PARENT_ID] = None
1411 repaired = True
1412 linked = values.get(CONF_LINKED_PROTOCOL_IDS)
1413 if isinstance(linked, list) and player_id in linked:
1414 values[CONF_LINKED_PROTOCOL_IDS] = [pid for pid in linked if pid != player_id]
1415 repaired = True
1416 if repaired:
1417 LOGGER.warning("Repaired self-referential protocol link for %s", player_id)
1418 changed = True
1419 return changed
1420
1421
1422def _migrate_metadata_maintenance_schedule(data: dict[str, Any]) -> bool:
1423 """Remove the orphaned persisted state for the pre-randomization metadata task ids."""
1424 core_config = data.get(CONF_CORE)
1425 if not isinstance(core_config, dict):
1426 return False
1427 tasks_config = core_config.get("tasks")
1428 if not isinstance(tasks_config, dict):
1429 return False
1430 task_states = tasks_config.get("scheduled_task_states")
1431 if not isinstance(task_states, dict):
1432 return False
1433 legacy_task_ids = (
1434 "metadata_missing_artist_metadata_scan",
1435 "metadata_playlist_metadata_scan",
1436 "metadata_thumb_cache_cleanup",
1437 )
1438 removed = [task_id for task_id in legacy_task_ids if task_id in task_states]
1439 for task_id in removed:
1440 del task_states[task_id]
1441 if removed:
1442 LOGGER.info("Removed orphaned metadata maintenance schedule state for %s", removed)
1443 return bool(removed)
1444
1445
1446def _migrate_fully_kiosk_multi_instance(data: dict[str, Any]) -> bool:
1447 """Collapse legacy multi-instance Fully Kiosk configs into a single provider instance."""
1448 providers = data.get(CONF_PROVIDERS, {})
1449 legacy_ids = [
1450 iid
1451 for iid, conf in providers.items()
1452 if isinstance(conf, dict) and conf.get("domain") == "fully_kiosk" and iid != "fully_kiosk"
1453 ]
1454 if not legacy_ids:
1455 return False
1456
1457 ip_entries: list[str] = []
1458 players = data.setdefault(CONF_PLAYERS, {})
1459 for iid in legacy_ids:
1460 old_values = providers[iid].get("values") or {}
1461 host = old_values.get("ip_address")
1462 if not host:
1463 del providers[iid]
1464 continue
1465 try:
1466 port = int(old_values.get("port") or 2323)
1467 except TypeError, ValueError:
1468 port = 2323
1469 entry = host if port == 2323 else f"{host}:{port}"
1470 if entry not in ip_entries:
1471 ip_entries.append(entry)
1472
1473 new_player_id = f"fully_kiosk_{host}_{port}"
1474 player_conf = players.setdefault(
1475 new_player_id,
1476 {
1477 "player_id": new_player_id,
1478 "provider": "fully_kiosk",
1479 "enabled": True,
1480 "values": {},
1481 },
1482 )
1483 player_values = player_conf.setdefault("values", {})
1484 for key in ("password", "use_ssl", "verify_ssl", "ssl_fingerprint"):
1485 if old_values.get(key) is not None and key not in player_values:
1486 player_values[key] = old_values[key]
1487
1488 del providers[iid]
1489
1490 if "fully_kiosk" in providers:
1491 existing_values = providers["fully_kiosk"].setdefault("values", {})
1492 existing_ips = list(existing_values.get("manual_discovery_ip_addresses") or [])
1493 for entry in ip_entries:
1494 if entry not in existing_ips:
1495 existing_ips.append(entry)
1496 existing_values["manual_discovery_ip_addresses"] = existing_ips
1497 else:
1498 providers["fully_kiosk"] = {
1499 "type": "player",
1500 "domain": "fully_kiosk",
1501 "instance_id": "fully_kiosk",
1502 "enabled": True,
1503 "values": {"manual_discovery_ip_addresses": ip_entries},
1504 }
1505
1506 LOGGER.warning(
1507 "Migrated %d legacy Fully Kiosk provider instance(s) into a single instance. "
1508 "Devices and their passwords have been preserved, but any Fully Kiosk player "
1509 "that was part of a universal group will need to be re-added to it. ",
1510 len(legacy_ids),
1511 )
1512 return True
1513
1514
1515def _migrate_airplay_receiver_ghost_players(data: dict[str, Any]) -> bool:
1516 """
1517 Remove ghost players left behind by this server's own AirPlay Receiver instances.
1518
1519 The AirPlay provider could discover the server's own AirPlay Receiver
1520 (shairport-sync) advertisements as regular AirPlay players. shairport-sync
1521 derives its device id from the receiver name plus a host interface MAC, which
1522 can change per boot (e.g. virtual interface MACs), so every restart could mint
1523 a new player id: the previous ids linger as permanently unavailable players and
1524 universal player wrappers. Discovery now filters these advertisements out; this
1525 migration drops the leftovers.
1526 """
1527 all_provider_configs = data.get(CONF_PROVIDERS, {})
1528 all_player_configs = data.get(CONF_PLAYERS, {})
1529 if not isinstance(all_provider_configs, dict) or not isinstance(all_player_configs, dict):
1530 return False
1531 # the advertised name of every enabled receiver instance
1532 # (key and default mirror the airplay_receiver provider's config entry).
1533 # Disabled instances are skipped, consistent with the discovery filter: they
1534 # run no daemon and cannot have produced the ghosts, so their name is too weak
1535 # a signal to delete a config on (it could be a legitimate same-named device).
1536 receiver_names: set[str] = set()
1537 for provider_cfg in all_provider_configs.values():
1538 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != "airplay_receiver":
1539 continue
1540 if not provider_cfg.get("enabled", True):
1541 continue
1542 setup_data = provider_cfg.get("setup_data")
1543 if isinstance(setup_data, dict) and "airplay_name" in setup_data:
1544 # New setup-flow instances cannot have produced legacy ghosts. Their
1545 # encrypted receiver name is unavailable during this early migration.
1546 continue
1547 provider_values = provider_cfg.get("values")
1548 if provider_cfg.get("instance_id") == "airplay_receiver" or (
1549 isinstance(provider_values, dict) and CONF_CONNECTED_PLAYERS in provider_values
1550 ):
1551 # a per-player-model instance advertises player-derived names, so the
1552 # legacy default names this cleanup matches on cannot originate there.
1553 # The bare domain id is the durable signal (the legacy multi-instance
1554 # era always minted suffixed ids); the connected_players marker alone
1555 # is not, as the config store drops it again when the stored value
1556 # equals the entry default (an empty selection).
1557 continue
1558 airplay_name = (
1559 provider_values.get("airplay_name") if isinstance(provider_values, dict) else None
1560 )
1561 receiver_names.add(str(airplay_name) if airplay_name else "Music Assistant")
1562 if not receiver_names:
1563 return False
1564 # the Sendspin bridge of such a ghost registered under "<name> (AirPlay)"
1565 bridge_names = {f"{name} (AirPlay)" for name in receiver_names}
1566
1567 # First identify the ghost protocol endpoints: the discovered AirPlay player and
1568 # its Sendspin bridge, each matched by its own advertised (receiver) name.
1569 endpoint_ghost_ids: set[str] = set()
1570 for player_id, player_cfg in all_player_configs.items():
1571 if not isinstance(player_cfg, dict):
1572 continue
1573 default_name = player_cfg.get("default_name")
1574 provider = player_cfg.get("provider")
1575 if (
1576 player_id.startswith("ap") and provider == "airplay" and default_name in receiver_names
1577 ) or (
1578 player_id.startswith("spb_") and provider == "sendspin" and default_name in bridge_names
1579 ):
1580 endpoint_ghost_ids.add(player_id)
1581
1582 # Then add the universal player wrappers that exclusively wrap those endpoints.
1583 # A wrapper is only removed when it links at least one confirmed ghost endpoint
1584 # and nothing else, so a real player that merely shares the receiver name (with
1585 # no or different linked protocols) is never deleted.
1586 ghost_ids = set(endpoint_ghost_ids)
1587 for player_id, player_cfg in all_player_configs.items():
1588 if not isinstance(player_cfg, dict):
1589 continue
1590 if not (
1591 player_id.startswith("up")
1592 and player_cfg.get("provider") == "universal_player"
1593 and player_cfg.get("default_name") in receiver_names | bridge_names
1594 ):
1595 continue
1596 values = player_cfg.get("values")
1597 linked = values.get(CONF_LINKED_PROTOCOL_IDS) if isinstance(values, dict) else None
1598 if isinstance(linked, list) and linked and all(pid in endpoint_ghost_ids for pid in linked):
1599 ghost_ids.add(player_id)
1600 if not ghost_ids:
1601 return False
1602
1603 for player_id in ghost_ids:
1604 del all_player_configs[player_id]
1605 # drop dead per-queue and DSP state along with the player config
1606 for tree_key in (CONF_PLAYER_QUEUES, CONF_PLAYER_DSP):
1607 tree = data.get(tree_key)
1608 if isinstance(tree, dict):
1609 tree.pop(player_id, None)
1610 # strip dangling references to the removed ghosts from group configurations
1611 for player_cfg in all_player_configs.values():
1612 if not isinstance(player_cfg, dict):
1613 continue
1614 values = player_cfg.get("values")
1615 if not isinstance(values, dict):
1616 continue
1617 for key in ("group_members", "allowed_members"):
1618 members = values.get(key)
1619 if isinstance(members, list) and any(pid in ghost_ids for pid in members):
1620 values[key] = [pid for pid in members if pid not in ghost_ids]
1621 LOGGER.info(
1622 "Removed %d ghost player config(s) left behind by this server's own "
1623 "AirPlay Receiver instances",
1624 len(ghost_ids),
1625 )
1626 return True
1627
1628
1629def _migrate_airplay_apple_power_control(data: dict[str, Any]) -> bool:
1630 """
1631 Enable native power control for Apple TVs paired before the feature existed.
1632
1633 Native on/off (Companion) power control was added to Apple TVs later, but
1634 players configured earlier kept the power_control default from that time
1635 ("none"), so the power button stayed hidden. Flip that stale default to
1636 "native" for paired Apple devices (those with Companion credentials, i.e.
1637 the ones that actually gained the feature); a device that turns out not to
1638 support power degrades back to "none" at runtime.
1639 """
1640 all_player_configs = data.get(CONF_PLAYERS, {})
1641 if not isinstance(all_player_configs, dict):
1642 return False
1643 changed = False
1644 for player_id, player_cfg in all_player_configs.items():
1645 if not isinstance(player_cfg, dict):
1646 continue
1647 if not str(player_cfg.get("provider", "")).startswith("airplay"):
1648 continue
1649 values = player_cfg.get("values")
1650 if not isinstance(values, dict) or not values.get("companion_credentials"):
1651 continue
1652 if values.get("power_control") != PLAYER_CONTROL_NONE:
1653 continue
1654 values["power_control"] = PLAYER_CONTROL_NATIVE
1655 LOGGER.info("Enabled native power control for paired Apple device %s", player_id)
1656 changed = True
1657 return changed
1658
1659
1660def _migrate_output_limiter(data: dict[str, Any]) -> bool:
1661 """Remove the stored values of the removed per-player output limiter setting."""
1662 all_player_configs = data.get(CONF_PLAYERS, {})
1663 if not isinstance(all_player_configs, dict):
1664 return False
1665 changed = False
1666 for player_cfg in all_player_configs.values():
1667 if not isinstance(player_cfg, dict):
1668 continue
1669 player_values = player_cfg.get("values")
1670 if isinstance(player_values, dict) and LEGACY_CONF_OUTPUT_LIMITER in player_values:
1671 del player_values[LEGACY_CONF_OUTPUT_LIMITER]
1672 changed = True
1673 if changed:
1674 LOGGER.info("Removed the obsolete output limiter setting from the player configuration(s)")
1675 return changed
1676
1677
1678# the only HTTP profile BluOS devices play back correctly on
1679FORCED_HTTP_PROFILE = "forced_content_length"
1680
1681
1682def _migrate_bluesound_http_profile(data: dict[str, Any]) -> bool:
1683 """
1684 Drop a stored HTTP profile that Bluesound players can no longer select.
1685
1686 BluOS keeps looping the audio on any profile other than the forced content length one,
1687 so the setting is no longer offered. A player left on another profile would stay broken
1688 with no way back, so that pick is removed.
1689 """
1690 all_player_configs = data.get(CONF_PLAYERS, {})
1691 if not isinstance(all_player_configs, dict):
1692 return False
1693 changed = False
1694 for player_cfg in all_player_configs.values():
1695 if not isinstance(player_cfg, dict):
1696 continue
1697 if not str(player_cfg.get("provider", "")).startswith("bluesound"):
1698 continue
1699 player_values = player_cfg.get("values")
1700 if not isinstance(player_values, dict):
1701 continue
1702 if player_values.get(CONF_HTTP_PROFILE, FORCED_HTTP_PROFILE) != FORCED_HTTP_PROFILE:
1703 del player_values[CONF_HTTP_PROFILE]
1704 changed = True
1705 if changed:
1706 LOGGER.info("Restored the required HTTP profile on the Bluesound player configuration(s)")
1707 return changed
1708
1709
1710def _migrate_unrenamed_player_names(data: dict[str, Any]) -> bool:
1711 """
1712 Clear the stored name of player configs that hold the default name verbatim.
1713
1714 Player configs used to store the name a player was created with as both the custom
1715 and the default name, which makes a never-renamed player indistinguishable from a
1716 renamed one and lets the creation-time name shadow every later default name.
1717 """
1718 all_player_configs = data.get(CONF_PLAYERS, {})
1719 if not isinstance(all_player_configs, dict):
1720 return False
1721 changed = False
1722 for player_cfg in all_player_configs.values():
1723 if not isinstance(player_cfg, dict):
1724 continue
1725 # a config without a default name would be left without any name at all
1726 if not (default_name := player_cfg.get("default_name")):
1727 continue
1728 if player_cfg.get("name") != default_name:
1729 continue
1730 player_cfg["name"] = None
1731 changed = True
1732 return changed
1733
1734
1735def _migrate_orphaned_disabled_protocol_configs(data: dict[str, Any]) -> bool:
1736 """
1737 Remove disabled protocol player configs that no longer belong to a player.
1738
1739 A protocol player is only ever presented as part of the player that owns it, so a
1740 disabled config that outlived its owner keeps the device from registering again while
1741 offering no way to enable it.
1742 """
1743 all_player_configs = data.get(CONF_PLAYERS, {})
1744 if not isinstance(all_player_configs, dict):
1745 return False
1746 linked_ids: set[str] = set()
1747 for player_cfg in all_player_configs.values():
1748 if not isinstance(player_cfg, dict):
1749 continue
1750 player_values = player_cfg.get("values")
1751 if not isinstance(player_values, dict):
1752 continue
1753 if isinstance(cached_ids := player_values.get(CONF_LINKED_PROTOCOL_IDS), list):
1754 linked_ids.update(pid for pid in cached_ids if isinstance(pid, str))
1755 orphaned: list[str] = []
1756 for player_id, player_cfg in all_player_configs.items():
1757 if not isinstance(player_cfg, dict):
1758 continue
1759 if player_cfg.get("player_type") != "protocol":
1760 continue
1761 if player_cfg.get("enabled", True):
1762 continue
1763 # a player owns a protocol player from either side of the link
1764 if player_id in linked_ids:
1765 continue
1766 player_values = player_cfg.get("values")
1767 parent_id = (
1768 player_values.get(CONF_PROTOCOL_PARENT_ID) if isinstance(player_values, dict) else None
1769 )
1770 if parent_id in all_player_configs:
1771 continue
1772 orphaned.append(player_id)
1773 dsp_configs = data.get(CONF_PLAYER_DSP)
1774 for player_id in orphaned:
1775 del all_player_configs[player_id]
1776 if isinstance(dsp_configs, dict):
1777 dsp_configs.pop(player_id, None)
1778 LOGGER.warning("Removed orphaned player configuration %s", player_id)
1779 return bool(orphaned)
1780
1781
1782def _migrate_bose_soundtouch_presets(data: dict[str, Any]) -> bool:
1783 """
1784 Remove the per-player Bose SoundTouch preset mappings.
1785
1786 The physical preset buttons are now mapped once on the provider config, so the same
1787 button plays the same content on every speaker. The old per-player values are dropped
1788 rather than promoted: several speakers can hold conflicting mappings and there is no
1789 correct winner, so the user maps the buttons once more on the provider.
1790 """
1791 all_player_configs = data.get(CONF_PLAYERS, {})
1792 if not isinstance(all_player_configs, dict):
1793 return False
1794 changed = False
1795 for player_id, player_cfg in all_player_configs.items():
1796 if not isinstance(player_cfg, dict):
1797 continue
1798 if str(player_cfg.get("provider", "")).split("--", 1)[0] != "bose_soundtouch":
1799 continue
1800 values = player_cfg.get("values")
1801 if not isinstance(values, dict):
1802 continue
1803 preset_keys = [key for key in values if key.startswith(LEGACY_BOSE_PRESET_KEY_PREFIX)]
1804 if not preset_keys:
1805 continue
1806 for key in preset_keys:
1807 del values[key]
1808 LOGGER.info(
1809 "Removed the per-player preset mappings for Bose SoundTouch player %s; "
1810 "map the preset buttons on the provider settings instead",
1811 player_id,
1812 )
1813 changed = True
1814 return changed
1815
1816
1817_PLAYER_SETUP_DATA_KEYS: dict[str, tuple[str, ...]] = {
1818 "airplay": (
1819 "raop_credentials",
1820 "airplay_credentials",
1821 "companion_credentials",
1822 "mrp_credentials",
1823 "native_mrp_credentials",
1824 ),
1825 "fully_kiosk": ("password",),
1826 "mpd": ("password",),
1827}
1828
1829
1830_PLAYER_DEAD_SETUP_KEYS: dict[str, tuple[str, ...]] = {
1831 "airplay": ("ap2password",),
1832}
1833
1834
1835def _migrate_player_setup_data(data: dict[str, Any]) -> bool:
1836 """
1837 Move player-owned credential/pairing keys from player `values` into `setup_data`.
1838
1839 Idempotent (only moves a key still present in `values` and absent from `setup_data`)
1840 and multi-instance safe (matches on the player provider domain). Values are moved
1841 as-is: they are already encrypted SECURE_STRINGs, which is exactly the at-rest form
1842 setup_data expects. Also drops keys that are dead now (never read at runtime).
1843 """
1844 all_player_configs = data.get(CONF_PLAYERS, {})
1845 if not isinstance(all_player_configs, dict):
1846 return False
1847 changed = False
1848 for player_id, player_cfg in all_player_configs.items():
1849 if not isinstance(player_cfg, dict):
1850 continue
1851 domain = str(player_cfg.get("provider", "")).split("--", 1)[0]
1852 move_keys = _PLAYER_SETUP_DATA_KEYS.get(domain, ())
1853 dead_keys = _PLAYER_DEAD_SETUP_KEYS.get(domain, ())
1854 if not move_keys and not dead_keys:
1855 continue
1856 values = player_cfg.get("values")
1857 if not isinstance(values, dict):
1858 continue
1859 setup_data = player_cfg.get("setup_data")
1860 if not isinstance(setup_data, dict):
1861 setup_data = {}
1862 moved = False
1863 for key in move_keys:
1864 if key not in values:
1865 continue
1866 value = values.pop(key)
1867 moved = True
1868 # a stored null is just dropped; only real values move across
1869 if value is not None and key not in setup_data:
1870 setup_data[key] = value
1871 for key in dead_keys:
1872 if key in values:
1873 del values[key]
1874 moved = True
1875 if moved:
1876 if setup_data:
1877 player_cfg["setup_data"] = setup_data
1878 LOGGER.info(
1879 "Migrated credential/pairing values into setup_data for player %s", player_id
1880 )
1881 changed = True
1882 return changed
1883
1884
1885def _migrate_player_icons(data: dict[str, Any]) -> bool:
1886 """Rewrite legacy stored player icon values to canonical shared-icon-set ids."""
1887 all_player_configs = data.get(CONF_PLAYERS, {})
1888 if not isinstance(all_player_configs, dict):
1889 return False
1890 changed = False
1891 for player_id, player_cfg in all_player_configs.items():
1892 if not isinstance(player_cfg, dict):
1893 continue
1894 values = player_cfg.get("values")
1895 if not isinstance(values, dict):
1896 continue
1897 icon = values.get(CONF_ICON)
1898 if not isinstance(icon, str) or icon in _CANONICAL_ICON_IDS:
1899 continue
1900 if (replacement := _LEGACY_ICON_MAP.get(icon)) is not None:
1901 values[CONF_ICON] = replacement
1902 LOGGER.info("Migrated icon %s to %s for player %s", icon, replacement, player_id)
1903 changed = True
1904 elif icon.startswith("mdi-"):
1905 # no close equivalent in the shared icon set: drop the stored value
1906 # so the player-type default applies
1907 del values[CONF_ICON]
1908 LOGGER.info("Dropped legacy icon %s for player %s", icon, player_id)
1909 changed = True
1910 # any other unknown value is left in place: clients render the fallback icon
1911 # for unknown ids and the value may become a valid id in a future icon set
1912 return changed
1913