/
/
/
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 LABEL mirror (UI_ONLY, never persisted back to values), so moving
334# it to setup_data is safe and required (it is read via get_provider_setup_value).
335# - hass "url"/"token"/"verify_ssl": on a Home Assistant add-on these come from fixed
336# (hidden) config entries whose values equal what a stored copy would hold, so moving a
337# stored copy is a harmless no-op there while restoring normal installs.
338# - plex_connect's "plex_provider_id"/"mass_player_id" are not secrets, but its setup flow
339# collects them (the options are only known from live server state), so they live in
340# setup_data like any other flow-collected value.
341# - spotify is deliberately absent: its own _migrate_legacy_token still reads the legacy
342# "refresh_token" and "client_id" from `values`, so this migration must not move them.
343# TODO: remove after 2.13 release
344PROVIDER_SETUP_FLOW_KEYS: dict[str, tuple[str, ...]] = {
345 "alexa": ("url", "username", "password", "api_url", "api_username", "api_password"),
346 "amplipi": ("host",),
347 "apple_music": ("music_app_token", "music_user_token", "music_user_manual_token"),
348 "airplay_receiver": ("mass_player_id", "airplay_name"),
349 "ard_audiothek": ("email", "password", "token", "user_id", "token_expiry", "display_name"),
350 "ariacast_receiver": ("mass_player_id",),
351 "audible": ("auth_file", "locale"),
352 "audiobookshelf": ("url", "username", "password", "token", "api_token", "verify_ssl"),
353 "bandcamp": ("identity",),
354 "bbc_sounds": ("username", "password"),
355 "bose_soundtouch": ("app_key",),
356 "deezer": ("arl_token",),
357 "digitally_incorporated": ("listen_key",),
358 "emby": ("ip_address", "username", "password"),
359 "filesystem_google_drive": (
360 "content_type",
361 "client_id",
362 "client_secret",
363 "folder_id",
364 "refresh_token",
365 ),
366 "filesystem_local": ("content_type", "path"),
367 "filesystem_nfs": ("content_type", "host", "export_path", "subfolder", "nfs_version"),
368 "filesystem_onedrive": (
369 "content_type",
370 "client_id",
371 "client_secret",
372 "folder_id",
373 "refresh_token",
374 ),
375 "filesystem_smb": (
376 "content_type",
377 "host",
378 "share",
379 "username",
380 "password",
381 "subfolder",
382 "smb_version",
383 ),
384 "gpodder": ("url", "username", "password", "device_id", "token", "url_nc", "verify_ssl"),
385 "hass": ("url", "token", "verify_ssl"),
386 "hue_entertainment": ("bridge_host", "hue_username", "hue_clientkey", "bridge_id"),
387 "ibroadcast": ("username", "password"),
388 "jellyfin": ("url", "username", "password", "verify_ssl"),
389 "kion_music": ("token",),
390 "lastfm_scrobble": ("_provider", "_api_session_key", "_username", "_api_key", "_api_secret"),
391 "listenbrainz_scrobble": ("_user_token", "api_base_url"),
392 "musicme": ("username", "password"),
393 "neteasecloudmusic": ("api_base_url", "cookie", "uid"),
394 "nicovideo": ("mail", "password", "user_session"),
395 "nugs": ("username", "password"),
396 "opensubsonic": ("username", "password", "baseURL", "port", "path"),
397 "pandora": ("username", "password"),
398 "plex": (
399 "token",
400 "local_server_ip",
401 "local_server_port",
402 "local_server_ssl",
403 "local_server_verify_cert",
404 "library_id",
405 "library_type",
406 ),
407 "plex_connect": ("plex_provider_id", "mass_player_id"),
408 "pocketcasts": ("username", "password"),
409 "podcast_index": ("api_key", "api_secret"),
410 "podcastfeed": ("feed_url",),
411 "qobuz": ("username", "password"),
412 "qqmusic": ("uin", "musicid", "musickey", "login_type", "credential_json"),
413 "siriusxm": ("sxm_email_address", "sxm_password", "sxm_region"),
414 "soundcloud": ("client_id", "authorization"),
415 "spotify_connect": ("mass_player_id", "publish_name"),
416 "teddycloud": ("url",),
417 "tidal": ("auth_token", "refresh_token", "expiry_time", "user_id"),
418 "tunein": ("username",),
419 "vban_receiver": (
420 "bind_ip",
421 "bind_port",
422 "sender_host",
423 "vban_stream_name",
424 "audio_format",
425 "sample_rate",
426 "audio_channels",
427 ),
428 "webdav": ("content_type", "url", "username", "password", "verify_ssl"),
429 "yandex_music": ("token", "x_token", "refresh_token"),
430 "yandex_smarthome": (
431 "connection_type",
432 "cloud_instance_id",
433 "cloud_instance_password",
434 "cloud_connection_token",
435 "skill_id",
436 "skill_token",
437 "direct_access_token",
438 "direct_client_secret",
439 ),
440 "yandex_station": (
441 "ym_instance",
442 "music_token",
443 "x_token",
444 "refresh_token",
445 "remember_session",
446 ),
447 "yandex_ynison": ("ym_instance", "token", "x_token", "mass_player_id", "publish_name"),
448 "yousee": ("username", "password"),
449 "ytmusic": ("username", "cookie", "po_token_server_url"),
450 "zvuk_music": ("token",),
451}
452
453
454async def migrate(data: dict[str, Any]) -> bool: # noqa: PLR0915
455 """Migrate the persistent settings data in-place; return True if anything changed."""
456 changed = False
457
458 # The background tasks controller originally persisted runtime state directly under
459 # core/tasks, which could create a CoreConfig object without the required domain field.
460 # Repair that single known corruption case on load.
461 # TODO: remove after 2.9 release
462 tasks_core_config = data.get(CONF_CORE, {}).get("tasks")
463 if isinstance(tasks_core_config, dict) and "domain" not in tasks_core_config:
464 tasks_core_config["domain"] = "tasks"
465 LOGGER.warning("Repaired corrupt tasks core configuration")
466 changed = True
467
468 # Drop orphaned provider config stubs: a load failure could write last_error back to a
469 # provider key whose config had already been removed (e.g. removing an unsupported provider
470 # while a load/retry was still in flight), leaving an entry with only a last_error and no
471 # 'domain'. Such stubs are dead data and crash get_provider_configs on startup.
472 # TODO: remove after 2.11 release
473 all_provider_configs = data.get(CONF_PROVIDERS, {})
474 if isinstance(all_provider_configs, dict):
475 orphaned = [
476 instance_id
477 for instance_id, cfg in all_provider_configs.items()
478 if isinstance(cfg, dict) and "domain" not in cfg
479 ]
480 for instance_id in orphaned:
481 del all_provider_configs[instance_id]
482 LOGGER.warning("Removed orphaned provider config stub %s", instance_id)
483 changed = True
484
485 # Collapse legacy multi-instance Fully Kiosk provider configs into a single
486 # provider instance with a list of devices (matching the MPD provider pattern).
487 # TODO: remove after 2.10 release
488 if _migrate_fully_kiosk_multi_instance(data):
489 changed = True
490 # Migrate default_enqueue_option_radio -> default_enqueue_option_live_sources.
491 # The same setting now covers both radio stations and plugin AudioSources
492 # (Spotify Connect, AirPlay receiver, etc.); preserves the user's customised
493 # value if they set one.
494 # TODO: remove after 2.10 release
495 player_queues_cfg = data.get(CONF_CORE, {}).get("player_queues")
496 if isinstance(player_queues_cfg, dict):
497 values = player_queues_cfg.get("values")
498 if isinstance(values, dict) and "default_enqueue_option_radio" in values:
499 radio_value = values.pop("default_enqueue_option_radio")
500 values.setdefault("default_enqueue_option_live_sources", radio_value)
501 LOGGER.info(
502 "Migrated default_enqueue_option_radio -> default_enqueue_option_live_sources"
503 )
504 changed = True
505
506 # Migrate sync_group members_filter (exclusion) -> allowed_members (inclusion).
507 # Inversion freezes the universe at migration time; speakers added after this
508 # point must be added by the user explicitly, which matches the new design's
509 # "limit to these" intent.
510 # TODO: remove after 2.10 release
511 all_player_configs = data.get(CONF_PLAYERS, {})
512 if isinstance(all_player_configs, dict):
513 group_provider_domains = {"sync_group", "universal_group"}
514 universe = {
515 pid
516 for pid, cfg in all_player_configs.items()
517 if isinstance(cfg, dict) and cfg.get("provider") not in group_provider_domains
518 }
519 for player_id, player_cfg in all_player_configs.items():
520 if not isinstance(player_cfg, dict):
521 continue
522 if player_cfg.get("provider") != "sync_group":
523 continue
524 values = player_cfg.setdefault("values", {})
525 old_exclude = values.get("members_filter") or []
526 if not old_exclude or values.get("allowed_members") is not None:
527 continue
528 values["allowed_members"] = sorted(universe - set(old_exclude))
529 values["members_filter"] = []
530 LOGGER.info(
531 "Migrated sync_group %s: members_filter (exclusion) -> allowed_members (inclusion)",
532 player_id,
533 )
534 changed = True
535
536 # Clear self-referential protocol links: a player whose protocol_parent_id or
537 # linked_protocol_ids pointed at its own id was hidden as its own protocol child.
538 # TODO: remove after 2.10 release
539 if _migrate_self_referential_protocol_links(data):
540 changed = True
541
542 # Drop the persisted schedule for the metadata maintenance tasks that were hardcoded
543 # to run at 04:00 local. They are now registered under new ("_v2") task ids with a
544 # randomized full-day schedule (to avoid spiking the shared MusicBrainz mirror), so the
545 # old persisted state is orphaned and can be removed.
546 # TODO: remove after 2.9 release
547 if _migrate_metadata_maintenance_schedule(data):
548 changed = True
549
550 # TODO: remove after 2.10 release
551 if _migrate_volume_normalization_target(data):
552 changed = True
553
554 # Move queue-scoped settings (crossfade duration, volume normalization) from the per-player
555 # config to the new per-queue config (queue_id == player_id, so the id maps 1:1).
556 # TODO: remove after 2.11 release
557 if _migrate_player_queue_settings(data):
558 changed = True
559
560 # Adopt the global-with-override model for queue settings: convert the former boolean toggles to
561 # their select strings, and promote the now global-only settings (crossfade duration, smart
562 # shuffle recency windows) to the Player Queues core config. Runs after the player->queue move
563 # above so any values it just landed are picked up here.
564 # TODO: remove after 2.10 release
565 if _migrate_global_queue_settings(data):
566 changed = True
567
568 # Promote local_audio attribution stubs to regular players and fold the settings of
569 # their (now obsolete) universal player wrappers back onto them.
570 # TODO: remove after 2.11 release
571 if _migrate_local_audio_attribution_stubs(data):
572 changed = True
573
574 # Drop ghost players that were discovered from this server's own AirPlay Receiver
575 # (shairport-sync) advertisements before discovery learned to filter them out.
576 # TODO: remove after 2.10 release
577 if _migrate_airplay_receiver_ghost_players(data):
578 changed = True
579
580 # Give Apple TVs paired before native power control existed the current
581 # default ("native") instead of the stale "none" that hid their power button.
582 # TODO: remove after 2.11 release
583 if _migrate_airplay_apple_power_control(data):
584 changed = True
585
586 # Drop the stored value of the removed output limiter player setting; clipping protection
587 # is now an explicit Safety Limiter DSP filter instead of a fixed output stage.
588 # TODO: remove after 2.10 release
589 if _migrate_output_limiter(data):
590 changed = True
591
592 # Move player-owned credential/pairing keys (AirPlay creds, Fully Kiosk / MPD password)
593 # from the player config `values` into the player's encrypted `setup_data`, so those reads
594 # switch to Player.get_setup_value now that pairing/credentials are owned by the setup flows.
595 # TODO: remove after 2.11 release
596 if _migrate_player_setup_data(data):
597 changed = True
598
599 # Drop the per-player Bose SoundTouch preset mappings; presets are now mapped once on
600 # the provider config and shared by all its speakers.
601 # TODO: remove after 2.11 release
602 if _migrate_bose_soundtouch_presets(data):
603 changed = True
604
605 # Rewrite stored player icons from legacy values (mdi-* names and pre-1.0 picker
606 # names) to canonical ids of the shared icon set; unmappable mdi-* picks drop
607 # back to the player-type default.
608 # TODO: remove after 2.12 release
609 if _migrate_player_icons(data):
610 changed = True
611
612 # Drop the stored HTTP profile of Bluesound players; the setting is no longer offered
613 # because BluOS only plays back correctly on the forced content length profile.
614 # TODO: remove after 2.12 release
615 if _migrate_bluesound_http_profile(data):
616 changed = True
617
618 return changed
619
620
621def migrate_provider_setup_data(data: dict[str, Any], encrypt: Callable[[str], str]) -> bool:
622 """
623 Move each provider's setup-flow-owned keys from `values` to `setup_data` in-place.
624
625 Runs after encryption is initialized (unlike migrate()), so string values are
626 encrypted at rest with the given callback - matching how the setup flows persist
627 collected values. Returns True if anything changed.
628
629 :param data: The persistent settings data to migrate in-place.
630 :param encrypt: Callback that encrypts a string value (idempotent for already
631 encrypted values), used to encrypt migrated string values at rest.
632 """
633 all_provider_configs = data.get(CONF_PROVIDERS, {})
634 if not isinstance(all_provider_configs, dict):
635 return False
636 changed = False
637 for provider_cfg in all_provider_configs.values():
638 if not isinstance(provider_cfg, dict):
639 continue
640 owned_keys = PROVIDER_SETUP_FLOW_KEYS.get(provider_cfg.get("domain", ""))
641 if not owned_keys:
642 continue
643 values = provider_cfg.get("values")
644 if not isinstance(values, dict):
645 continue
646 movable_keys = [key for key in owned_keys if key in values]
647 setup_data = provider_cfg.get("setup_data")
648 needs_airplay_default = provider_cfg.get("domain") == "airplay_receiver" and (
649 not isinstance(setup_data, dict) or "airplay_name" not in setup_data
650 )
651 if not movable_keys and not needs_airplay_default:
652 continue
653 if not isinstance(setup_data, dict):
654 setup_data = {}
655 provider_cfg["setup_data"] = setup_data
656 for key in movable_keys:
657 # a value already collected into setup_data wins; only drop the stale copy
658 if key not in setup_data:
659 value = values[key]
660 setup_data[key] = encrypt(value) if isinstance(value, str) else value
661 del values[key]
662 if needs_airplay_default and "airplay_name" not in setup_data:
663 setup_data["airplay_name"] = encrypt("Music Assistant")
664 changed = True
665 if changed:
666 LOGGER.info("Migrated provider setup values into setup_data")
667 return changed
668
669
670# TODO: remove after 2.10 release
671def migrate_nfs_subfolder_into_export_path(
672 data: dict[str, Any],
673 encrypt: Callable[[str], str],
674 decrypt: Callable[[str], str],
675) -> bool:
676 """
677 Fold a stored NFS `subfolder` into its `export_path`, once.
678
679 The provider mounts the export as configured and scans the subfolder inside that mount, so
680 folding the two keys into one keeps an existing instance mounting what it already mounts.
681 Runs after encryption is initialized, like migrate_provider_setup_data, because both keys
682 live encrypted in `setup_data`.
683
684 Guarded by CONF_NFS_SUBFOLDER_MIGRATED so it cannot run twice: a subfolder stored
685 afterwards means "scan this path inside the mount" and must never be folded. Returns True
686 when the settings were modified, including the first run's marker.
687
688 :param data: The persistent settings data to migrate in-place.
689 :param encrypt: Callback that encrypts a string value at rest.
690 :param decrypt: Callback that decrypts a stored string value (a no-op for plain values).
691 """
692 if data.get(CONF_NFS_SUBFOLDER_MIGRATED):
693 return False
694 all_provider_configs = data.get(CONF_PROVIDERS, {})
695 if not isinstance(all_provider_configs, dict):
696 return False
697 changed = False
698 for instance_id, provider_cfg in all_provider_configs.items():
699 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != "filesystem_nfs":
700 continue
701 setup_data = provider_cfg.get("setup_data")
702 if not isinstance(setup_data, dict):
703 continue
704 stored_subfolder = setup_data.get("subfolder")
705 stored_export_path = setup_data.get("export_path")
706 if not isinstance(stored_subfolder, str) or not isinstance(stored_export_path, str):
707 continue
708 try:
709 subfolder = decrypt(stored_subfolder).strip()
710 export_path = decrypt(stored_export_path)
711 except InvalidDataError:
712 # one unreadable instance must not fail config setup for the whole server; it
713 # still surfaces the problem at its own setup. Name it without its values.
714 LOGGER.warning(
715 "Could not read the stored NFS paths of %s; skipping its subfolder migration",
716 instance_id,
717 )
718 continue
719 if not subfolder or not export_path:
720 # an empty export path is broken either way and must not become a relative one
721 continue
722 # must come out as <export_path>/<subfolder> so the mount source is unchanged
723 setup_data["export_path"] = encrypt(str(PurePosixPath(export_path) / subfolder.lstrip("/")))
724 del setup_data["subfolder"]
725 changed = True
726 if changed:
727 LOGGER.info("Migrated NFS provider subfolder into the export path")
728 # claim the marker even when nothing was folded, so a subfolder stored later is safe
729 data[CONF_NFS_SUBFOLDER_MIGRATED] = True
730 return True
731
732
733# TODO: remove after 2.10 release
734def migrate_hass_engine_selection(data: dict[str, Any], encrypt: Callable[[str], str]) -> bool:
735 """
736 Hand the removed Home Assistant TTS/AI entity choice over to the providers consuming it.
737
738 The Home Assistant plugin exposes every TTS/AI entity as a selectable engine now and each
739 consuming provider picks one itself, so the single choice that used to live on the plugin
740 is copied to the installed consumers that have no choice of their own yet. Providers
741 installed later pick an engine themselves at load. Returns True if anything changed.
742
743 Runs after encryption is initialized (like migrate_provider_setup_data), since the ai_radio
744 selection belongs in its encrypted `setup_data`.
745
746 :param data: The persistent settings data to migrate in-place.
747 :param encrypt: Callback that encrypts a string value, used for the values that land in
748 `setup_data`.
749 """
750 all_provider_configs = data.get(CONF_PROVIDERS, {})
751 if not isinstance(all_provider_configs, dict):
752 return False
753 hass_configs = {
754 instance_id: provider_cfg
755 for instance_id, provider_cfg in all_provider_configs.items()
756 if isinstance(provider_cfg, dict) and provider_cfg.get("domain") == "hass"
757 }
758 if len(hass_configs) > 1:
759 # there is no correct winner between several choices, so let the user pick per provider
760 LOGGER.warning(
761 "Skipped migrating the Home Assistant TTS/AI entity selection: "
762 "%s Home Assistant configurations found, select the engines manually",
763 len(hass_configs),
764 )
765 return False
766 changed = False
767 for instance_id, hass_cfg in hass_configs.items():
768 values = hass_cfg.get("values")
769 if not isinstance(values, dict):
770 continue
771 if not any(key in values for key in (LEGACY_CONF_TTS_ENTITY, LEGACY_CONF_AI_TASK_ENTITY)):
772 continue
773 tts_entity = values.pop(LEGACY_CONF_TTS_ENTITY, None)
774 ai_task_entity = values.pop(LEGACY_CONF_AI_TASK_ENTITY, None)
775 changed = True
776 if isinstance(ai_task_entity, str) and ai_task_entity:
777 ai_engine = f"{instance_id}/{ai_task_entity}"
778 _set_engine_selection(
779 all_provider_configs, "music_quiz", "values", CONF_AI_ENGINE, ai_engine
780 )
781 _set_engine_selection(
782 all_provider_configs, "smart_playlist", "values", CONF_AI_ENGINE, ai_engine
783 )
784 _set_engine_selection(
785 all_provider_configs, "ai_radio", "setup_data", CONF_AI_ENGINE, encrypt(ai_engine)
786 )
787 if isinstance(tts_entity, str) and tts_entity:
788 _set_engine_selection(
789 all_provider_configs,
790 "ai_radio",
791 "setup_data",
792 CONF_TTS_ENGINE,
793 encrypt(f"{instance_id}/{tts_entity}"),
794 )
795 LOGGER.info("Migrated the Home Assistant TTS/AI entity selection to the plugin engines")
796 return changed
797
798
799def _set_engine_selection(
800 all_provider_configs: dict[str, Any], domain: str, section: str, key: str, value: str
801) -> None:
802 """Store an engine selection on each config of the given domain that has none of its own."""
803 for provider_cfg in all_provider_configs.values():
804 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != domain:
805 continue
806 section_values = provider_cfg.get(section)
807 if not isinstance(section_values, dict):
808 section_values = {}
809 provider_cfg[section] = section_values
810 section_values.setdefault(key, value)
811
812
813def _migrate_player_queue_settings(data: dict[str, Any]) -> bool:
814 """Move queue-scoped settings from the per-player config to the per-queue config."""
815 moved_keys = (
816 CONF_CROSSFADE_DURATION,
817 CONF_VOLUME_NORMALIZATION,
818 )
819 all_player_configs = data.get(CONF_PLAYERS, {})
820 if not isinstance(all_player_configs, dict):
821 return False
822 changed = False
823 for player_id, player_cfg in all_player_configs.items():
824 if not isinstance(player_cfg, dict):
825 continue
826 player_values = player_cfg.get("values")
827 if not isinstance(player_values, dict):
828 continue
829 to_move = {key: player_values[key] for key in moved_keys if key in player_values}
830 # the legacy smart_fades_mode encoded both on/off and standard-vs-smart; the on/off is
831 # now a runtime queue toggle, and standard/smart carries over to the crossfade_mode
832 # select ("disabled" just means crossfade is off -> nothing to carry). Consume the key.
833 legacy_mode = player_values.pop(CONF_SMART_FADES_MODE, None)
834 migrated_mode = (
835 legacy_mode
836 if legacy_mode in (CrossfadeMode.STANDARD_CROSSFADE, CrossfadeMode.SMART_CROSSFADE)
837 else None
838 )
839 if not to_move and legacy_mode is None:
840 continue
841 if to_move or migrated_mode is not None:
842 queue_cfg = data.setdefault(CONF_PLAYER_QUEUES, {}).setdefault(
843 player_id, {"queue_id": player_id}
844 )
845 queue_values = queue_cfg.setdefault("values", {})
846 for key, value in to_move.items():
847 # don't clobber an existing queue value if one was already stored
848 queue_values.setdefault(key, value)
849 del player_values[key]
850 if migrated_mode is not None:
851 queue_values.setdefault(CONF_CROSSFADE_MODE, migrated_mode)
852 LOGGER.info("Migrated queue settings for %s", player_id)
853 changed = True
854 return changed
855
856
857def _migrate_global_queue_settings(data: dict[str, Any]) -> bool:
858 """
859 Adopt the global-with-override model for the per-queue settings.
860
861 The two former boolean toggles become their select strings (so a queue can also follow the
862 global value), and the settings that are now global-only are promoted to the Player Queues core
863 config. Queues that stored nothing keep nothing and therefore fall back to the new "global"
864 default. Idempotent: a second run finds only select strings and no per-queue global-only values.
865 """
866 all_queue_configs = data.get(CONF_PLAYER_QUEUES, {})
867 if not isinstance(all_queue_configs, dict):
868 return False
869 changed = False
870 # 1. convert the former booleans (True/False) to their select strings (enabled/disabled)
871 bool_to_select = {True: CONF_VALUE_ENABLED, False: CONF_VALUE_DISABLED}
872 for queue_cfg in all_queue_configs.values():
873 if not isinstance(queue_cfg, dict):
874 continue
875 values = queue_cfg.get("values")
876 if not isinstance(values, dict):
877 continue
878 for key in (CONF_VOLUME_NORMALIZATION, CONF_SMART_SHUFFLE_ENABLED):
879 if isinstance(values.get(key), bool):
880 values[key] = bool_to_select[values[key]]
881 changed = True
882 # 2. promote the now global-only settings to the Player Queues core config
883 global_only_keys = (
884 CONF_CROSSFADE_DURATION,
885 CONF_SMART_SHUFFLE_SONG_RECENCY,
886 CONF_SMART_SHUFFLE_ARTIST_RECENCY,
887 CONF_SMART_SHUFFLE_DUPLICATE_GAP,
888 )
889 for key in global_only_keys:
890 if _promote_queue_setting_to_global(data, key):
891 changed = True
892 return changed
893
894
895def _promote_queue_setting_to_global(data: dict[str, Any], key: str) -> bool:
896 """
897 Promote a (now global-only) per-queue setting to global config and drop the per-queue copies.
898
899 A single value shared by every queue that set it is promoted so the user's preference is kept;
900 mixed values fall back to the new default. Mirrors _migrate_volume_normalization_target.
901 """
902 all_queue_configs = data.get(CONF_PLAYER_QUEUES, {})
903 if not isinstance(all_queue_configs, dict):
904 return False
905 stored_values: set[Any] = set()
906 for queue_cfg in all_queue_configs.values():
907 if not isinstance(queue_cfg, dict):
908 continue
909 values = queue_cfg.get("values")
910 if isinstance(values, dict) and key in values:
911 stored_values.add(values[key])
912 if not stored_values:
913 return False
914 # promote only a single consistent value (mixed values fall back to the new default), and never
915 # clobber a value the user already set globally; only touch the core config when promoting
916 existing_core = data.get(CONF_CORE, {}).get(CONF_PLAYER_QUEUES, {})
917 existing_values = existing_core.get("values", {}) if isinstance(existing_core, dict) else {}
918 if len(stored_values) == 1 and key not in existing_values:
919 core_values = (
920 data.setdefault(CONF_CORE, {})
921 .setdefault(CONF_PLAYER_QUEUES, {"domain": CONF_PLAYER_QUEUES})
922 .setdefault("values", {})
923 )
924 core_values[key] = next(iter(stored_values))
925 LOGGER.info("Promoted per-queue %s to the global Player Queues config", key)
926 # the setting is global-only now, so drop every per-queue copy
927 for queue_cfg in all_queue_configs.values():
928 if not isinstance(queue_cfg, dict):
929 continue
930 values = queue_cfg.get("values")
931 if isinstance(values, dict):
932 values.pop(key, None)
933 return True
934
935
936def _migrate_volume_normalization_target(data: dict[str, Any]) -> bool:
937 """
938 Migrate volume_normalization_target from per-player to the global streams setting.
939
940 Collects all explicitly stored per-player values; if they all agree on a single value,
941 that value is promoted to the streams core config so the user's preference is preserved.
942 """
943 all_player_configs = data.get(CONF_PLAYERS, {})
944 if not isinstance(all_player_configs, dict):
945 return False
946 per_player_values: set[int] = set()
947 for player_cfg in all_player_configs.values():
948 if not isinstance(player_cfg, dict):
949 continue
950 values = player_cfg.get("values")
951 if not isinstance(values, dict):
952 continue
953 if CONF_VOLUME_NORMALIZATION_TARGET in values:
954 per_player_values.add(int(values[CONF_VOLUME_NORMALIZATION_TARGET]))
955
956 if not per_player_values:
957 return False
958
959 streams_core = data.setdefault(CONF_CORE, {}).setdefault("streams", {})
960 streams_values = streams_core.setdefault("values", {})
961 # only promote when not already globally configured
962 if CONF_VOLUME_NORMALIZATION_TARGET not in streams_values:
963 # single consistent value across all players → promote it; mixed → use new default
964 promoted = per_player_values.pop() if len(per_player_values) == 1 else None
965 if promoted is not None:
966 streams_values[CONF_VOLUME_NORMALIZATION_TARGET] = promoted
967 LOGGER.info(
968 "Promoted volume_normalization_target %s LUFS to global streams setting",
969 promoted,
970 )
971
972 for player_id, player_cfg in all_player_configs.items():
973 if not isinstance(player_cfg, dict):
974 continue
975 values = player_cfg.get("values")
976 if not isinstance(values, dict):
977 continue
978 if CONF_VOLUME_NORMALIZATION_TARGET in values:
979 del values[CONF_VOLUME_NORMALIZATION_TARGET]
980 LOGGER.info(
981 "Removed per-player volume_normalization_target for player %s",
982 player_id,
983 )
984 return True
985
986
987def _migrate_local_audio_attribution_stubs(data: dict[str, Any]) -> bool:
988 """
989 Promote local_audio attribution-stub players to regular players.
990
991 The local_audio provider used to register a hidden PROTOCOL "attribution stub"
992 per audio device, which got wrapped (together with the Sendspin bridge player)
993 in an auto-created universal player. The stub is now a regular, visible player
994 that parents the Sendspin bridge directly, making the universal player wrapper
995 obsolete: its user settings move onto the stub's config (same bare device-uuid
996 player_id) and the wrapper is removed.
997 """
998 all_player_configs = data.get(CONF_PLAYERS, {})
999 if not isinstance(all_player_configs, dict):
1000 return False
1001 changed = False
1002 for player_id, player_cfg in list(all_player_configs.items()):
1003 if not isinstance(player_cfg, dict):
1004 continue
1005 if player_cfg.get("provider") != "local_audio":
1006 continue
1007 if player_cfg.get("player_type") != "protocol":
1008 continue
1009 player_cfg["player_type"] = "player"
1010 values = player_cfg.setdefault("values", {})
1011 values.pop(CONF_PROTOCOL_PARENT_ID, None)
1012 changed = True
1013
1014 # the universal player wrapper was keyed on the stub's player_id
1015 # (the stub had no device identifiers to derive a device key from)
1016 universal_id = f"up{player_id.replace('-', '').lower()}"
1017 universal_cfg = all_player_configs.get(universal_id)
1018 if isinstance(universal_cfg, dict) and universal_cfg.get("provider") == "universal_player":
1019 del all_player_configs[universal_id]
1020 _absorb_universal_player_config(
1021 data, player_id, player_cfg, universal_id, universal_cfg
1022 )
1023 LOGGER.info(
1024 "Migrated universal player %s settings to local_audio player %s",
1025 universal_id,
1026 player_id,
1027 )
1028 LOGGER.info("Promoted local_audio player %s to a regular player", player_id)
1029 return changed
1030
1031
1032def _absorb_universal_player_config(
1033 data: dict[str, Any],
1034 player_id: str,
1035 player_cfg: dict[str, Any],
1036 universal_id: str,
1037 universal_cfg: dict[str, Any],
1038) -> None:
1039 """
1040 Fold the user settings of an obsolete universal player onto its replacement.
1041
1042 The universal player was the visible device the user configured, so its
1043 settings win over anything stored on the (hidden) stub. Everything keyed on
1044 the old universal player_id (protocol parent links, queue settings, DSP
1045 config, group memberships) is re-pointed to the new player_id.
1046 """
1047 player_cfg["enabled"] = universal_cfg.get("enabled", True)
1048 # only carry an actual user rename, not the auto-generated default name
1049 if universal_cfg.get("name") and universal_cfg.get("name") != universal_cfg.get("default_name"):
1050 player_cfg["name"] = universal_cfg["name"]
1051
1052 values = player_cfg.setdefault("values", {})
1053 universal_values = universal_cfg.get("values")
1054 universal_values = universal_values if isinstance(universal_values, dict) else {}
1055 # bookkeeping only relevant to the universal player wrapper itself
1056 internal_keys = (
1057 CONF_LINKED_PROTOCOL_IDS,
1058 CONF_PROTOCOL_PARENT_ID,
1059 "device_identifiers",
1060 "device_info",
1061 )
1062 for key, value in universal_values.items():
1063 if key in internal_keys:
1064 continue
1065 values[key] = value
1066
1067 # carry the linked protocols (minus the stub itself, it is the parent now)
1068 # and re-point their cached parent so they restore fast on the next start
1069 linked_ids = [
1070 pid for pid in (universal_values.get(CONF_LINKED_PROTOCOL_IDS) or []) if pid != player_id
1071 ]
1072 if linked_ids:
1073 existing_ids = list(values.get(CONF_LINKED_PROTOCOL_IDS) or [])
1074 values[CONF_LINKED_PROTOCOL_IDS] = existing_ids + [
1075 pid for pid in linked_ids if pid not in existing_ids
1076 ]
1077 all_player_configs = data.get(CONF_PLAYERS, {})
1078 for protocol_id in linked_ids:
1079 protocol_cfg = all_player_configs.get(protocol_id)
1080 if not isinstance(protocol_cfg, dict):
1081 continue
1082 protocol_values = protocol_cfg.setdefault("values", {})
1083 if protocol_values.get(CONF_PROTOCOL_PARENT_ID) == universal_id:
1084 protocol_values[CONF_PROTOCOL_PARENT_ID] = player_id
1085
1086 # move per-queue settings and DSP configuration to the new player_id
1087 for tree_key in (CONF_PLAYER_QUEUES, CONF_PLAYER_DSP):
1088 tree = data.get(tree_key)
1089 if isinstance(tree, dict) and universal_id in tree and player_id not in tree:
1090 tree[player_id] = tree.pop(universal_id)
1091 if tree_key == CONF_PLAYER_QUEUES and isinstance(tree[player_id], dict):
1092 tree[player_id]["queue_id"] = player_id
1093
1094 # re-point group memberships that referenced the universal player
1095 for other_cfg in all_player_configs.values():
1096 if not isinstance(other_cfg, dict):
1097 continue
1098 other_values = other_cfg.get("values")
1099 if not isinstance(other_values, dict):
1100 continue
1101 for key in ("group_members", "allowed_members"):
1102 members = other_values.get(key)
1103 if isinstance(members, list) and universal_id in members:
1104 other_values[key] = [player_id if pid == universal_id else pid for pid in members]
1105
1106
1107def _migrate_self_referential_protocol_links(data: dict[str, Any]) -> bool:
1108 """Clear protocol links that point a player at its own id."""
1109 all_player_configs = data.get(CONF_PLAYERS, {})
1110 if not isinstance(all_player_configs, dict):
1111 return False
1112 changed = False
1113 for player_id, player_cfg in all_player_configs.items():
1114 if not isinstance(player_cfg, dict):
1115 continue
1116 values = player_cfg.get("values")
1117 if not isinstance(values, dict):
1118 continue
1119 repaired = False
1120 if values.get(CONF_PROTOCOL_PARENT_ID) == player_id:
1121 values[CONF_PROTOCOL_PARENT_ID] = None
1122 repaired = True
1123 linked = values.get(CONF_LINKED_PROTOCOL_IDS)
1124 if isinstance(linked, list) and player_id in linked:
1125 values[CONF_LINKED_PROTOCOL_IDS] = [pid for pid in linked if pid != player_id]
1126 repaired = True
1127 if repaired:
1128 LOGGER.warning("Repaired self-referential protocol link for %s", player_id)
1129 changed = True
1130 return changed
1131
1132
1133def _migrate_metadata_maintenance_schedule(data: dict[str, Any]) -> bool:
1134 """Remove the orphaned persisted state for the pre-randomization metadata task ids."""
1135 core_config = data.get(CONF_CORE)
1136 if not isinstance(core_config, dict):
1137 return False
1138 tasks_config = core_config.get("tasks")
1139 if not isinstance(tasks_config, dict):
1140 return False
1141 task_states = tasks_config.get("scheduled_task_states")
1142 if not isinstance(task_states, dict):
1143 return False
1144 legacy_task_ids = (
1145 "metadata_missing_artist_metadata_scan",
1146 "metadata_playlist_metadata_scan",
1147 "metadata_thumb_cache_cleanup",
1148 )
1149 removed = [task_id for task_id in legacy_task_ids if task_id in task_states]
1150 for task_id in removed:
1151 del task_states[task_id]
1152 if removed:
1153 LOGGER.info("Removed orphaned metadata maintenance schedule state for %s", removed)
1154 return bool(removed)
1155
1156
1157def _migrate_fully_kiosk_multi_instance(data: dict[str, Any]) -> bool:
1158 """Collapse legacy multi-instance Fully Kiosk configs into a single provider instance."""
1159 providers = data.get(CONF_PROVIDERS, {})
1160 legacy_ids = [
1161 iid
1162 for iid, conf in providers.items()
1163 if isinstance(conf, dict) and conf.get("domain") == "fully_kiosk" and iid != "fully_kiosk"
1164 ]
1165 if not legacy_ids:
1166 return False
1167
1168 ip_entries: list[str] = []
1169 players = data.setdefault(CONF_PLAYERS, {})
1170 for iid in legacy_ids:
1171 old_values = providers[iid].get("values") or {}
1172 host = old_values.get("ip_address")
1173 if not host:
1174 del providers[iid]
1175 continue
1176 try:
1177 port = int(old_values.get("port") or 2323)
1178 except TypeError, ValueError:
1179 port = 2323
1180 entry = host if port == 2323 else f"{host}:{port}"
1181 if entry not in ip_entries:
1182 ip_entries.append(entry)
1183
1184 new_player_id = f"fully_kiosk_{host}_{port}"
1185 player_conf = players.setdefault(
1186 new_player_id,
1187 {
1188 "player_id": new_player_id,
1189 "provider": "fully_kiosk",
1190 "enabled": True,
1191 "values": {},
1192 },
1193 )
1194 player_values = player_conf.setdefault("values", {})
1195 for key in ("password", "use_ssl", "verify_ssl", "ssl_fingerprint"):
1196 if old_values.get(key) is not None and key not in player_values:
1197 player_values[key] = old_values[key]
1198
1199 del providers[iid]
1200
1201 if "fully_kiosk" in providers:
1202 existing_values = providers["fully_kiosk"].setdefault("values", {})
1203 existing_ips = list(existing_values.get("manual_discovery_ip_addresses") or [])
1204 for entry in ip_entries:
1205 if entry not in existing_ips:
1206 existing_ips.append(entry)
1207 existing_values["manual_discovery_ip_addresses"] = existing_ips
1208 else:
1209 providers["fully_kiosk"] = {
1210 "type": "player",
1211 "domain": "fully_kiosk",
1212 "instance_id": "fully_kiosk",
1213 "enabled": True,
1214 "values": {"manual_discovery_ip_addresses": ip_entries},
1215 }
1216
1217 LOGGER.warning(
1218 "Migrated %d legacy Fully Kiosk provider instance(s) into a single instance. "
1219 "Devices and their passwords have been preserved, but any Fully Kiosk player "
1220 "that was part of a universal group will need to be re-added to it. ",
1221 len(legacy_ids),
1222 )
1223 return True
1224
1225
1226def _migrate_airplay_receiver_ghost_players(data: dict[str, Any]) -> bool:
1227 """
1228 Remove ghost players left behind by this server's own AirPlay Receiver instances.
1229
1230 The AirPlay provider could discover the server's own AirPlay Receiver
1231 (shairport-sync) advertisements as regular AirPlay players. shairport-sync
1232 derives its device id from the receiver name plus a host interface MAC, which
1233 can change per boot (e.g. virtual interface MACs), so every restart could mint
1234 a new player id: the previous ids linger as permanently unavailable players and
1235 universal player wrappers. Discovery now filters these advertisements out; this
1236 migration drops the leftovers.
1237 """
1238 all_provider_configs = data.get(CONF_PROVIDERS, {})
1239 all_player_configs = data.get(CONF_PLAYERS, {})
1240 if not isinstance(all_provider_configs, dict) or not isinstance(all_player_configs, dict):
1241 return False
1242 # the advertised name of every enabled receiver instance
1243 # (key and default mirror the airplay_receiver provider's config entry).
1244 # Disabled instances are skipped, consistent with the discovery filter: they
1245 # run no daemon and cannot have produced the ghosts, so their name is too weak
1246 # a signal to delete a config on (it could be a legitimate same-named device).
1247 receiver_names: set[str] = set()
1248 for provider_cfg in all_provider_configs.values():
1249 if not isinstance(provider_cfg, dict) or provider_cfg.get("domain") != "airplay_receiver":
1250 continue
1251 if not provider_cfg.get("enabled", True):
1252 continue
1253 setup_data = provider_cfg.get("setup_data")
1254 if isinstance(setup_data, dict) and "airplay_name" in setup_data:
1255 # New setup-flow instances cannot have produced legacy ghosts. Their
1256 # encrypted receiver name is unavailable during this early migration.
1257 continue
1258 provider_values = provider_cfg.get("values")
1259 airplay_name = (
1260 provider_values.get("airplay_name") if isinstance(provider_values, dict) else None
1261 )
1262 receiver_names.add(str(airplay_name) if airplay_name else "Music Assistant")
1263 if not receiver_names:
1264 return False
1265 # the Sendspin bridge of such a ghost registered under "<name> (AirPlay)"
1266 bridge_names = {f"{name} (AirPlay)" for name in receiver_names}
1267
1268 # First identify the ghost protocol endpoints: the discovered AirPlay player and
1269 # its Sendspin bridge, each matched by its own advertised (receiver) name.
1270 endpoint_ghost_ids: set[str] = set()
1271 for player_id, player_cfg in all_player_configs.items():
1272 if not isinstance(player_cfg, dict):
1273 continue
1274 default_name = player_cfg.get("default_name")
1275 provider = player_cfg.get("provider")
1276 if (
1277 player_id.startswith("ap") and provider == "airplay" and default_name in receiver_names
1278 ) or (
1279 player_id.startswith("spb_") and provider == "sendspin" and default_name in bridge_names
1280 ):
1281 endpoint_ghost_ids.add(player_id)
1282
1283 # Then add the universal player wrappers that exclusively wrap those endpoints.
1284 # A wrapper is only removed when it links at least one confirmed ghost endpoint
1285 # and nothing else, so a real player that merely shares the receiver name (with
1286 # no or different linked protocols) is never deleted.
1287 ghost_ids = set(endpoint_ghost_ids)
1288 for player_id, player_cfg in all_player_configs.items():
1289 if not isinstance(player_cfg, dict):
1290 continue
1291 if not (
1292 player_id.startswith("up")
1293 and player_cfg.get("provider") == "universal_player"
1294 and player_cfg.get("default_name") in receiver_names | bridge_names
1295 ):
1296 continue
1297 values = player_cfg.get("values")
1298 linked = values.get(CONF_LINKED_PROTOCOL_IDS) if isinstance(values, dict) else None
1299 if isinstance(linked, list) and linked and all(pid in endpoint_ghost_ids for pid in linked):
1300 ghost_ids.add(player_id)
1301 if not ghost_ids:
1302 return False
1303
1304 for player_id in ghost_ids:
1305 del all_player_configs[player_id]
1306 # drop dead per-queue and DSP state along with the player config
1307 for tree_key in (CONF_PLAYER_QUEUES, CONF_PLAYER_DSP):
1308 tree = data.get(tree_key)
1309 if isinstance(tree, dict):
1310 tree.pop(player_id, None)
1311 # strip dangling references to the removed ghosts from group configurations
1312 for player_cfg in all_player_configs.values():
1313 if not isinstance(player_cfg, dict):
1314 continue
1315 values = player_cfg.get("values")
1316 if not isinstance(values, dict):
1317 continue
1318 for key in ("group_members", "allowed_members"):
1319 members = values.get(key)
1320 if isinstance(members, list) and any(pid in ghost_ids for pid in members):
1321 values[key] = [pid for pid in members if pid not in ghost_ids]
1322 LOGGER.info(
1323 "Removed %d ghost player config(s) left behind by this server's own "
1324 "AirPlay Receiver instances",
1325 len(ghost_ids),
1326 )
1327 return True
1328
1329
1330def _migrate_airplay_apple_power_control(data: dict[str, Any]) -> bool:
1331 """
1332 Enable native power control for Apple TVs paired before the feature existed.
1333
1334 Native on/off (Companion) power control was added to Apple TVs later, but
1335 players configured earlier kept the power_control default from that time
1336 ("none"), so the power button stayed hidden. Flip that stale default to
1337 "native" for paired Apple devices (those with Companion credentials, i.e.
1338 the ones that actually gained the feature); a device that turns out not to
1339 support power degrades back to "none" at runtime.
1340 """
1341 all_player_configs = data.get(CONF_PLAYERS, {})
1342 if not isinstance(all_player_configs, dict):
1343 return False
1344 changed = False
1345 for player_id, player_cfg in all_player_configs.items():
1346 if not isinstance(player_cfg, dict):
1347 continue
1348 if not str(player_cfg.get("provider", "")).startswith("airplay"):
1349 continue
1350 values = player_cfg.get("values")
1351 if not isinstance(values, dict) or not values.get("companion_credentials"):
1352 continue
1353 if values.get("power_control") != PLAYER_CONTROL_NONE:
1354 continue
1355 values["power_control"] = PLAYER_CONTROL_NATIVE
1356 LOGGER.info("Enabled native power control for paired Apple device %s", player_id)
1357 changed = True
1358 return changed
1359
1360
1361def _migrate_output_limiter(data: dict[str, Any]) -> bool:
1362 """Remove the stored values of the removed per-player output limiter setting."""
1363 all_player_configs = data.get(CONF_PLAYERS, {})
1364 if not isinstance(all_player_configs, dict):
1365 return False
1366 changed = False
1367 for player_cfg in all_player_configs.values():
1368 if not isinstance(player_cfg, dict):
1369 continue
1370 player_values = player_cfg.get("values")
1371 if isinstance(player_values, dict) and LEGACY_CONF_OUTPUT_LIMITER in player_values:
1372 del player_values[LEGACY_CONF_OUTPUT_LIMITER]
1373 changed = True
1374 if changed:
1375 LOGGER.info("Removed the obsolete output limiter setting from the player configuration(s)")
1376 return changed
1377
1378
1379# the only HTTP profile BluOS devices play back correctly on
1380FORCED_HTTP_PROFILE = "forced_content_length"
1381
1382
1383def _migrate_bluesound_http_profile(data: dict[str, Any]) -> bool:
1384 """
1385 Drop a stored HTTP profile that Bluesound players can no longer select.
1386
1387 BluOS keeps looping the audio on any profile other than the forced content length one,
1388 so the setting is no longer offered. A player left on another profile would stay broken
1389 with no way back, so that pick is removed.
1390 """
1391 all_player_configs = data.get(CONF_PLAYERS, {})
1392 if not isinstance(all_player_configs, dict):
1393 return False
1394 changed = False
1395 for player_cfg in all_player_configs.values():
1396 if not isinstance(player_cfg, dict):
1397 continue
1398 if not str(player_cfg.get("provider", "")).startswith("bluesound"):
1399 continue
1400 player_values = player_cfg.get("values")
1401 if not isinstance(player_values, dict):
1402 continue
1403 if player_values.get(CONF_HTTP_PROFILE, FORCED_HTTP_PROFILE) != FORCED_HTTP_PROFILE:
1404 del player_values[CONF_HTTP_PROFILE]
1405 changed = True
1406 if changed:
1407 LOGGER.info("Restored the required HTTP profile on the Bluesound player configuration(s)")
1408 return changed
1409
1410
1411def _migrate_bose_soundtouch_presets(data: dict[str, Any]) -> bool:
1412 """
1413 Remove the per-player Bose SoundTouch preset mappings.
1414
1415 The physical preset buttons are now mapped once on the provider config, so the same
1416 button plays the same content on every speaker. The old per-player values are dropped
1417 rather than promoted: several speakers can hold conflicting mappings and there is no
1418 correct winner, so the user maps the buttons once more on the provider.
1419 """
1420 all_player_configs = data.get(CONF_PLAYERS, {})
1421 if not isinstance(all_player_configs, dict):
1422 return False
1423 changed = False
1424 for player_id, player_cfg in all_player_configs.items():
1425 if not isinstance(player_cfg, dict):
1426 continue
1427 if str(player_cfg.get("provider", "")).split("--", 1)[0] != "bose_soundtouch":
1428 continue
1429 values = player_cfg.get("values")
1430 if not isinstance(values, dict):
1431 continue
1432 preset_keys = [key for key in values if key.startswith(LEGACY_BOSE_PRESET_KEY_PREFIX)]
1433 if not preset_keys:
1434 continue
1435 for key in preset_keys:
1436 del values[key]
1437 LOGGER.info(
1438 "Removed the per-player preset mappings for Bose SoundTouch player %s; "
1439 "map the preset buttons on the provider settings instead",
1440 player_id,
1441 )
1442 changed = True
1443 return changed
1444
1445
1446_PLAYER_SETUP_DATA_KEYS: dict[str, tuple[str, ...]] = {
1447 "airplay": (
1448 "raop_credentials",
1449 "airplay_credentials",
1450 "companion_credentials",
1451 "mrp_credentials",
1452 "native_mrp_credentials",
1453 ),
1454 "fully_kiosk": ("password",),
1455 "mpd": ("password",),
1456}
1457
1458
1459_PLAYER_DEAD_SETUP_KEYS: dict[str, tuple[str, ...]] = {
1460 "airplay": ("ap2password",),
1461}
1462
1463
1464def _migrate_player_setup_data(data: dict[str, Any]) -> bool:
1465 """
1466 Move player-owned credential/pairing keys from player `values` into `setup_data`.
1467
1468 Idempotent (only moves a key still present in `values` and absent from `setup_data`)
1469 and multi-instance safe (matches on the player provider domain). Values are moved
1470 as-is: they are already encrypted SECURE_STRINGs, which is exactly the at-rest form
1471 setup_data expects. Also drops keys that are dead now (never read at runtime).
1472 """
1473 all_player_configs = data.get(CONF_PLAYERS, {})
1474 if not isinstance(all_player_configs, dict):
1475 return False
1476 changed = False
1477 for player_id, player_cfg in all_player_configs.items():
1478 if not isinstance(player_cfg, dict):
1479 continue
1480 domain = str(player_cfg.get("provider", "")).split("--", 1)[0]
1481 move_keys = _PLAYER_SETUP_DATA_KEYS.get(domain, ())
1482 dead_keys = _PLAYER_DEAD_SETUP_KEYS.get(domain, ())
1483 if not move_keys and not dead_keys:
1484 continue
1485 values = player_cfg.get("values")
1486 if not isinstance(values, dict):
1487 continue
1488 setup_data = player_cfg.get("setup_data")
1489 if not isinstance(setup_data, dict):
1490 setup_data = {}
1491 moved = False
1492 for key in move_keys:
1493 if key not in values:
1494 continue
1495 value = values.pop(key)
1496 moved = True
1497 # a stored null is just dropped; only real values move across
1498 if value is not None and key not in setup_data:
1499 setup_data[key] = value
1500 for key in dead_keys:
1501 if key in values:
1502 del values[key]
1503 moved = True
1504 if moved:
1505 if setup_data:
1506 player_cfg["setup_data"] = setup_data
1507 LOGGER.info(
1508 "Migrated credential/pairing values into setup_data for player %s", player_id
1509 )
1510 changed = True
1511 return changed
1512
1513
1514def _migrate_player_icons(data: dict[str, Any]) -> bool:
1515 """Rewrite legacy stored player icon values to canonical shared-icon-set ids."""
1516 all_player_configs = data.get(CONF_PLAYERS, {})
1517 if not isinstance(all_player_configs, dict):
1518 return False
1519 changed = False
1520 for player_id, player_cfg in all_player_configs.items():
1521 if not isinstance(player_cfg, dict):
1522 continue
1523 values = player_cfg.get("values")
1524 if not isinstance(values, dict):
1525 continue
1526 icon = values.get(CONF_ICON)
1527 if not isinstance(icon, str) or icon in _CANONICAL_ICON_IDS:
1528 continue
1529 if (replacement := _LEGACY_ICON_MAP.get(icon)) is not None:
1530 values[CONF_ICON] = replacement
1531 LOGGER.info("Migrated icon %s to %s for player %s", icon, replacement, player_id)
1532 changed = True
1533 elif icon.startswith("mdi-"):
1534 # no close equivalent in the shared icon set: drop the stored value
1535 # so the player-type default applies
1536 del values[CONF_ICON]
1537 LOGGER.info("Dropped legacy icon %s for player %s", icon, player_id)
1538 changed = True
1539 # any other unknown value is left in place: clients render the fallback icon
1540 # for unknown ids and the value may become a valid id in a future icon set
1541 return changed
1542