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