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