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