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