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