/
/
1"""Tests for (settings.json) config migrations."""
2
3from __future__ import annotations
4
5from copy import deepcopy
6from types import SimpleNamespace
7from typing import TYPE_CHECKING, Any
8from uuid import uuid4
9
10from music_assistant_models.errors import InvalidDataError
11
12from music_assistant.constants import (
13 CONF_NFS_SUBFOLDER_MIGRATED,
14 CONF_SERVER_ID,
15 ENCRYPT_SUFFIX,
16)
17from music_assistant.controllers.config.controller import ConfigController
18from music_assistant.controllers.config.migrations import (
19 PROVIDER_SETUP_FLOW_DEFAULTS,
20 PROVIDER_SETUP_FLOW_KEYS,
21 _migrate_airplay_apple_power_control,
22 _migrate_airplay_receiver_ghost_players,
23 _migrate_bluesound_http_profile,
24 _migrate_bose_soundtouch_presets,
25 _migrate_orphaned_disabled_protocol_configs,
26 _migrate_output_limiter,
27 _migrate_player_icons,
28 _migrate_player_setup_data,
29 _migrate_retired_local_audio,
30 _migrate_unrenamed_player_names,
31 migrate_hass_engine_selection,
32 migrate_nfs_subfolder_into_export_path,
33 migrate_provider_setup_data,
34)
35
36if TYPE_CHECKING:
37 from pathlib import Path
38
39 import pytest
40
41
42def _fake_encrypt(value: str) -> str:
43 """Mirror ConfigController.encrypt_string: prefix once, idempotent for encrypted values."""
44 return value if value.startswith(ENCRYPT_SUFFIX) else ENCRYPT_SUFFIX + value
45
46
47def _fake_decrypt(value: str) -> str:
48 """Mirror ConfigController.decrypt_string: strip the prefix, pass plain values through."""
49 return value.removeprefix(ENCRYPT_SUFFIX)
50
51
52def test_migrate_output_limiter_drops_stored_values() -> None:
53 """The removed output limiter setting is dropped, other player values are kept."""
54 data: dict[str, Any] = {
55 "players": {
56 "p1": {"player_id": "p1", "values": {"output_limiter": False, "flow_mode": True}},
57 "p2": {"player_id": "p2", "values": {"output_limiter": True}},
58 "p3": {"player_id": "p3", "values": {}},
59 }
60 }
61 assert _migrate_output_limiter(data) is True
62 assert data["players"]["p1"]["values"] == {"flow_mode": True}
63 assert data["players"]["p2"]["values"] == {}
64 assert data["players"]["p3"]["values"] == {}
65
66
67def test_migrate_output_limiter_noop_when_absent() -> None:
68 """Migration reports no change when no player stored the setting."""
69 data: dict[str, Any] = {"players": {"p1": {"player_id": "p1", "values": {"flow_mode": True}}}}
70 assert _migrate_output_limiter(data) is False
71
72
73def test_migrate_bluesound_http_profile_drops_stored_pick() -> None:
74 """A Bluesound player left on another HTTP profile is returned to the required one."""
75 data: dict[str, Any] = {
76 "players": {
77 "b1": {
78 "provider": "bluesound",
79 "values": {"http_profile": "chunked", "flow_mode": True},
80 },
81 "b2": {"provider": "bluesound--abc1", "values": {"http_profile": "no_content_length"}},
82 "c1": {"provider": "chromecast", "values": {"http_profile": "chunked"}},
83 }
84 }
85 assert _migrate_bluesound_http_profile(data) is True
86 assert data["players"]["b1"]["values"] == {"flow_mode": True}
87 assert data["players"]["b2"]["values"] == {}
88 # other providers still offer the setting, so their pick is left alone
89 assert data["players"]["c1"]["values"] == {"http_profile": "chunked"}
90
91
92def test_migrate_bluesound_http_profile_noop_when_absent() -> None:
93 """Migration reports no change when no Bluesound player stored a profile."""
94 data: dict[str, Any] = {"players": {"b1": {"provider": "bluesound", "values": {}}}}
95 assert _migrate_bluesound_http_profile(data) is False
96
97
98def test_migrate_bluesound_http_profile_noop_when_already_required() -> None:
99 """A player already on the required profile needs no rewrite of the settings."""
100 data: dict[str, Any] = {
101 "players": {
102 "b1": {"provider": "bluesound", "values": {"http_profile": "forced_content_length"}}
103 }
104 }
105 assert _migrate_bluesound_http_profile(data) is False
106
107
108def _airplay_receiver_ghost_data() -> dict[str, Any]:
109 """Build a config store with AirPlay Receiver ghost players next to real players."""
110 return {
111 "providers": {
112 "airplay_receiver--abc1": {
113 "domain": "airplay_receiver",
114 "instance_id": "airplay_receiver--abc1",
115 "values": {"airplay_name": "Garage [AirPlay]"},
116 },
117 "airplay": {"domain": "airplay", "instance_id": "airplay", "values": {}},
118 },
119 "players": {
120 # ghosts of the receiver: airplay player + sendspin bridge + universal wrapper
121 "ap41cf0e23916f": {
122 "player_id": "ap41cf0e23916f",
123 "provider": "airplay",
124 "default_name": "Garage [AirPlay]",
125 "values": {},
126 },
127 "spb_41cf0e23916f": {
128 "player_id": "spb_41cf0e23916f",
129 "provider": "sendspin",
130 "default_name": "Garage [AirPlay] (AirPlay)",
131 "values": {},
132 },
133 "up41cf0e23916f": {
134 "player_id": "up41cf0e23916f",
135 "provider": "universal_player",
136 "default_name": "Garage [AirPlay]",
137 "values": {"linked_protocol_ids": ["ap41cf0e23916f", "spb_41cf0e23916f"]},
138 },
139 # a real airplay player with another name must be kept
140 "apaabbccddeeff": {
141 "player_id": "apaabbccddeeff",
142 "provider": "airplay",
143 "default_name": "Kitchen",
144 "values": {},
145 },
146 # a universal player with a matching name wrapping a native (non-ghost)
147 # protocol player must be kept
148 "up10b41dc887f8": {
149 "player_id": "up10b41dc887f8",
150 "provider": "universal_player",
151 "default_name": "Garage [AirPlay]",
152 "values": {"linked_protocol_ids": ["10:B4:1D:C8:87:F8", "spb_10b41dc887f8"]},
153 },
154 # a group referencing a ghost keeps its other members
155 "syncgroup1": {
156 "player_id": "syncgroup1",
157 "provider": "sync_group",
158 "default_name": "All Speakers",
159 "values": {"group_members": ["up41cf0e23916f", "apaabbccddeeff"]},
160 },
161 },
162 "player_queues": {"up41cf0e23916f": {"queue_id": "up41cf0e23916f"}},
163 "player_dsp": {"up41cf0e23916f": {"enabled": True}},
164 }
165
166
167def test_migrate_airplay_receiver_ghosts_removes_matching_players() -> None:
168 """Ghost ap/spb/up players of an own receiver are removed with their leftover state."""
169 data = _airplay_receiver_ghost_data()
170
171 assert _migrate_airplay_receiver_ghost_players(data) is True
172
173 players = data["players"]
174 assert "ap41cf0e23916f" not in players
175 assert "spb_41cf0e23916f" not in players
176 assert "up41cf0e23916f" not in players
177 # real players survive, including the same-name wrapper of a native player
178 assert "apaabbccddeeff" in players
179 assert "up10b41dc887f8" in players
180 # ghost references are stripped from group membership and per-player state trees
181 assert players["syncgroup1"]["values"]["group_members"] == ["apaabbccddeeff"]
182 assert data["player_queues"] == {}
183 assert data["player_dsp"] == {}
184
185
186def test_migrate_airplay_receiver_ghosts_uses_default_name() -> None:
187 """A receiver without an explicit airplay_name matches ghosts of the default name."""
188 data: dict[str, Any] = {
189 "providers": {
190 "airplay_receiver--abc1": {
191 "domain": "airplay_receiver",
192 "instance_id": "airplay_receiver--abc1",
193 "values": {},
194 },
195 },
196 "players": {
197 "apdb1ff0aae80e": {
198 "player_id": "apdb1ff0aae80e",
199 "provider": "airplay",
200 "default_name": "Music Assistant",
201 "values": {},
202 },
203 },
204 }
205
206 assert _migrate_airplay_receiver_ghost_players(data) is True
207 assert data["players"] == {}
208
209
210def test_migrate_airplay_receiver_ghosts_ignores_setup_flow_instances() -> None:
211 """Setup-flow receivers are skipped because they cannot have produced legacy ghosts."""
212 data: dict[str, Any] = {
213 "providers": {
214 "airplay_receiver--abc1": {
215 "domain": "airplay_receiver",
216 "instance_id": "airplay_receiver--abc1",
217 "values": {},
218 "setup_data": {"airplay_name": ENCRYPT_SUFFIX + "Garage [AirPlay]"},
219 },
220 },
221 "players": {
222 "apdb1ff0aae80e": {
223 "player_id": "apdb1ff0aae80e",
224 "provider": "airplay",
225 "default_name": "Music Assistant",
226 "values": {},
227 },
228 },
229 }
230
231 assert _migrate_airplay_receiver_ghost_players(data) is False
232 assert "apdb1ff0aae80e" in data["players"]
233
234
235def test_migrate_airplay_receiver_ghosts_keeps_same_name_wrapper_without_ghost_links() -> None:
236 """A wrapper sharing the receiver name is kept unless it links only ghost endpoints."""
237 data: dict[str, Any] = {
238 "providers": {
239 "airplay_receiver--abc1": {
240 "domain": "airplay_receiver",
241 "instance_id": "airplay_receiver--abc1",
242 "values": {"airplay_name": "Garage [AirPlay]"},
243 },
244 },
245 "players": {
246 # empty linked list: must NOT be deleted (all([]) is True)
247 "upemptylinks": {
248 "player_id": "upemptylinks",
249 "provider": "universal_player",
250 "default_name": "Garage [AirPlay]",
251 "values": {"linked_protocol_ids": []},
252 },
253 # no linked_protocol_ids at all: must NOT be deleted
254 "upnolinks": {
255 "player_id": "upnolinks",
256 "provider": "universal_player",
257 "default_name": "Garage [AirPlay]",
258 "values": {},
259 },
260 # links a native (non-ghost) protocol player: must NOT be deleted
261 "upnative": {
262 "player_id": "upnative",
263 "provider": "universal_player",
264 "default_name": "Garage [AirPlay]",
265 "values": {"linked_protocol_ids": ["spb_10b41dc887f8"]},
266 },
267 },
268 }
269
270 assert _migrate_airplay_receiver_ghost_players(data) is False
271 assert set(data["players"]) == {"upemptylinks", "upnolinks", "upnative"}
272
273
274def test_migrate_airplay_receiver_ghosts_ignores_disabled_receiver() -> None:
275 """A disabled receiver's name is not used, so same-named players are kept."""
276 data: dict[str, Any] = {
277 "providers": {
278 "airplay_receiver--abc1": {
279 "domain": "airplay_receiver",
280 "instance_id": "airplay_receiver--abc1",
281 "enabled": False,
282 "values": {"airplay_name": "Garage [AirPlay]"},
283 },
284 },
285 "players": {
286 "ap41cf0e23916f": {
287 "player_id": "ap41cf0e23916f",
288 "provider": "airplay",
289 "default_name": "Garage [AirPlay]",
290 "values": {},
291 },
292 },
293 }
294
295 assert _migrate_airplay_receiver_ghost_players(data) is False
296 assert "ap41cf0e23916f" in data["players"]
297
298
299def test_migrate_airplay_receiver_ghosts_noop_without_receivers() -> None:
300 """Without configured receiver instances no player config is touched."""
301 data: dict[str, Any] = {
302 "providers": {"airplay": {"domain": "airplay", "instance_id": "airplay", "values": {}}},
303 "players": {
304 "apdb1ff0aae80e": {
305 "player_id": "apdb1ff0aae80e",
306 "provider": "airplay",
307 "default_name": "Music Assistant",
308 "values": {},
309 },
310 },
311 }
312
313 assert _migrate_airplay_receiver_ghost_players(data) is False
314 assert "apdb1ff0aae80e" in data["players"]
315
316
317def test_migrate_airplay_apple_power_control_flips_stale_default() -> None:
318 """Paired Apple TVs stuck on the old 'none' default get native power control."""
319 data: dict[str, Any] = {
320 "players": {
321 "ap_paired_stale": {
322 "provider": "airplay--x",
323 "values": {"companion_credentials": "enc", "power_control": "none"},
324 },
325 "ap_unpaired": {"provider": "airplay--x", "values": {"power_control": "none"}},
326 "ap_already_native": {
327 "provider": "airplay--x",
328 "values": {"companion_credentials": "enc", "power_control": "native"},
329 },
330 "cast_x": {
331 "provider": "chromecast",
332 "values": {"companion_credentials": "enc", "power_control": "none"},
333 },
334 }
335 }
336 assert _migrate_airplay_apple_power_control(data) is True
337 values = data["players"]
338 # only the paired Apple device on the stale default is changed
339 assert values["ap_paired_stale"]["values"]["power_control"] == "native"
340 assert values["ap_unpaired"]["values"]["power_control"] == "none"
341 assert values["ap_already_native"]["values"]["power_control"] == "native"
342 assert values["cast_x"]["values"]["power_control"] == "none"
343 # idempotent: nothing left to migrate on a second pass
344 assert _migrate_airplay_apple_power_control(data) is False
345
346
347def test_migrate_retired_local_audio_drops_unused_config() -> None:
348 """An install that never attached a soundcard loses the auto-created config silently."""
349 data: dict[str, Any] = {
350 "providers": {
351 "local_audio": {"instance_id": "local_audio", "domain": "local_audio"},
352 "spotify": {"instance_id": "spotify", "domain": "spotify"},
353 },
354 "players": {"p1": {"player_id": "p1", "provider": "chromecast"}},
355 }
356 assert _migrate_retired_local_audio(data) is True
357 assert "local_audio" not in data["providers"]
358 assert "spotify" in data["providers"]
359
360
361def test_migrate_retired_local_audio_keeps_config_when_players_exist() -> None:
362 """An install that actually used local audio keeps the config, so it sees the notice."""
363 data: dict[str, Any] = {
364 "providers": {"local_audio": {"instance_id": "local_audio", "domain": "local_audio"}},
365 "players": {"p1": {"player_id": "p1", "provider": "local_audio"}},
366 }
367 assert _migrate_retired_local_audio(data) is False
368 assert "local_audio" in data["providers"]
369
370
371def test_migrate_retired_local_audio_noop_without_config() -> None:
372 """Migration reports no change when the provider was never configured."""
373 data: dict[str, Any] = {"providers": {"spotify": {"domain": "spotify"}}, "players": {}}
374 assert _migrate_retired_local_audio(data) is False
375
376
377def test_migrate_provider_setup_data_moves_and_encrypts(monkeypatch: pytest.MonkeyPatch) -> None:
378 """Owned string keys move to setup_data encrypted; non-strings move raw; options stay."""
379 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("username", "password", "port"))
380 data: dict[str, Any] = {
381 "providers": {
382 "demo": {
383 "domain": "demo",
384 "values": {
385 "username": "bob",
386 "password": "sekret",
387 "port": 8096,
388 "quality": "high",
389 },
390 }
391 }
392 }
393 assert migrate_provider_setup_data(data, _fake_encrypt) is True
394 cfg = data["providers"]["demo"]
395 # the (non-owned) option key stays untouched in values
396 assert cfg["values"] == {"quality": "high"}
397 # owned string values are encrypted at rest, non-string values move as-is
398 assert cfg["setup_data"]["username"] == ENCRYPT_SUFFIX + "bob"
399 assert cfg["setup_data"]["password"] == ENCRYPT_SUFFIX + "sekret"
400 assert cfg["setup_data"]["port"] == 8096
401
402
403def test_setup_flow_defaults_are_owned_keys() -> None:
404 """Every key with a fallback default is also a key the migration owns."""
405 for domain, defaults in PROVIDER_SETUP_FLOW_DEFAULTS.items():
406 owned = PROVIDER_SETUP_FLOW_KEYS[domain]
407 assert set(defaults).issubset(owned), f"{domain}: {set(defaults) - set(owned)}"
408
409
410def test_migrate_provider_setup_data_restores_dropped_defaults() -> None:
411 """
412 A plex instance left on the default port gets that port back.
413
414 Regression: a config value that matches its entry default is not persisted, so an
415 instance on port 32400 had nothing in values to migrate and its server URL became
416 'https://<host>:None'.
417 """
418 data: dict[str, Any] = {
419 "providers": {
420 "plex--abc": {
421 "domain": "plex",
422 "values": {"local_server_ip": "local.abc.plex.direct", "local_server_ssl": True},
423 }
424 }
425 }
426 assert migrate_provider_setup_data(data, _fake_encrypt) is True
427 setup_data = data["providers"]["plex--abc"]["setup_data"]
428 assert setup_data["local_server_port"] == 32400
429 assert setup_data["local_server_verify_cert"] is True
430 # the user's own value is migrated, not replaced by the default
431 assert setup_data["local_server_ssl"] is True
432
433
434def test_migrate_provider_setup_data_restores_defaults_after_earlier_run() -> None:
435 """An install whose values were already moved by an earlier run is still repaired."""
436 data: dict[str, Any] = {
437 "providers": {
438 "plex--abc": {
439 "domain": "plex",
440 "values": {},
441 "setup_data": {
442 "local_server_ip": ENCRYPT_SUFFIX + "local.abc.plex.direct",
443 "local_server_ssl": True,
444 },
445 }
446 }
447 }
448 assert migrate_provider_setup_data(data, _fake_encrypt) is True
449 assert data["providers"]["plex--abc"]["setup_data"]["local_server_port"] == 32400
450
451
452def test_migrate_provider_setup_data_keeps_explicit_values() -> None:
453 """A stored choice is never overwritten by a fallback default."""
454 data: dict[str, Any] = {
455 "providers": {
456 "jellyfin": {
457 "domain": "jellyfin",
458 "values": {},
459 "setup_data": {"verify_ssl": False},
460 }
461 }
462 }
463 assert migrate_provider_setup_data(data, _fake_encrypt) is False
464 assert data["providers"]["jellyfin"]["setup_data"]["verify_ssl"] is False
465
466
467def test_migrate_provider_setup_data_encrypts_restored_strings() -> None:
468 """A restored string default is encrypted at rest like a migrated one."""
469 data: dict[str, Any] = {
470 "providers": {
471 "filesystem_local": {"domain": "filesystem_local", "values": {}},
472 }
473 }
474 assert migrate_provider_setup_data(data, _fake_encrypt) is True
475 assert data["providers"]["filesystem_local"]["setup_data"]["path"] == ENCRYPT_SUFFIX + "/media"
476
477
478def test_migrate_provider_setup_data_restores_lastfm_network() -> None:
479 """
480 A Last.fm scrobbler that never stored its network choice gets it back.
481
482 Regression: `_provider` defaulted to 'lastfm' so it was never persisted, and the
483 None read back from setup_data made get_network raise on _NetworkType('None').
484 """
485 data: dict[str, Any] = {
486 "providers": {
487 "lastfm_scrobble": {
488 "domain": "lastfm_scrobble",
489 "values": {"_api_session_key": "abc123"},
490 }
491 }
492 }
493 assert migrate_provider_setup_data(data, _fake_encrypt) is True
494 setup_data = data["providers"]["lastfm_scrobble"]["setup_data"]
495 assert setup_data["_provider"] == ENCRYPT_SUFFIX + "lastfm"
496 # an explicit librefm choice differed from the default and was persisted, so it
497 # arrives via the move and is never overwritten by the fallback
498 data = {
499 "providers": {
500 "lastfm_scrobble": {
501 "domain": "lastfm_scrobble",
502 "values": {"_provider": "librefm"},
503 }
504 }
505 }
506 assert migrate_provider_setup_data(data, _fake_encrypt) is True
507 assert (
508 data["providers"]["lastfm_scrobble"]["setup_data"]["_provider"]
509 == ENCRYPT_SUFFIX + "librefm"
510 )
511
512
513def test_migrate_provider_setup_data_restores_smb_version() -> None:
514 """
515 An SMB share left on the default protocol version keeps its 3.0 pin.
516
517 Regression: the dropped default made both mount paths read '' and skip the
518 vers=3.0 mount option, silently changing protocol negotiation.
519 """
520 data: dict[str, Any] = {
521 "providers": {
522 "filesystem_smb": {"domain": "filesystem_smb", "values": {}},
523 }
524 }
525 assert migrate_provider_setup_data(data, _fake_encrypt) is True
526 setup_data = data["providers"]["filesystem_smb"]["setup_data"]
527 assert setup_data["smb_version"] == ENCRYPT_SUFFIX + "3.0"
528 # an explicit '' (auto-negotiate) choice was persisted and must win over the pin
529 data = {
530 "providers": {
531 "filesystem_smb": {"domain": "filesystem_smb", "values": {"smb_version": ""}},
532 }
533 }
534 assert migrate_provider_setup_data(data, _fake_encrypt) is True
535 assert data["providers"]["filesystem_smb"]["setup_data"]["smb_version"] == ENCRYPT_SUFFIX
536
537
538def test_migrate_receiver_and_connect_setup_values() -> None:
539 """New setup-flow fields move to setup_data without losing typed values."""
540 data: dict[str, Any] = {
541 "providers": {
542 "airplay_receiver--1": {
543 "domain": "airplay_receiver",
544 "values": {
545 "mass_player_id": "kitchen",
546 "airplay_name": "Kitchen AirPlay",
547 "log_level": "INFO",
548 },
549 },
550 "ariacast_receiver": {
551 "domain": "ariacast_receiver",
552 "values": {"mass_player_id": "office"},
553 },
554 "spotify_connect--1": {
555 "domain": "spotify_connect",
556 "values": {
557 "mass_player_id": "living-room",
558 "publish_name": "Living Room Spotify",
559 },
560 },
561 "vban_receiver--1": {
562 "domain": "vban_receiver",
563 "values": {
564 "bind_ip": "192.0.2.2",
565 "bind_port": 6981,
566 "sender_host": "192.0.2.10",
567 "vban_stream_name": "Studio",
568 "audio_format": "S16LE",
569 "sample_rate": 48000,
570 "audio_channels": 2,
571 "vban_queue_size": 100,
572 },
573 },
574 "yandex_ynison--1": {
575 "domain": "yandex_ynison",
576 "values": {
577 "mass_player_id": "office",
578 "publish_name": "Office Yandex",
579 "allow_player_switch": False,
580 },
581 },
582 }
583 }
584
585 assert migrate_provider_setup_data(data, _fake_encrypt) is True
586
587 providers = data["providers"]
588 assert providers["airplay_receiver--1"]["values"] == {"log_level": "INFO"}
589 assert providers["airplay_receiver--1"]["setup_data"] == {
590 "mass_player_id": ENCRYPT_SUFFIX + "kitchen",
591 "airplay_name": ENCRYPT_SUFFIX + "Kitchen AirPlay",
592 }
593 assert providers["ariacast_receiver"]["values"] == {}
594 assert providers["ariacast_receiver"]["setup_data"] == {
595 "mass_player_id": ENCRYPT_SUFFIX + "office"
596 }
597 assert providers["spotify_connect--1"]["values"] == {}
598 assert providers["spotify_connect--1"]["setup_data"] == {
599 "mass_player_id": ENCRYPT_SUFFIX + "living-room",
600 "publish_name": ENCRYPT_SUFFIX + "Living Room Spotify",
601 }
602 assert providers["vban_receiver--1"]["values"] == {"vban_queue_size": 100}
603 assert providers["vban_receiver--1"]["setup_data"] == {
604 "bind_ip": ENCRYPT_SUFFIX + "192.0.2.2",
605 "bind_port": 6981,
606 "sender_host": ENCRYPT_SUFFIX + "192.0.2.10",
607 "vban_stream_name": ENCRYPT_SUFFIX + "Studio",
608 "audio_format": ENCRYPT_SUFFIX + "S16LE",
609 "sample_rate": 48000,
610 "audio_channels": 2,
611 }
612 assert providers["yandex_ynison--1"]["values"] == {"allow_player_switch": False}
613 assert providers["yandex_ynison--1"]["setup_data"] == {
614 "mass_player_id": ENCRYPT_SUFFIX + "office",
615 "publish_name": ENCRYPT_SUFFIX + "Office Yandex",
616 }
617
618
619def test_migrate_default_airplay_receiver_name_once() -> None:
620 """The implicit receiver name is persisted so ghost cleanup cannot rerun later."""
621 data: dict[str, Any] = {
622 "providers": {
623 "airplay_receiver--1": {
624 "domain": "airplay_receiver",
625 "values": {"mass_player_id": "__auto__"},
626 }
627 },
628 "players": {
629 "aplegitimate": {
630 "player_id": "aplegitimate",
631 "provider": "airplay",
632 "default_name": "Music Assistant",
633 "values": {},
634 }
635 },
636 }
637
638 assert migrate_provider_setup_data(data, _fake_encrypt) is True
639 assert data["providers"]["airplay_receiver--1"]["setup_data"] == {
640 "mass_player_id": ENCRYPT_SUFFIX + "__auto__",
641 "airplay_name": ENCRYPT_SUFFIX + "Music Assistant",
642 }
643 assert _migrate_airplay_receiver_ghost_players(data) is False
644 assert "aplegitimate" in data["players"]
645 assert migrate_provider_setup_data(data, _fake_encrypt) is False
646
647
648def test_migrate_provider_setup_data_idempotent_and_preserves_existing(
649 monkeypatch: pytest.MonkeyPatch,
650) -> None:
651 """Already-encrypted values move unchanged, existing setup_data wins, second run is a no-op."""
652 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("token", "secret"))
653 data: dict[str, Any] = {
654 "providers": {
655 "demo": {
656 "domain": "demo",
657 "values": {"token": ENCRYPT_SUFFIX + "abc", "secret": "raw"},
658 # a value already collected into setup_data must not be clobbered
659 "setup_data": {"secret": ENCRYPT_SUFFIX + "kept"},
660 }
661 }
662 }
663 assert migrate_provider_setup_data(data, _fake_encrypt) is True
664 cfg = data["providers"]["demo"]
665 # an already-encrypted value is moved without re-encrypting (no double prefix)
666 assert cfg["setup_data"]["token"] == ENCRYPT_SUFFIX + "abc"
667 # the pre-existing setup_data value survives; the stale values copy is dropped
668 assert cfg["setup_data"]["secret"] == ENCRYPT_SUFFIX + "kept"
669 assert cfg["values"] == {}
670 # a second pass finds nothing left to move
671 assert migrate_provider_setup_data(data, _fake_encrypt) is False
672
673
674def test_migrate_provider_setup_data_multi_instance_and_scope(
675 monkeypatch: pytest.MonkeyPatch,
676) -> None:
677 """All instances of a mapped domain migrate; unmapped domains are left untouched."""
678 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("host",))
679 data: dict[str, Any] = {
680 "providers": {
681 "demo--a": {"domain": "demo", "values": {"host": "h1"}},
682 "demo--b": {"domain": "demo", "values": {"host": "h2", "quality": "x"}},
683 "other": {"domain": "other", "values": {"host": "keep"}},
684 }
685 }
686 assert migrate_provider_setup_data(data, _fake_encrypt) is True
687 prov = data["providers"]
688 assert prov["demo--a"]["setup_data"]["host"] == ENCRYPT_SUFFIX + "h1"
689 assert prov["demo--a"]["values"] == {}
690 assert prov["demo--b"]["setup_data"]["host"] == ENCRYPT_SUFFIX + "h2"
691 assert prov["demo--b"]["values"] == {"quality": "x"}
692 # a provider whose domain is not in the map is never touched
693 assert prov["other"]["values"] == {"host": "keep"}
694 assert "setup_data" not in prov["other"]
695
696
697def test_migrate_provider_setup_data_noop() -> None:
698 """Missing or empty provider config store reports no change."""
699 assert migrate_provider_setup_data({}, _fake_encrypt) is False
700 assert migrate_provider_setup_data({"providers": {}}, _fake_encrypt) is False
701
702
703def test_migrate_provider_setup_data_real_domain_opensubsonic() -> None:
704 """A real mapped domain moves its setup keys (incl. the redefined baseURL literal)."""
705 data: dict[str, Any] = {
706 "providers": {
707 "opensubsonic--x": {
708 "domain": "opensubsonic",
709 "values": {
710 "username": "alice",
711 "password": "pw",
712 "baseURL": "https://music.example",
713 "port": 4533,
714 "enable_podcasts": True,
715 },
716 }
717 }
718 }
719 assert migrate_provider_setup_data(data, _fake_encrypt) is True
720 cfg = data["providers"]["opensubsonic--x"]
721 assert cfg["setup_data"]["username"] == ENCRYPT_SUFFIX + "alice"
722 assert cfg["setup_data"]["baseURL"] == ENCRYPT_SUFFIX + "https://music.example"
723 assert cfg["setup_data"]["port"] == 4533
724 # a genuine provider option is not part of the setup-flow key set and stays put
725 assert cfg["values"] == {"enable_podcasts": True}
726
727
728def test_migrate_player_setup_data_moves_credentials() -> None:
729 """Player-owned credential/pairing keys move from values into setup_data."""
730 data: dict[str, Any] = {
731 "players": {
732 "ap1": {
733 "player_id": "ap1",
734 "provider": "airplay",
735 "values": {
736 "airplay_credentials": "ENC_ap2creds",
737 "companion_credentials": "ENC_companion",
738 "ap2password": "ENC_dead",
739 "password": "ENC_devpw",
740 "ignore_volume": True,
741 },
742 },
743 "fk1": {
744 "player_id": "fk1",
745 "provider": "fully_kiosk",
746 "values": {"password": "ENC_fk", "use_ssl": True},
747 },
748 "mpd1": {"player_id": "mpd1", "provider": "mpd", "values": {}},
749 "sonos1": {
750 "player_id": "sonos1",
751 "provider": "sonos",
752 "values": {"password": "keepme"},
753 },
754 }
755 }
756 assert _migrate_player_setup_data(data) is True
757 ap1 = data["players"]["ap1"]
758 # credentials moved to setup_data (already-encrypted, moved as-is)
759 assert ap1["setup_data"] == {
760 "airplay_credentials": "ENC_ap2creds",
761 "companion_credentials": "ENC_companion",
762 }
763 # the RAOP device password (a genuine user option) and other options stay in values;
764 # the vestigial ap2password is dropped entirely
765 assert ap1["values"] == {"password": "ENC_devpw", "ignore_volume": True}
766 # fully_kiosk password moves, unrelated option stays
767 assert data["players"]["fk1"]["setup_data"] == {"password": "ENC_fk"}
768 assert data["players"]["fk1"]["values"] == {"use_ssl": True}
769 # a provider not in the map is untouched
770 assert data["players"]["sonos1"]["values"] == {"password": "keepme"}
771 assert "setup_data" not in data["players"]["sonos1"]
772 # idempotent second run
773 assert _migrate_player_setup_data(data) is False
774
775
776def test_migrate_player_setup_data_preserves_existing_and_drops_null() -> None:
777 """An existing setup_data value is never clobbered and stored nulls are dropped."""
778 data: dict[str, Any] = {
779 "players": {
780 "ap1": {
781 "player_id": "ap1",
782 "provider": "airplay",
783 "setup_data": {"airplay_credentials": "ENC_existing"},
784 "values": {"airplay_credentials": "ENC_stale", "raop_credentials": None},
785 }
786 }
787 }
788 assert _migrate_player_setup_data(data) is True
789 ap1 = data["players"]["ap1"]
790 # the pre-existing setup_data value wins; the null raop value is just dropped
791 assert ap1["setup_data"] == {"airplay_credentials": "ENC_existing"}
792 assert ap1["values"] == {}
793
794
795def test_migrate_player_setup_data_multi_instance_domain() -> None:
796 """Domain matching handles multi-instance provider ids (<domain>--<id>)."""
797 data: dict[str, Any] = {
798 "players": {
799 "ap1": {
800 "player_id": "ap1",
801 "provider": "airplay--2",
802 "values": {"raop_credentials": "ENC_raop"},
803 }
804 }
805 }
806 assert _migrate_player_setup_data(data) is True
807 assert data["players"]["ap1"]["setup_data"] == {"raop_credentials": "ENC_raop"}
808 assert data["players"]["ap1"]["values"] == {}
809
810
811def test_migrate_bose_soundtouch_presets_drops_player_values() -> None:
812 """The per-player preset mappings are removed, other player values are kept."""
813 data: dict[str, Any] = {
814 "players": {
815 "bose_soundtouch_a": {
816 "player_id": "bose_soundtouch_a",
817 "provider": "bose_soundtouch",
818 "values": {
819 "preset_1_media": "library://playlist/12",
820 "preset_1_media_type": "playlist",
821 "preset_1_search": "morning",
822 "preset_1_selected_media": "library://playlist/12",
823 "volume_normalization": True,
824 },
825 },
826 "bose_soundtouch_b": {
827 "player_id": "bose_soundtouch_b",
828 "provider": "bose_soundtouch--2",
829 "values": {"preset_6_media": "library://radio/3"},
830 },
831 }
832 }
833 assert _migrate_bose_soundtouch_presets(data) is True
834 assert data["players"]["bose_soundtouch_a"]["values"] == {"volume_normalization": True}
835 assert data["players"]["bose_soundtouch_b"]["values"] == {}
836
837
838def test_migrate_bose_soundtouch_presets_scoped_to_provider() -> None:
839 """Players of other providers keep any similarly named values."""
840 data: dict[str, Any] = {
841 "players": {
842 "other": {
843 "player_id": "other",
844 "provider": "sonos",
845 "values": {"preset_1_media": "library://playlist/12"},
846 }
847 }
848 }
849 assert _migrate_bose_soundtouch_presets(data) is False
850 assert data["players"]["other"]["values"] == {"preset_1_media": "library://playlist/12"}
851
852
853def test_migrate_bose_soundtouch_presets_noop_when_absent() -> None:
854 """Migration reports no change when no SoundTouch player stored presets."""
855 data: dict[str, Any] = {
856 "players": {
857 "bose_soundtouch_a": {
858 "player_id": "bose_soundtouch_a",
859 "provider": "bose_soundtouch",
860 "values": {"volume_normalization": True},
861 }
862 }
863 }
864 assert _migrate_bose_soundtouch_presets(data) is False
865
866
867def test_migrate_nfs_subfolder_folds_into_export_path() -> None:
868 """The subfolder is appended to the export path and the now-obsolete key is dropped."""
869 data: dict[str, Any] = {
870 "providers": {
871 "filesystem_nfs--1": {
872 "domain": "filesystem_nfs",
873 "setup_data": {
874 "host": ENCRYPT_SUFFIX + "nas.local",
875 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
876 "subfolder": ENCRYPT_SUFFIX + "Music",
877 "nfs_version": ENCRYPT_SUFFIX + "3",
878 },
879 "values": {"sync_tracks": True},
880 }
881 }
882 }
883
884 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
885
886 setup_data = data["providers"]["filesystem_nfs--1"]["setup_data"]
887 assert setup_data["export_path"] == ENCRYPT_SUFFIX + "/mnt/vault/Music"
888 assert "subfolder" not in setup_data
889 assert setup_data["host"] == ENCRYPT_SUFFIX + "nas.local"
890 assert setup_data["nfs_version"] == ENCRYPT_SUFFIX + "3"
891 assert data["providers"]["filesystem_nfs--1"]["values"] == {"sync_tracks": True}
892 # the marker stops a second pass
893 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
894 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is False
895 assert setup_data["export_path"] == ENCRYPT_SUFFIX + "/mnt/vault/Music"
896
897
898def test_migrate_nfs_subfolder_normalizes_leading_slash_and_nesting() -> None:
899 """A leading slash is tolerated and a nested subfolder keeps its full depth."""
900 data: dict[str, Any] = {
901 "providers": {
902 "rooted": {
903 "domain": "filesystem_nfs",
904 "setup_data": {
905 "export_path": ENCRYPT_SUFFIX + "/volume1",
906 "subfolder": ENCRYPT_SUFFIX + "/music",
907 },
908 },
909 "nested": {
910 "domain": "filesystem_nfs",
911 "setup_data": {
912 "export_path": ENCRYPT_SUFFIX + "/exports/media/",
913 "subfolder": ENCRYPT_SUFFIX + " albums/A-K ",
914 },
915 },
916 }
917 }
918
919 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
920
921 providers = data["providers"]
922 assert providers["rooted"]["setup_data"]["export_path"] == ENCRYPT_SUFFIX + "/volume1/music"
923 assert (
924 providers["nested"]["setup_data"]["export_path"]
925 == ENCRYPT_SUFFIX + "/exports/media/albums/A-K"
926 )
927
928
929def test_migrate_nfs_subfolder_noop_cases() -> None:
930 """Instances without a usable subfolder, and other providers, are left untouched."""
931 data: dict[str, Any] = {
932 "providers": {
933 "empty_subfolder": {
934 "domain": "filesystem_nfs",
935 "setup_data": {
936 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
937 "subfolder": ENCRYPT_SUFFIX + "",
938 },
939 },
940 "no_subfolder_key": {
941 "domain": "filesystem_nfs",
942 "setup_data": {"export_path": ENCRYPT_SUFFIX + "/mnt/vault"},
943 },
944 "no_export_path": {
945 "domain": "filesystem_nfs",
946 "setup_data": {"subfolder": ENCRYPT_SUFFIX + "Music"},
947 },
948 "smb": {
949 "domain": "filesystem_smb",
950 "setup_data": {
951 "share": ENCRYPT_SUFFIX + "media",
952 "subfolder": ENCRYPT_SUFFIX + "Music",
953 },
954 },
955 }
956 }
957 providers_before = deepcopy(data["providers"])
958
959 # only the one-shot marker is written; no provider config is touched
960 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
961 assert data["providers"] == providers_before
962 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
963
964
965def test_migrate_nfs_subfolder_never_folds_a_post_fix_config() -> None:
966 """A subfolder stored after the marker is set is never folded, however often this runs."""
967 data: dict[str, Any] = {
968 CONF_NFS_SUBFOLDER_MIGRATED: True,
969 "providers": {
970 "filesystem_nfs--1": {
971 "domain": "filesystem_nfs",
972 "setup_data": {
973 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
974 "subfolder": ENCRYPT_SUFFIX + "Music",
975 },
976 }
977 },
978 }
979 before = deepcopy(data)
980
981 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is False
982 assert data == before
983
984
985def test_migrate_nfs_subfolder_handles_plaintext_values() -> None:
986 """A value that was never encrypted at rest folds just the same."""
987 data: dict[str, Any] = {
988 "providers": {
989 "filesystem_nfs--1": {
990 "domain": "filesystem_nfs",
991 "setup_data": {"export_path": "/mnt/vault", "subfolder": "Music"},
992 }
993 }
994 }
995
996 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
997 assert (
998 data["providers"]["filesystem_nfs--1"]["setup_data"]["export_path"]
999 == ENCRYPT_SUFFIX + "/mnt/vault/Music"
1000 )
1001
1002
1003def test_migrate_nfs_subfolder_skips_an_undecryptable_instance() -> None:
1004 """An unreadable value costs that instance its migration, not the server its startup."""
1005
1006 def _failing_decrypt(_value: str) -> str:
1007 raise InvalidDataError("Password decryption failed")
1008
1009 data: dict[str, Any] = {
1010 "providers": {
1011 "broken": {
1012 "domain": "filesystem_nfs",
1013 "setup_data": {
1014 "export_path": ENCRYPT_SUFFIX + "unreadable",
1015 "subfolder": ENCRYPT_SUFFIX + "unreadable",
1016 },
1017 }
1018 }
1019 }
1020
1021 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _failing_decrypt) is True
1022
1023 # left as it was, but the marker is still claimed
1024 assert data["providers"]["broken"]["setup_data"] == {
1025 "export_path": ENCRYPT_SUFFIX + "unreadable",
1026 "subfolder": ENCRYPT_SUFFIX + "unreadable",
1027 }
1028 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
1029
1030
1031def test_migrate_nfs_subfolder_round_trips_through_real_encryption(tmp_path: Path) -> None:
1032 """The folded export path is re-encrypted with the live key, so a migrated config loads."""
1033 mass = SimpleNamespace(storage_path=str(tmp_path))
1034 controller = ConfigController(mass) # type: ignore[arg-type]
1035 controller.initialized = True
1036 controller.save = lambda **_kwargs: None # type: ignore[method-assign]
1037 controller.set(CONF_SERVER_ID, uuid4().hex)
1038 controller._init_encryption()
1039
1040 data: dict[str, Any] = {
1041 "providers": {
1042 "filesystem_nfs--1": {
1043 "domain": "filesystem_nfs",
1044 "setup_data": {
1045 "export_path": controller.encrypt_string("/mnt/vault"),
1046 "subfolder": controller.encrypt_string("Music"),
1047 },
1048 }
1049 }
1050 }
1051
1052 assert (
1053 migrate_nfs_subfolder_into_export_path(
1054 data, controller.encrypt_string, controller.decrypt_string
1055 )
1056 is True
1057 )
1058
1059 stored = data["providers"]["filesystem_nfs--1"]["setup_data"]["export_path"]
1060 assert stored.startswith(ENCRYPT_SUFFIX)
1061 assert controller.decrypt_string(stored) == "/mnt/vault/Music"
1062
1063
1064def _hass_engine_data(instance_id: str = "hass", **hass_values: Any) -> dict[str, Any]:
1065 """Build a config store with a hass provider and all three engine consumers."""
1066 return {
1067 "providers": {
1068 instance_id: {
1069 "domain": "hass",
1070 "instance_id": instance_id,
1071 "values": {"verify_ssl": True, **hass_values},
1072 },
1073 "ai_radio": {"domain": "ai_radio", "instance_id": "ai_radio", "values": {}},
1074 "music_quiz": {"domain": "music_quiz", "instance_id": "music_quiz", "values": {}},
1075 "smart_playlist": {
1076 "domain": "smart_playlist",
1077 "instance_id": "smart_playlist",
1078 "values": {},
1079 },
1080 }
1081 }
1082
1083
1084def test_migrate_hass_engine_selection_fans_out_to_all_consumers() -> None:
1085 """Both stored entity ids become engine uids on every consumer, encrypted for ai_radio."""
1086 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
1087 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1088 prov = data["providers"]
1089 assert prov["ai_radio"]["setup_data"] == {
1090 "ai_engine": ENCRYPT_SUFFIX + "hass/ai_task.google",
1091 "tts_engine": ENCRYPT_SUFFIX + "hass/tts.piper",
1092 }
1093 # the plain providers store their pick unencrypted in values
1094 assert prov["music_quiz"]["values"] == {"ai_engine": "hass/ai_task.google"}
1095 assert prov["smart_playlist"]["values"] == {"ai_engine": "hass/ai_task.google"}
1096 # the dead keys are consumed, the hass config is otherwise untouched
1097 assert prov["hass"]["values"] == {"verify_ssl": True}
1098 # a second pass finds nothing left to migrate
1099 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1100
1101
1102def test_migrate_hass_engine_selection_uses_own_instance_id() -> None:
1103 """The engine uid is built from the hass config's own instance id, not its domain."""
1104 data = _hass_engine_data("hass--abc", ai_task_entity="ai_task.google")
1105 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1106 assert data["providers"]["music_quiz"]["values"] == {"ai_engine": "hass--abc/ai_task.google"}
1107
1108
1109def test_migrate_hass_engine_selection_migrates_keys_independently() -> None:
1110 """A single stored key migrates on its own; the other selection stays unset."""
1111 data = _hass_engine_data(tts_entity="tts.piper")
1112 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1113 prov = data["providers"]
1114 assert prov["ai_radio"]["setup_data"] == {"tts_engine": ENCRYPT_SUFFIX + "hass/tts.piper"}
1115 assert prov["music_quiz"]["values"] == {}
1116 assert prov["smart_playlist"]["values"] == {}
1117 assert prov["hass"]["values"] == {"verify_ssl": True}
1118
1119
1120def test_migrate_hass_engine_selection_without_consumers() -> None:
1121 """The dead hass keys are dropped even when no consuming provider is installed."""
1122 data: dict[str, Any] = {
1123 "providers": {
1124 "hass": {
1125 "domain": "hass",
1126 "instance_id": "hass",
1127 "values": {"tts_entity": "tts.piper", "ai_task_entity": "ai_task.google"},
1128 },
1129 "music_quiz": {"domain": "music_quiz", "instance_id": "music_quiz", "values": {}},
1130 }
1131 }
1132 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1133 assert data["providers"]["hass"]["values"] == {}
1134 assert data["providers"]["music_quiz"]["values"] == {"ai_engine": "hass/ai_task.google"}
1135 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1136
1137
1138def test_migrate_hass_engine_selection_preserves_existing_choice() -> None:
1139 """A selection the user already made on a consumer is never overwritten."""
1140 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
1141 prov = data["providers"]
1142 prov["music_quiz"]["values"]["ai_engine"] = "openai/gpt"
1143 prov["ai_radio"]["setup_data"] = {"tts_engine": ENCRYPT_SUFFIX + "elevenlabs/voice"}
1144 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1145 assert prov["music_quiz"]["values"] == {"ai_engine": "openai/gpt"}
1146 assert prov["ai_radio"]["setup_data"] == {
1147 "ai_engine": ENCRYPT_SUFFIX + "hass/ai_task.google",
1148 "tts_engine": ENCRYPT_SUFFIX + "elevenlabs/voice",
1149 }
1150 # the consumer that had no choice yet still gets the migrated one
1151 assert prov["smart_playlist"]["values"] == {"ai_engine": "hass/ai_task.google"}
1152 assert prov["hass"]["values"] == {"verify_ssl": True}
1153
1154
1155def test_migrate_hass_engine_selection_noop_without_stored_values() -> None:
1156 """Nothing stored (or no hass provider at all) reports no change."""
1157 assert migrate_hass_engine_selection({}, _fake_encrypt) is False
1158 assert migrate_hass_engine_selection({"providers": {}}, _fake_encrypt) is False
1159 data = _hass_engine_data()
1160 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1161 assert "setup_data" not in data["providers"]["ai_radio"]
1162 assert data["providers"]["music_quiz"]["values"] == {}
1163
1164
1165def test_migrate_hass_engine_selection_skips_multiple_hass_configs() -> None:
1166 """With several hass configurations there is no correct winner, so nothing is touched."""
1167 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
1168 data["providers"]["hass--second"] = {
1169 "domain": "hass",
1170 "instance_id": "hass--second",
1171 "values": {"tts_entity": "tts.cloud"},
1172 }
1173 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1174 prov = data["providers"]
1175 assert prov["hass"]["values"]["tts_entity"] == "tts.piper"
1176 assert prov["hass"]["values"]["ai_task_entity"] == "ai_task.google"
1177 assert prov["music_quiz"]["values"] == {}
1178 assert "setup_data" not in prov["ai_radio"]
1179
1180
1181def test_migrate_player_icons_rewrites_legacy_values() -> None:
1182 """Legacy mdi-* and pre-1.0 picker icon values are rewritten to canonical ids."""
1183 data: dict[str, Any] = {
1184 "players": {
1185 "p1": {"player_id": "p1", "values": {"icon": "mdi-speaker-multiple"}},
1186 "p2": {"player_id": "p2", "values": {"icon": "sofa"}},
1187 "p3": {"player_id": "p3", "values": {"icon": "mdi-television-classic"}},
1188 }
1189 }
1190 assert _migrate_player_icons(data) is True
1191 assert data["players"]["p1"]["values"]["icon"] == "speakers"
1192 assert data["players"]["p2"]["values"]["icon"] == "living-room"
1193 assert data["players"]["p3"]["values"]["icon"] == "tv"
1194
1195
1196def test_migrate_player_icons_drops_unmappable_mdi_values() -> None:
1197 """An mdi-* icon with no close equivalent is dropped so the default applies."""
1198 data: dict[str, Any] = {
1199 "players": {
1200 "p1": {"player_id": "p1", "values": {"icon": "mdi-pac-man", "flow_mode": True}},
1201 }
1202 }
1203 assert _migrate_player_icons(data) is True
1204 assert data["players"]["p1"]["values"] == {"flow_mode": True}
1205
1206
1207def test_migrate_player_icons_keeps_canonical_and_unknown_ids() -> None:
1208 """Canonical ids are never touched; unknown non-mdi values are left in place."""
1209 data: dict[str, Any] = {
1210 "players": {
1211 "p1": {"player_id": "p1", "values": {"icon": "sonos"}},
1212 "p2": {"player_id": "p2", "values": {"icon": "kitchen"}},
1213 "p3": {"player_id": "p3", "values": {"icon": "dog"}},
1214 }
1215 }
1216 assert _migrate_player_icons(data) is False
1217 assert data["players"]["p1"]["values"]["icon"] == "sonos"
1218 assert data["players"]["p2"]["values"]["icon"] == "kitchen"
1219 assert data["players"]["p3"]["values"]["icon"] == "dog"
1220
1221
1222def test_migrate_player_icons_noop_when_absent() -> None:
1223 """Migration reports no change for players without a stored icon."""
1224 data: dict[str, Any] = {
1225 "players": {
1226 "p1": {"player_id": "p1", "values": {"flow_mode": True}},
1227 "p2": {"player_id": "p2"},
1228 }
1229 }
1230 assert _migrate_player_icons(data) is False
1231
1232
1233def test_migrate_player_icons_idempotent() -> None:
1234 """A second run over already-migrated data reports no change."""
1235 data: dict[str, Any] = {
1236 "players": {
1237 "p1": {"player_id": "p1", "values": {"icon": "mdi-speaker-multiple"}},
1238 "p2": {"player_id": "p2", "values": {"icon": "mdi-pac-man"}},
1239 }
1240 }
1241 assert _migrate_player_icons(data) is True
1242 assert _migrate_player_icons(data) is False
1243 assert data["players"]["p1"]["values"]["icon"] == "speakers"
1244 assert "icon" not in data["players"]["p2"]["values"]
1245
1246
1247def test_migrate_player_icons_tolerates_malformed_data() -> None:
1248 """Non-dict player configs/values and non-string icon values are skipped."""
1249 data: dict[str, Any] = {
1250 "players": {
1251 "p1": "not-a-dict",
1252 "p2": {"player_id": "p2", "values": "not-a-dict"},
1253 "p3": {"player_id": "p3", "values": {"icon": None}},
1254 "p4": {"player_id": "p4", "values": {"icon": 123}},
1255 }
1256 }
1257 assert _migrate_player_icons(data) is False
1258 assert data["players"]["p3"]["values"]["icon"] is None
1259 assert data["players"]["p4"]["values"]["icon"] == 123
1260
1261
1262def _orphaned_protocol_data() -> dict[str, Any]:
1263 """Build a config store with a disabled protocol player whose parent was removed."""
1264 return {
1265 "players": {
1266 "spb_esp32": {
1267 "player_id": "spb_esp32",
1268 "provider": "sendspin",
1269 "player_type": "protocol",
1270 "enabled": False,
1271 "values": {"protocol_parent_id": "up_esp32"},
1272 },
1273 "spb_kitchen": {
1274 "player_id": "spb_kitchen",
1275 "provider": "sendspin",
1276 "player_type": "protocol",
1277 "enabled": False,
1278 "values": {"protocol_parent_id": "up_kitchen"},
1279 },
1280 # only the parent side of the link survived
1281 "ap_office": {
1282 "player_id": "ap_office",
1283 "provider": "airplay",
1284 "player_type": "protocol",
1285 "enabled": False,
1286 "values": {},
1287 },
1288 "cast_office": {
1289 "player_id": "cast_office",
1290 "provider": "chromecast",
1291 "player_type": "player",
1292 "enabled": True,
1293 "values": {"linked_protocol_ids": ["ap_office"]},
1294 },
1295 # neither side of the link survived
1296 "ap_ghost": {
1297 "player_id": "ap_ghost",
1298 "provider": "airplay",
1299 "player_type": "protocol",
1300 "enabled": False,
1301 "values": {},
1302 },
1303 "up_kitchen": {
1304 "player_id": "up_kitchen",
1305 "provider": "universal_player",
1306 "player_type": "player",
1307 "enabled": False,
1308 "values": {"linked_protocol_ids": ["spb_kitchen"]},
1309 },
1310 },
1311 "player_dsp": {"spb_esp32": {"enabled": True}, "spb_kitchen": {"enabled": True}},
1312 }
1313
1314
1315def test_migrate_orphaned_disabled_protocol_configs() -> None:
1316 """A disabled protocol player without a parent config is dropped, others are kept."""
1317 data = _orphaned_protocol_data()
1318 assert _migrate_orphaned_disabled_protocol_configs(data) is True
1319 assert "spb_esp32" not in data["players"]
1320 assert "spb_esp32" not in data["player_dsp"]
1321 assert "ap_ghost" not in data["players"]
1322 # protocol players that are still owned by a player are left alone
1323 assert "spb_kitchen" in data["players"]
1324 assert "spb_kitchen" in data["player_dsp"]
1325 assert "ap_office" in data["players"]
1326 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1327
1328
1329def test_migrate_orphaned_disabled_protocol_configs_keeps_enabled_players() -> None:
1330 """An enabled protocol player is kept: it can register and find a new parent."""
1331 data = _orphaned_protocol_data()
1332 data["players"]["spb_esp32"]["enabled"] = True
1333 data["players"]["ap_ghost"]["enabled"] = True
1334 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1335 assert "spb_esp32" in data["players"]
1336
1337
1338def test_migrate_orphaned_disabled_protocol_configs_tolerates_malformed_data() -> None:
1339 """Non-dict player configs/values and non-protocol players are skipped."""
1340 data: dict[str, Any] = {
1341 "players": {
1342 "p1": "not-a-dict",
1343 "p2": {"player_id": "p2", "player_type": "player", "enabled": False, "values": None},
1344 "p3": {"player_id": "p3", "player_type": "player", "enabled": False, "values": {}},
1345 }
1346 }
1347 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1348 assert len(data["players"]) == 3
1349
1350
1351def test_migrate_unrenamed_player_names() -> None:
1352 """A stored name that merely repeats the default name is cleared, real renames stay."""
1353 data: dict[str, Any] = {
1354 "players": {
1355 "never_renamed": {"name": "Living Room", "default_name": "Living Room"},
1356 "user_renamed": {"name": "Bathroom", "default_name": "solarium-bath-sl"},
1357 # without a default name, clearing the name would leave no name at all
1358 "without_default_name": {"name": "Kitchen"},
1359 "not_a_dict": "malformed",
1360 }
1361 }
1362
1363 assert _migrate_unrenamed_player_names(data) is True
1364 assert data["players"]["never_renamed"]["name"] is None
1365 assert data["players"]["user_renamed"]["name"] == "Bathroom"
1366 assert data["players"]["without_default_name"]["name"] == "Kitchen"
1367 # the cleared name must not be picked up again on the next start
1368 assert _migrate_unrenamed_player_names(data) is False
1369