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