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