/
/
/
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 return changed
627
628
629def migrate_provider_setup_data(data: dict[str, Any], encrypt: Callable[[str], str]) -> bool:
630 """
631 Move each provider's setup-flow-owned keys from `values` to `setup_data` in-place.
632
633 Runs after encryption is initialized (unlike migrate()), so string values are
634 encrypted at rest with the given callback - matching how the setup flows persist
635 collected values. Returns True if anything changed.
636
637 :param data: The persistent settings data to migrate in-place.
638 :param encrypt: Callback that encrypts a string value (idempotent for already
639 encrypted values), used to encrypt migrated string values at rest.
640 """
641 all_provider_configs = data.get(CONF_PROVIDERS, {})
642 if not isinstance(all_provider_configs, dict):
643 return False
644 changed = False
645 for provider_cfg in all_provider_configs.values():
646 if not isinstance(provider_cfg, dict):
647 continue
648 owned_keys = PROVIDER_SETUP_FLOW_KEYS.get(provider_cfg.get("domain", ""))
649 if not owned_keys:
650 continue
651 values = provider_cfg.get("values")
652 if not isinstance(values, dict):
653 continue
654 movable_keys = [key for key in owned_keys if key in values]
655 setup_data = provider_cfg.get("setup_data")
656 needs_airplay_default = provider_cfg.get("domain") == "airplay_receiver" and (
657 not isinstance(setup_data, dict) or "airplay_name" not in setup_data
658 )
659 if not movable_keys and not needs_airplay_default:
660 continue
661 if not isinstance(setup_data, dict):
662 setup_data = {}
663 provider_cfg["setup_data"] = setup_data
664 for key in movable_keys:
665 # a value already collected into setup_data wins; only drop the stale copy
666 if key not in setup_data:
667 value = values[key]
668 setup_data[key] = encrypt(value) if isinstance(value, str) else value
669 del values[key]
670 if needs_airplay_default and "airplay_name" not in setup_data:
671 setup_data["airplay_name"] = encrypt("Music Assistant")
672 changed = True
673 if changed:
674 LOGGER.info("Migrated provider setup values into setup_data")
675 return changed
676
677
678# TODO: remove after 2.10 release
679def migrate_nfs_subfolder_into_export_path(
680 data: dict[str, Any],
681 encrypt: Callable[[str], str],
682 decrypt: Callable[[str], str],
683) -> bool:
684 """
685 Fold a stored NFS `subfolder` into its `export_path`, once.
686
687 The provider mounts the export as configured and scans the subfolder inside that mount, so
688 folding the two keys into one keeps an existing instance mounting what it already mounts.
689 Runs after encryption is initialized, like migrate_provider_setup_data, because both keys
690 live encrypted in `setup_data`.
691
692 Guarded by CONF_NFS_SUBFOLDER_MIGRATED so it cannot run twice: a subfolder stored
693 afterwards means "scan this path inside the mount" and must never be folded. Returns True
694 when the settings were modified, including the first run's marker.
695
696 :param data: The persistent settings data to migrate in-place.
697 :param encrypt: Callback that encrypts a string value at rest.
698 :param decrypt: Callback that decrypts a stored string value (a no-op for plain values).
699 """
700 if data.get(CONF_NFS_SUBFOLDER_MIGRATED):
701 return False
702 all_provider_configs = data.get(CONF_PROVIDERS, {})
703 if not isinstance(all_provider_configs, dict):
704 return False
705 changed = False
706 for instance_id, provider_cfg in all_provider_configs.items():
707 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != "filesystem_nfs":
708 continue
709 setup_data = provider_cfg.get("setup_data")
710 if not isinstance(setup_data, dict):
711 continue
712 stored_subfolder = setup_data.get("subfolder")
713 stored_export_path = setup_data.get("export_path")
714 if not isinstance(stored_subfolder, str) or not isinstance(stored_export_path, str):
715 continue
716 try:
717 subfolder = decrypt(stored_subfolder).strip()
718 export_path = decrypt(stored_export_path)
719 except InvalidDataError:
720 # one unreadable instance must not fail config setup for the whole server; it
721 # still surfaces the problem at its own setup. Name it without its values.
722 LOGGER.warning(
723 "Could not read the stored NFS paths of %s; skipping its subfolder migration",
724 instance_id,
725 )
726 continue
727 if not subfolder or not export_path:
728 # an empty export path is broken either way and must not become a relative one
729 continue
730 # must come out as <export_path>/<subfolder> so the mount source is unchanged
731 setup_data["export_path"] = encrypt(str(PurePosixPath(export_path) / subfolder.lstrip("/")))
732 del setup_data["subfolder"]
733 changed = True
734 if changed:
735 LOGGER.info("Migrated NFS provider subfolder into the export path")
736 # claim the marker even when nothing was folded, so a subfolder stored later is safe
737 data[CONF_NFS_SUBFOLDER_MIGRATED] = True
738 return True
739
740
741# TODO: remove after 2.10 release
742def migrate_hass_engine_selection(data: dict[str, Any], encrypt: Callable[[str], str]) -> bool:
743 """
744 Hand the removed Home Assistant TTS/AI entity choice over to the providers consuming it.
745
746 The Home Assistant plugin exposes every TTS/AI entity as a selectable engine now and each
747 consuming provider picks one itself, so the single choice that used to live on the plugin
748 is copied to the installed consumers that have no choice of their own yet. Providers
749 installed later pick an engine themselves at load. Returns True if anything changed.
750
751 Runs after encryption is initialized (like migrate_provider_setup_data), since the ai_radio
752 selection belongs in its encrypted `setup_data`.
753
754 :param data: The persistent settings data to migrate in-place.
755 :param encrypt: Callback that encrypts a string value, used for the values that land in
756 `setup_data`.
757 """
758 all_provider_configs = data.get(CONF_PROVIDERS, {})
759 if not isinstance(all_provider_configs, dict):
760 return False
761 hass_configs = {
762 instance_id: provider_cfg
763 for instance_id, provider_cfg in all_provider_configs.items()
764 if isinstance(provider_cfg, dict) and provider_cfg.get("domain") == "hass"
765 }
766 if len(hass_configs) > 1:
767 # there is no correct winner between several choices, so let the user pick per provider
768 LOGGER.warning(
769 "Skipped migrating the Home Assistant TTS/AI entity selection: "
770 "%s Home Assistant configurations found, select the engines manually",
771 len(hass_configs),
772 )
773 return False
774 changed = False
775 for instance_id, hass_cfg in hass_configs.items():
776 values = hass_cfg.get("values")
777 if not isinstance(values, dict):
778 continue
779 if not any(key in values for key in (LEGACY_CONF_TTS_ENTITY, LEGACY_CONF_AI_TASK_ENTITY)):
780 continue
781 tts_entity = values.pop(LEGACY_CONF_TTS_ENTITY, None)
782 ai_task_entity = values.pop(LEGACY_CONF_AI_TASK_ENTITY, None)
783 changed = True
784 if isinstance(ai_task_entity, str) and ai_task_entity:
785 ai_engine = f"{instance_id}/{ai_task_entity}"
786 _set_engine_selection(
787 all_provider_configs, "music_quiz", "values", CONF_AI_ENGINE, ai_engine
788 )
789 _set_engine_selection(
790 all_provider_configs, "smart_playlist", "values", CONF_AI_ENGINE, ai_engine
791 )
792 _set_engine_selection(
793 all_provider_configs, "ai_radio", "setup_data", CONF_AI_ENGINE, encrypt(ai_engine)
794 )
795 if isinstance(tts_entity, str) and tts_entity:
796 _set_engine_selection(
797 all_provider_configs,
798 "ai_radio",
799 "setup_data",
800 CONF_TTS_ENGINE,
801 encrypt(f"{instance_id}/{tts_entity}"),
802 )
803 LOGGER.info("Migrated the Home Assistant TTS/AI entity selection to the plugin engines")
804 return changed
805
806
807def _set_engine_selection(
808 all_provider_configs: dict[str, Any], domain: str, section: str, key: str, value: str
809) -> None:
810 """Store an engine selection on each config of the given domain that has none of its own."""
811 for provider_cfg in all_provider_configs.values():
812 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != domain:
813 continue
814 section_values = provider_cfg.get(section)
815 if not isinstance(section_values, dict):
816 section_values = {}
817 provider_cfg[section] = section_values
818 section_values.setdefault(key, value)
819
820
821def _migrate_player_queue_settings(data: dict[str, Any]) -> bool:
822 """Move queue-scoped settings from the per-player config to the per-queue config."""
823 moved_keys = (
824 CONF_CROSSFADE_DURATION,
825 CONF_VOLUME_NORMALIZATION,
826 )
827 all_player_configs = data.get(CONF_PLAYERS, {})
828 if not isinstance(all_player_configs, dict):
829 return False
830 changed = False
831 for player_id, player_cfg in all_player_configs.items():
832 if not isinstance(player_cfg, dict):
833 continue
834 player_values = player_cfg.get("values")
835 if not isinstance(player_values, dict):
836 continue
837 to_move = {key: player_values[key] for key in moved_keys if key in player_values}
838 # the legacy smart_fades_mode encoded both on/off and standard-vs-smart; the on/off is
839 # now a runtime queue toggle, and standard/smart carries over to the crossfade_mode
840 # select ("disabled" just means crossfade is off -> nothing to carry). Consume the key.
841 legacy_mode = player_values.pop(CONF_SMART_FADES_MODE, None)
842 migrated_mode = (
843 legacy_mode
844 if legacy_mode in (CrossfadeMode.STANDARD_CROSSFADE, CrossfadeMode.SMART_CROSSFADE)
845 else None
846 )
847 if not to_move and legacy_mode is None:
848 continue
849 if to_move or migrated_mode is not None:
850 queue_cfg = data.setdefault(CONF_PLAYER_QUEUES, {}).setdefault(
851 player_id, {"queue_id": player_id}
852 )
853 queue_values = queue_cfg.setdefault("values", {})
854 for key, value in to_move.items():
855 # don't clobber an existing queue value if one was already stored
856 queue_values.setdefault(key, value)
857 del player_values[key]
858 if migrated_mode is not None:
859 queue_values.setdefault(CONF_CROSSFADE_MODE, migrated_mode)
860 LOGGER.info("Migrated queue settings for %s", player_id)
861 changed = True
862 return changed
863
864
865def _migrate_global_queue_settings(data: dict[str, Any]) -> bool:
866 """
867 Adopt the global-with-override model for the per-queue settings.
868
869 The two former boolean toggles become their select strings (so a queue can also follow the
870 global value), and the settings that are now global-only are promoted to the Player Queues core
871 config. Queues that stored nothing keep nothing and therefore fall back to the new "global"
872 default. Idempotent: a second run finds only select strings and no per-queue global-only values.
873 """
874 all_queue_configs = data.get(CONF_PLAYER_QUEUES, {})
875 if not isinstance(all_queue_configs, dict):
876 return False
877 changed = False
878 # 1. convert the former booleans (True/False) to their select strings (enabled/disabled)
879 bool_to_select = {True: CONF_VALUE_ENABLED, False: CONF_VALUE_DISABLED}
880 for queue_cfg in all_queue_configs.values():
881 if not isinstance(queue_cfg, dict):
882 continue
883 values = queue_cfg.get("values")
884 if not isinstance(values, dict):
885 continue
886 for key in (CONF_VOLUME_NORMALIZATION, CONF_SMART_SHUFFLE_ENABLED):
887 if isinstance(values.get(key), bool):
888 values[key] = bool_to_select[values[key]]
889 changed = True
890 # 2. promote the now global-only settings to the Player Queues core config
891 global_only_keys = (
892 CONF_CROSSFADE_DURATION,
893 CONF_SMART_SHUFFLE_SONG_RECENCY,
894 CONF_SMART_SHUFFLE_ARTIST_RECENCY,
895 CONF_SMART_SHUFFLE_DUPLICATE_GAP,
896 )
897 for key in global_only_keys:
898 if _promote_queue_setting_to_global(data, key):
899 changed = True
900 return changed
901
902
903def _promote_queue_setting_to_global(data: dict[str, Any], key: str) -> bool:
904 """
905 Promote a (now global-only) per-queue setting to global config and drop the per-queue copies.
906
907 A single value shared by every queue that set it is promoted so the user's preference is kept;
908 mixed values fall back to the new default. Mirrors _migrate_volume_normalization_target.
909 """
910 all_queue_configs = data.get(CONF_PLAYER_QUEUES, {})
911 if not isinstance(all_queue_configs, dict):
912 return False
913 stored_values: set[Any] = set()
914 for queue_cfg in all_queue_configs.values():
915 if not isinstance(queue_cfg, dict):
916 continue
917 values = queue_cfg.get("values")
918 if isinstance(values, dict) and key in values:
919 stored_values.add(values[key])
920 if not stored_values:
921 return False
922 # promote only a single consistent value (mixed values fall back to the new default), and never
923 # clobber a value the user already set globally; only touch the core config when promoting
924 existing_core = data.get(CONF_CORE, {}).get(CONF_PLAYER_QUEUES, {})
925 existing_values = existing_core.get("values", {}) if isinstance(existing_core, dict) else {}
926 if len(stored_values) == 1 and key not in existing_values:
927 core_values = (
928 data.setdefault(CONF_CORE, {})
929 .setdefault(CONF_PLAYER_QUEUES, {"domain": CONF_PLAYER_QUEUES})
930 .setdefault("values", {})
931 )
932 core_values[key] = next(iter(stored_values))
933 LOGGER.info("Promoted per-queue %s to the global Player Queues config", key)
934 # the setting is global-only now, so drop every per-queue copy
935 for queue_cfg in all_queue_configs.values():
936 if not isinstance(queue_cfg, dict):
937 continue
938 values = queue_cfg.get("values")
939 if isinstance(values, dict):
940 values.pop(key, None)
941 return True
942
943
944def _migrate_volume_normalization_target(data: dict[str, Any]) -> bool:
945 """
946 Migrate volume_normalization_target from per-player to the global streams setting.
947
948 Collects all explicitly stored per-player values; if they all agree on a single value,
949 that value is promoted to the streams core config so the user's preference is preserved.
950 """
951 all_player_configs = data.get(CONF_PLAYERS, {})
952 if not isinstance(all_player_configs, dict):
953 return False
954 per_player_values: set[int] = set()
955 for player_cfg in all_player_configs.values():
956 if not isinstance(player_cfg, dict):
957 continue
958 values = player_cfg.get("values")
959 if not isinstance(values, dict):
960 continue
961 if CONF_VOLUME_NORMALIZATION_TARGET in values:
962 per_player_values.add(int(values[CONF_VOLUME_NORMALIZATION_TARGET]))
963
964 if not per_player_values:
965 return False
966
967 streams_core = data.setdefault(CONF_CORE, {}).setdefault("streams", {})
968 streams_values = streams_core.setdefault("values", {})
969 # only promote when not already globally configured
970 if CONF_VOLUME_NORMALIZATION_TARGET not in streams_values:
971 # single consistent value across all players → promote it; mixed → use new default
972 promoted = per_player_values.pop() if len(per_player_values) == 1 else None
973 if promoted is not None:
974 streams_values[CONF_VOLUME_NORMALIZATION_TARGET] = promoted
975 LOGGER.info(
976 "Promoted volume_normalization_target %s LUFS to global streams setting",
977 promoted,
978 )
979
980 for player_id, player_cfg in all_player_configs.items():
981 if not isinstance(player_cfg, dict):
982 continue
983 values = player_cfg.get("values")
984 if not isinstance(values, dict):
985 continue
986 if CONF_VOLUME_NORMALIZATION_TARGET in values:
987 del values[CONF_VOLUME_NORMALIZATION_TARGET]
988 LOGGER.info(
989 "Removed per-player volume_normalization_target for player %s",
990 player_id,
991 )
992 return True
993
994
995def _migrate_local_audio_attribution_stubs(data: dict[str, Any]) -> bool:
996 """
997 Promote local_audio attribution-stub players to regular players.
998
999 The local_audio provider used to register a hidden PROTOCOL "attribution stub"
1000 per audio device, which got wrapped (together with the Sendspin bridge player)
1001 in an auto-created universal player. The stub is now a regular, visible player
1002 that parents the Sendspin bridge directly, making the universal player wrapper
1003 obsolete: its user settings move onto the stub's config (same bare device-uuid
1004 player_id) and the wrapper is removed.
1005 """
1006 all_player_configs = data.get(CONF_PLAYERS, {})
1007 if not isinstance(all_player_configs, dict):
1008 return False
1009 changed = False
1010 for player_id, player_cfg in list(all_player_configs.items()):
1011 if not isinstance(player_cfg, dict):
1012 continue
1013 if player_cfg.get("provider") != "local_audio":
1014 continue
1015 if player_cfg.get("player_type") != "protocol":
1016 continue
1017 player_cfg["player_type"] = "player"
1018 values = player_cfg.setdefault("values", {})
1019 values.pop(CONF_PROTOCOL_PARENT_ID, None)
1020 changed = True
1021
1022 # the universal player wrapper was keyed on the stub's player_id
1023 # (the stub had no device identifiers to derive a device key from)
1024 universal_id = f"up{player_id.replace('-', '').lower()}"
1025 universal_cfg = all_player_configs.get(universal_id)
1026 if isinstance(universal_cfg, dict) and universal_cfg.get("provider") == "universal_player":
1027 del all_player_configs[universal_id]
1028 _absorb_universal_player_config(
1029 data, player_id, player_cfg, universal_id, universal_cfg
1030 )
1031 LOGGER.info(
1032 "Migrated universal player %s settings to local_audio player %s",
1033 universal_id,
1034 player_id,
1035 )
1036 LOGGER.info("Promoted local_audio player %s to a regular player", player_id)
1037 return changed
1038
1039
1040def _absorb_universal_player_config(
1041 data: dict[str, Any],
1042 player_id: str,
1043 player_cfg: dict[str, Any],
1044 universal_id: str,
1045 universal_cfg: dict[str, Any],
1046) -> None:
1047 """
1048 Fold the user settings of an obsolete universal player onto its replacement.
1049
1050 The universal player was the visible device the user configured, so its
1051 settings win over anything stored on the (hidden) stub. Everything keyed on
1052 the old universal player_id (protocol parent links, queue settings, DSP
1053 config, group memberships) is re-pointed to the new player_id.
1054 """
1055 player_cfg["enabled"] = universal_cfg.get("enabled", True)
1056 # only carry an actual user rename, not the auto-generated default name
1057 if universal_cfg.get("name") and universal_cfg.get("name") != universal_cfg.get("default_name"):
1058 player_cfg["name"] = universal_cfg["name"]
1059
1060 values = player_cfg.setdefault("values", {})
1061 universal_values = universal_cfg.get("values")
1062 universal_values = universal_values if isinstance(universal_values, dict) else {}
1063 # bookkeeping only relevant to the universal player wrapper itself
1064 internal_keys = (
1065 CONF_LINKED_PROTOCOL_IDS,
1066 CONF_PROTOCOL_PARENT_ID,
1067 "device_identifiers",
1068 "device_info",
1069 )
1070 for key, value in universal_values.items():
1071 if key in internal_keys:
1072 continue
1073 values[key] = value
1074
1075 # carry the linked protocols (minus the stub itself, it is the parent now)
1076 # and re-point their cached parent so they restore fast on the next start
1077 linked_ids = [
1078 pid for pid in (universal_values.get(CONF_LINKED_PROTOCOL_IDS) or []) if pid != player_id
1079 ]
1080 if linked_ids:
1081 existing_ids = list(values.get(CONF_LINKED_PROTOCOL_IDS) or [])
1082 values[CONF_LINKED_PROTOCOL_IDS] = existing_ids + [
1083 pid for pid in linked_ids if pid not in existing_ids
1084 ]
1085 all_player_configs = data.get(CONF_PLAYERS, {})
1086 for protocol_id in linked_ids:
1087 protocol_cfg = all_player_configs.get(protocol_id)
1088 if not isinstance(protocol_cfg, dict):
1089 continue
1090 protocol_values = protocol_cfg.setdefault("values", {})
1091 if protocol_values.get(CONF_PROTOCOL_PARENT_ID) == universal_id:
1092 protocol_values[CONF_PROTOCOL_PARENT_ID] = player_id
1093
1094 # move per-queue settings and DSP configuration to the new player_id
1095 for tree_key in (CONF_PLAYER_QUEUES, CONF_PLAYER_DSP):
1096 tree = data.get(tree_key)
1097 if isinstance(tree, dict) and universal_id in tree and player_id not in tree:
1098 tree[player_id] = tree.pop(universal_id)
1099 if tree_key == CONF_PLAYER_QUEUES and isinstance(tree[player_id], dict):
1100 tree[player_id]["queue_id"] = player_id
1101
1102 # re-point group memberships that referenced the universal player
1103 for other_cfg in all_player_configs.values():
1104 if not isinstance(other_cfg, dict):
1105 continue
1106 other_values = other_cfg.get("values")
1107 if not isinstance(other_values, dict):
1108 continue
1109 for key in ("group_members", "allowed_members"):
1110 members = other_values.get(key)
1111 if isinstance(members, list) and universal_id in members:
1112 other_values[key] = [player_id if pid == universal_id else pid for pid in members]
1113
1114
1115def _migrate_self_referential_protocol_links(data: dict[str, Any]) -> bool:
1116 """Clear protocol links that point a player at its own id."""
1117 all_player_configs = data.get(CONF_PLAYERS, {})
1118 if not isinstance(all_player_configs, dict):
1119 return False
1120 changed = False
1121 for player_id, player_cfg in all_player_configs.items():
1122 if not isinstance(player_cfg, dict):
1123 continue
1124 values = player_cfg.get("values")
1125 if not isinstance(values, dict):
1126 continue
1127 repaired = False
1128 if values.get(CONF_PROTOCOL_PARENT_ID) == player_id:
1129 values[CONF_PROTOCOL_PARENT_ID] = None
1130 repaired = True
1131 linked = values.get(CONF_LINKED_PROTOCOL_IDS)
1132 if isinstance(linked, list) and player_id in linked:
1133 values[CONF_LINKED_PROTOCOL_IDS] = [pid for pid in linked if pid != player_id]
1134 repaired = True
1135 if repaired:
1136 LOGGER.warning("Repaired self-referential protocol link for %s", player_id)
1137 changed = True
1138 return changed
1139
1140
1141def _migrate_metadata_maintenance_schedule(data: dict[str, Any]) -> bool:
1142 """Remove the orphaned persisted state for the pre-randomization metadata task ids."""
1143 core_config = data.get(CONF_CORE)
1144 if not isinstance(core_config, dict):
1145 return False
1146 tasks_config = core_config.get("tasks")
1147 if not isinstance(tasks_config, dict):
1148 return False
1149 task_states = tasks_config.get("scheduled_task_states")
1150 if not isinstance(task_states, dict):
1151 return False
1152 legacy_task_ids = (
1153 "metadata_missing_artist_metadata_scan",
1154 "metadata_playlist_metadata_scan",
1155 "metadata_thumb_cache_cleanup",
1156 )
1157 removed = [task_id for task_id in legacy_task_ids if task_id in task_states]
1158 for task_id in removed:
1159 del task_states[task_id]
1160 if removed:
1161 LOGGER.info("Removed orphaned metadata maintenance schedule state for %s", removed)
1162 return bool(removed)
1163
1164
1165def _migrate_fully_kiosk_multi_instance(data: dict[str, Any]) -> bool:
1166 """Collapse legacy multi-instance Fully Kiosk configs into a single provider instance."""
1167 providers = data.get(CONF_PROVIDERS, {})
1168 legacy_ids = [
1169 iid
1170 for iid, conf in providers.items()
1171 if isinstance(conf, dict) and conf.get("domain") == "fully_kiosk" and iid != "fully_kiosk"
1172 ]
1173 if not legacy_ids:
1174 return False
1175
1176 ip_entries: list[str] = []
1177 players = data.setdefault(CONF_PLAYERS, {})
1178 for iid in legacy_ids:
1179 old_values = providers[iid].get("values") or {}
1180 host = old_values.get("ip_address")
1181 if not host:
1182 del providers[iid]
1183 continue
1184 try:
1185 port = int(old_values.get("port") or 2323)
1186 except TypeError, ValueError:
1187 port = 2323
1188 entry = host if port == 2323 else f"{host}:{port}"
1189 if entry not in ip_entries:
1190 ip_entries.append(entry)
1191
1192 new_player_id = f"fully_kiosk_{host}_{port}"
1193 player_conf = players.setdefault(
1194 new_player_id,
1195 {
1196 "player_id": new_player_id,
1197 "provider": "fully_kiosk",
1198 "enabled": True,
1199 "values": {},
1200 },
1201 )
1202 player_values = player_conf.setdefault("values", {})
1203 for key in ("password", "use_ssl", "verify_ssl", "ssl_fingerprint"):
1204 if old_values.get(key) is not None and key not in player_values:
1205 player_values[key] = old_values[key]
1206
1207 del providers[iid]
1208
1209 if "fully_kiosk" in providers:
1210 existing_values = providers["fully_kiosk"].setdefault("values", {})
1211 existing_ips = list(existing_values.get("manual_discovery_ip_addresses") or [])
1212 for entry in ip_entries:
1213 if entry not in existing_ips:
1214 existing_ips.append(entry)
1215 existing_values["manual_discovery_ip_addresses"] = existing_ips
1216 else:
1217 providers["fully_kiosk"] = {
1218 "type": "player",
1219 "domain": "fully_kiosk",
1220 "instance_id": "fully_kiosk",
1221 "enabled": True,
1222 "values": {"manual_discovery_ip_addresses": ip_entries},
1223 }
1224
1225 LOGGER.warning(
1226 "Migrated %d legacy Fully Kiosk provider instance(s) into a single instance. "
1227 "Devices and their passwords have been preserved, but any Fully Kiosk player "
1228 "that was part of a universal group will need to be re-added to it. ",
1229 len(legacy_ids),
1230 )
1231 return True
1232
1233
1234def _migrate_airplay_receiver_ghost_players(data: dict[str, Any]) -> bool:
1235 """
1236 Remove ghost players left behind by this server's own AirPlay Receiver instances.
1237
1238 The AirPlay provider could discover the server's own AirPlay Receiver
1239 (shairport-sync) advertisements as regular AirPlay players. shairport-sync
1240 derives its device id from the receiver name plus a host interface MAC, which
1241 can change per boot (e.g. virtual interface MACs), so every restart could mint
1242 a new player id: the previous ids linger as permanently unavailable players and
1243 universal player wrappers. Discovery now filters these advertisements out; this
1244 migration drops the leftovers.
1245 """
1246 all_provider_configs = data.get(CONF_PROVIDERS, {})
1247 all_player_configs = data.get(CONF_PLAYERS, {})
1248 if not isinstance(all_provider_configs, dict) or not isinstance(all_player_configs, dict):
1249 return False
1250 # the advertised name of every enabled receiver instance
1251 # (key and default mirror the airplay_receiver provider's config entry).
1252 # Disabled instances are skipped, consistent with the discovery filter: they
1253 # run no daemon and cannot have produced the ghosts, so their name is too weak
1254 # a signal to delete a config on (it could be a legitimate same-named device).
1255 receiver_names: set[str] = set()
1256 for provider_cfg in all_provider_configs.values():
1257 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != "airplay_receiver":
1258 continue
1259 if not provider_cfg.get("enabled", True):
1260 continue
1261 setup_data = provider_cfg.get("setup_data")
1262 if isinstance(setup_data, dict) and "airplay_name" in setup_data:
1263 # New setup-flow instances cannot have produced legacy ghosts. Their
1264 # encrypted receiver name is unavailable during this early migration.
1265 continue
1266 provider_values = provider_cfg.get("values")
1267 airplay_name = (
1268 provider_values.get("airplay_name") if isinstance(provider_values, dict) else None
1269 )
1270 receiver_names.add(str(airplay_name) if airplay_name else "Music Assistant")
1271 if not receiver_names:
1272 return False
1273 # the Sendspin bridge of such a ghost registered under "<name> (AirPlay)"
1274 bridge_names = {f"{name} (AirPlay)" for name in receiver_names}
1275
1276 # First identify the ghost protocol endpoints: the discovered AirPlay player and
1277 # its Sendspin bridge, each matched by its own advertised (receiver) name.
1278 endpoint_ghost_ids: set[str] = set()
1279 for player_id, player_cfg in all_player_configs.items():
1280 if not isinstance(player_cfg, dict):
1281 continue
1282 default_name = player_cfg.get("default_name")
1283 provider = player_cfg.get("provider")
1284 if (
1285 player_id.startswith("ap") and provider == "airplay" and default_name in receiver_names
1286 ) or (
1287 player_id.startswith("spb_") and provider == "sendspin" and default_name in bridge_names
1288 ):
1289 endpoint_ghost_ids.add(player_id)
1290
1291 # Then add the universal player wrappers that exclusively wrap those endpoints.
1292 # A wrapper is only removed when it links at least one confirmed ghost endpoint
1293 # and nothing else, so a real player that merely shares the receiver name (with
1294 # no or different linked protocols) is never deleted.
1295 ghost_ids = set(endpoint_ghost_ids)
1296 for player_id, player_cfg in all_player_configs.items():
1297 if not isinstance(player_cfg, dict):
1298 continue
1299 if not (
1300 player_id.startswith("up")
1301 and player_cfg.get("provider") == "universal_player"
1302 and player_cfg.get("default_name") in receiver_names | bridge_names
1303 ):
1304 continue
1305 values = player_cfg.get("values")
1306 linked = values.get(CONF_LINKED_PROTOCOL_IDS) if isinstance(values, dict) else None
1307 if isinstance(linked, list) and linked and all(pid in endpoint_ghost_ids for pid in linked):
1308 ghost_ids.add(player_id)
1309 if not ghost_ids:
1310 return False
1311
1312 for player_id in ghost_ids:
1313 del all_player_configs[player_id]
1314 # drop dead per-queue and DSP state along with the player config
1315 for tree_key in (CONF_PLAYER_QUEUES, CONF_PLAYER_DSP):
1316 tree = data.get(tree_key)
1317 if isinstance(tree, dict):
1318 tree.pop(player_id, None)
1319 # strip dangling references to the removed ghosts from group configurations
1320 for player_cfg in all_player_configs.values():
1321 if not isinstance(player_cfg, dict):
1322 continue
1323 values = player_cfg.get("values")
1324 if not isinstance(values, dict):
1325 continue
1326 for key in ("group_members", "allowed_members"):
1327 members = values.get(key)
1328 if isinstance(members, list) and any(pid in ghost_ids for pid in members):
1329 values[key] = [pid for pid in members if pid not in ghost_ids]
1330 LOGGER.info(
1331 "Removed %d ghost player config(s) left behind by this server's own "
1332 "AirPlay Receiver instances",
1333 len(ghost_ids),
1334 )
1335 return True
1336
1337
1338def _migrate_airplay_apple_power_control(data: dict[str, Any]) -> bool:
1339 """
1340 Enable native power control for Apple TVs paired before the feature existed.
1341
1342 Native on/off (Companion) power control was added to Apple TVs later, but
1343 players configured earlier kept the power_control default from that time
1344 ("none"), so the power button stayed hidden. Flip that stale default to
1345 "native" for paired Apple devices (those with Companion credentials, i.e.
1346 the ones that actually gained the feature); a device that turns out not to
1347 support power degrades back to "none" at runtime.
1348 """
1349 all_player_configs = data.get(CONF_PLAYERS, {})
1350 if not isinstance(all_player_configs, dict):
1351 return False
1352 changed = False
1353 for player_id, player_cfg in all_player_configs.items():
1354 if not isinstance(player_cfg, dict):
1355 continue
1356 if not str(player_cfg.get("provider", "")).startswith("airplay"):
1357 continue
1358 values = player_cfg.get("values")
1359 if not isinstance(values, dict) or not values.get("companion_credentials"):
1360 continue
1361 if values.get("power_control") != PLAYER_CONTROL_NONE:
1362 continue
1363 values["power_control"] = PLAYER_CONTROL_NATIVE
1364 LOGGER.info("Enabled native power control for paired Apple device %s", player_id)
1365 changed = True
1366 return changed
1367
1368
1369def _migrate_output_limiter(data: dict[str, Any]) -> bool:
1370 """Remove the stored values of the removed per-player output limiter setting."""
1371 all_player_configs = data.get(CONF_PLAYERS, {})
1372 if not isinstance(all_player_configs, dict):
1373 return False
1374 changed = False
1375 for player_cfg in all_player_configs.values():
1376 if not isinstance(player_cfg, dict):
1377 continue
1378 player_values = player_cfg.get("values")
1379 if isinstance(player_values, dict) and LEGACY_CONF_OUTPUT_LIMITER in player_values:
1380 del player_values[LEGACY_CONF_OUTPUT_LIMITER]
1381 changed = True
1382 if changed:
1383 LOGGER.info("Removed the obsolete output limiter setting from the player configuration(s)")
1384 return changed
1385
1386
1387# the only HTTP profile BluOS devices play back correctly on
1388FORCED_HTTP_PROFILE = "forced_content_length"
1389
1390
1391def _migrate_bluesound_http_profile(data: dict[str, Any]) -> bool:
1392 """
1393 Drop a stored HTTP profile that Bluesound players can no longer select.
1394
1395 BluOS keeps looping the audio on any profile other than the forced content length one,
1396 so the setting is no longer offered. A player left on another profile would stay broken
1397 with no way back, so that pick is removed.
1398 """
1399 all_player_configs = data.get(CONF_PLAYERS, {})
1400 if not isinstance(all_player_configs, dict):
1401 return False
1402 changed = False
1403 for player_cfg in all_player_configs.values():
1404 if not isinstance(player_cfg, dict):
1405 continue
1406 if not str(player_cfg.get("provider", "")).startswith("bluesound"):
1407 continue
1408 player_values = player_cfg.get("values")
1409 if not isinstance(player_values, dict):
1410 continue
1411 if player_values.get(CONF_HTTP_PROFILE, FORCED_HTTP_PROFILE) != FORCED_HTTP_PROFILE:
1412 del player_values[CONF_HTTP_PROFILE]
1413 changed = True
1414 if changed:
1415 LOGGER.info("Restored the required HTTP profile on the Bluesound player configuration(s)")
1416 return changed
1417
1418
1419def _migrate_orphaned_disabled_protocol_configs(data: dict[str, Any]) -> bool:
1420 """
1421 Remove disabled protocol player configs that no longer belong to a player.
1422
1423 A protocol player is only ever presented as part of the player that owns it, so a
1424 disabled config that outlived its owner keeps the device from registering again while
1425 offering no way to enable it.
1426 """
1427 all_player_configs = data.get(CONF_PLAYERS, {})
1428 if not isinstance(all_player_configs, dict):
1429 return False
1430 linked_ids: set[str] = set()
1431 for player_cfg in all_player_configs.values():
1432 if not isinstance(player_cfg, dict):
1433 continue
1434 player_values = player_cfg.get("values")
1435 if not isinstance(player_values, dict):
1436 continue
1437 if isinstance(cached_ids := player_values.get(CONF_LINKED_PROTOCOL_IDS), list):
1438 linked_ids.update(pid for pid in cached_ids if isinstance(pid, str))
1439 orphaned: list[str] = []
1440 for player_id, player_cfg in all_player_configs.items():
1441 if not isinstance(player_cfg, dict):
1442 continue
1443 if player_cfg.get("player_type") != "protocol":
1444 continue
1445 if player_cfg.get("enabled", True):
1446 continue
1447 # a player owns a protocol player from either side of the link
1448 if player_id in linked_ids:
1449 continue
1450 player_values = player_cfg.get("values")
1451 parent_id = (
1452 player_values.get(CONF_PROTOCOL_PARENT_ID) if isinstance(player_values, dict) else None
1453 )
1454 if parent_id in all_player_configs:
1455 continue
1456 orphaned.append(player_id)
1457 dsp_configs = data.get(CONF_PLAYER_DSP)
1458 for player_id in orphaned:
1459 del all_player_configs[player_id]
1460 if isinstance(dsp_configs, dict):
1461 dsp_configs.pop(player_id, None)
1462 LOGGER.warning("Removed orphaned player configuration %s", player_id)
1463 return bool(orphaned)
1464
1465
1466def _migrate_bose_soundtouch_presets(data: dict[str, Any]) -> bool:
1467 """
1468 Remove the per-player Bose SoundTouch preset mappings.
1469
1470 The physical preset buttons are now mapped once on the provider config, so the same
1471 button plays the same content on every speaker. The old per-player values are dropped
1472 rather than promoted: several speakers can hold conflicting mappings and there is no
1473 correct winner, so the user maps the buttons once more on the provider.
1474 """
1475 all_player_configs = data.get(CONF_PLAYERS, {})
1476 if not isinstance(all_player_configs, dict):
1477 return False
1478 changed = False
1479 for player_id, player_cfg in all_player_configs.items():
1480 if not isinstance(player_cfg, dict):
1481 continue
1482 if str(player_cfg.get("provider", "")).split("--", 1)[0] != "bose_soundtouch":
1483 continue
1484 values = player_cfg.get("values")
1485 if not isinstance(values, dict):
1486 continue
1487 preset_keys = [key for key in values if key.startswith(LEGACY_BOSE_PRESET_KEY_PREFIX)]
1488 if not preset_keys:
1489 continue
1490 for key in preset_keys:
1491 del values[key]
1492 LOGGER.info(
1493 "Removed the per-player preset mappings for Bose SoundTouch player %s; "
1494 "map the preset buttons on the provider settings instead",
1495 player_id,
1496 )
1497 changed = True
1498 return changed
1499
1500
1501_PLAYER_SETUP_DATA_KEYS: dict[str, tuple[str, ...]] = {
1502 "airplay": (
1503 "raop_credentials",
1504 "airplay_credentials",
1505 "companion_credentials",
1506 "mrp_credentials",
1507 "native_mrp_credentials",
1508 ),
1509 "fully_kiosk": ("password",),
1510 "mpd": ("password",),
1511}
1512
1513
1514_PLAYER_DEAD_SETUP_KEYS: dict[str, tuple[str, ...]] = {
1515 "airplay": ("ap2password",),
1516}
1517
1518
1519def _migrate_player_setup_data(data: dict[str, Any]) -> bool:
1520 """
1521 Move player-owned credential/pairing keys from player `values` into `setup_data`.
1522
1523 Idempotent (only moves a key still present in `values` and absent from `setup_data`)
1524 and multi-instance safe (matches on the player provider domain). Values are moved
1525 as-is: they are already encrypted SECURE_STRINGs, which is exactly the at-rest form
1526 setup_data expects. Also drops keys that are dead now (never read at runtime).
1527 """
1528 all_player_configs = data.get(CONF_PLAYERS, {})
1529 if not isinstance(all_player_configs, dict):
1530 return False
1531 changed = False
1532 for player_id, player_cfg in all_player_configs.items():
1533 if not isinstance(player_cfg, dict):
1534 continue
1535 domain = str(player_cfg.get("provider", "")).split("--", 1)[0]
1536 move_keys = _PLAYER_SETUP_DATA_KEYS.get(domain, ())
1537 dead_keys = _PLAYER_DEAD_SETUP_KEYS.get(domain, ())
1538 if not move_keys and not dead_keys:
1539 continue
1540 values = player_cfg.get("values")
1541 if not isinstance(values, dict):
1542 continue
1543 setup_data = player_cfg.get("setup_data")
1544 if not isinstance(setup_data, dict):
1545 setup_data = {}
1546 moved = False
1547 for key in move_keys:
1548 if key not in values:
1549 continue
1550 value = values.pop(key)
1551 moved = True
1552 # a stored null is just dropped; only real values move across
1553 if value is not None and key not in setup_data:
1554 setup_data[key] = value
1555 for key in dead_keys:
1556 if key in values:
1557 del values[key]
1558 moved = True
1559 if moved:
1560 if setup_data:
1561 player_cfg["setup_data"] = setup_data
1562 LOGGER.info(
1563 "Migrated credential/pairing values into setup_data for player %s", player_id
1564 )
1565 changed = True
1566 return changed
1567
1568
1569def _migrate_player_icons(data: dict[str, Any]) -> bool:
1570 """Rewrite legacy stored player icon values to canonical shared-icon-set ids."""
1571 all_player_configs = data.get(CONF_PLAYERS, {})
1572 if not isinstance(all_player_configs, dict):
1573 return False
1574 changed = False
1575 for player_id, player_cfg in all_player_configs.items():
1576 if not isinstance(player_cfg, dict):
1577 continue
1578 values = player_cfg.get("values")
1579 if not isinstance(values, dict):
1580 continue
1581 icon = values.get(CONF_ICON)
1582 if not isinstance(icon, str) or icon in _CANONICAL_ICON_IDS:
1583 continue
1584 if (replacement := _LEGACY_ICON_MAP.get(icon)) is not None:
1585 values[CONF_ICON] = replacement
1586 LOGGER.info("Migrated icon %s to %s for player %s", icon, replacement, player_id)
1587 changed = True
1588 elif icon.startswith("mdi-"):
1589 # no close equivalent in the shared icon set: drop the stored value
1590 # so the player-type default applies
1591 del values[CONF_ICON]
1592 LOGGER.info("Dropped legacy icon %s for player %s", icon, player_id)
1593 changed = True
1594 # any other unknown value is left in place: clients render the fallback icon
1595 # for unknown ids and the value may become a valid id in a future icon set
1596 return changed
1597