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