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