/
/
/
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_migrate_provider_setup_data_moves_spotify_credentials() -> None:
374 """
375 A spotify instance configured before setup flows gets its credentials into setup_data.
376
377 Regression: the provider reads these with get_setup_value, which falls back to the
378 declared config entries only. None of them are declared, so on an upgraded install
379 they all resolved to None and the provider failed to load.
380 """
381 data: dict[str, Any] = {
382 "providers": {
383 "spotify--abc": {
384 "domain": "spotify",
385 "values": {
386 "refresh_token_global": "global-token",
387 "refresh_token_dev": "dev-token",
388 "librespot_credentials": "playback-blob",
389 "client_id": "dev-client-id",
390 "account_id": "bob",
391 "refresh_token": "legacy-token",
392 "sync_podcast_progress": True,
393 },
394 }
395 }
396 }
397 assert migrate_provider_setup_data(data, _fake_encrypt) is True
398 cfg = data["providers"]["spotify--abc"]
399 setup_data = cfg["setup_data"]
400 assert setup_data["refresh_token_global"] == ENCRYPT_SUFFIX + "global-token"
401 assert setup_data["refresh_token_dev"] == ENCRYPT_SUFFIX + "dev-token"
402 assert setup_data["librespot_credentials"] == ENCRYPT_SUFFIX + "playback-blob"
403 assert setup_data["client_id"] == ENCRYPT_SUFFIX + "dev-client-id"
404 assert setup_data["account_id"] == ENCRYPT_SUFFIX + "bob"
405 assert cfg["values"] == {"refresh_token": "legacy-token", "sync_podcast_progress": True}
406
407
408def test_setup_flow_defaults_are_owned_keys() -> None:
409 """Every key with a fallback default is also a key the migration owns."""
410 for domain, defaults in PROVIDER_SETUP_FLOW_DEFAULTS.items():
411 owned = PROVIDER_SETUP_FLOW_KEYS[domain]
412 assert set(defaults).issubset(owned), f"{domain}: {set(defaults) - set(owned)}"
413
414
415def test_migrate_provider_setup_data_restores_dropped_defaults() -> None:
416 """
417 A plex instance left on the default port gets that port back.
418
419 Regression: a config value that matches its entry default is not persisted, so an
420 instance on port 32400 had nothing in values to migrate and its server URL became
421 'https://<host>:None'.
422 """
423 data: dict[str, Any] = {
424 "providers": {
425 "plex--abc": {
426 "domain": "plex",
427 "values": {"local_server_ip": "local.abc.plex.direct", "local_server_ssl": True},
428 }
429 }
430 }
431 assert migrate_provider_setup_data(data, _fake_encrypt) is True
432 setup_data = data["providers"]["plex--abc"]["setup_data"]
433 assert setup_data["local_server_port"] == 32400
434 assert setup_data["local_server_verify_cert"] is True
435 # the user's own value is migrated, not replaced by the default
436 assert setup_data["local_server_ssl"] is True
437
438
439def test_migrate_provider_setup_data_restores_defaults_after_earlier_run() -> None:
440 """An install whose values were already moved by an earlier run is still repaired."""
441 data: dict[str, Any] = {
442 "providers": {
443 "plex--abc": {
444 "domain": "plex",
445 "values": {},
446 "setup_data": {
447 "local_server_ip": ENCRYPT_SUFFIX + "local.abc.plex.direct",
448 "local_server_ssl": True,
449 },
450 }
451 }
452 }
453 assert migrate_provider_setup_data(data, _fake_encrypt) is True
454 assert data["providers"]["plex--abc"]["setup_data"]["local_server_port"] == 32400
455
456
457def test_migrate_provider_setup_data_keeps_explicit_values() -> None:
458 """A stored choice is never overwritten by a fallback default."""
459 data: dict[str, Any] = {
460 "providers": {
461 "jellyfin": {
462 "domain": "jellyfin",
463 "values": {},
464 "setup_data": {"verify_ssl": False},
465 }
466 }
467 }
468 assert migrate_provider_setup_data(data, _fake_encrypt) is False
469 assert data["providers"]["jellyfin"]["setup_data"]["verify_ssl"] is False
470
471
472def test_migrate_provider_setup_data_encrypts_restored_strings() -> None:
473 """A restored string default is encrypted at rest like a migrated one."""
474 data: dict[str, Any] = {
475 "providers": {
476 "filesystem_local": {"domain": "filesystem_local", "values": {}},
477 }
478 }
479 assert migrate_provider_setup_data(data, _fake_encrypt) is True
480 assert data["providers"]["filesystem_local"]["setup_data"]["path"] == ENCRYPT_SUFFIX + "/media"
481
482
483def test_migrate_provider_setup_data_restores_lastfm_network() -> None:
484 """
485 A Last.fm scrobbler that never stored its network choice gets it back.
486
487 Regression: `_provider` defaulted to 'lastfm' so it was never persisted, and the
488 None read back from setup_data made get_network raise on _NetworkType('None').
489 """
490 data: dict[str, Any] = {
491 "providers": {
492 "lastfm_scrobble": {
493 "domain": "lastfm_scrobble",
494 "values": {"_api_session_key": "abc123"},
495 }
496 }
497 }
498 assert migrate_provider_setup_data(data, _fake_encrypt) is True
499 setup_data = data["providers"]["lastfm_scrobble"]["setup_data"]
500 assert setup_data["_provider"] == ENCRYPT_SUFFIX + "lastfm"
501 # an explicit librefm choice differed from the default and was persisted, so it
502 # arrives via the move and is never overwritten by the fallback
503 data = {
504 "providers": {
505 "lastfm_scrobble": {
506 "domain": "lastfm_scrobble",
507 "values": {"_provider": "librefm"},
508 }
509 }
510 }
511 assert migrate_provider_setup_data(data, _fake_encrypt) is True
512 assert (
513 data["providers"]["lastfm_scrobble"]["setup_data"]["_provider"]
514 == ENCRYPT_SUFFIX + "librefm"
515 )
516
517
518def test_migrate_provider_setup_data_restores_smb_version() -> None:
519 """
520 An SMB share left on the default protocol version keeps its 3.0 pin.
521
522 Regression: the dropped default made both mount paths read '' and skip the
523 vers=3.0 mount option, silently changing protocol negotiation.
524 """
525 data: dict[str, Any] = {
526 "providers": {
527 "filesystem_smb": {"domain": "filesystem_smb", "values": {}},
528 }
529 }
530 assert migrate_provider_setup_data(data, _fake_encrypt) is True
531 setup_data = data["providers"]["filesystem_smb"]["setup_data"]
532 assert setup_data["smb_version"] == ENCRYPT_SUFFIX + "3.0"
533 # an explicit '' (auto-negotiate) choice was persisted and must win over the pin
534 data = {
535 "providers": {
536 "filesystem_smb": {"domain": "filesystem_smb", "values": {"smb_version": ""}},
537 }
538 }
539 assert migrate_provider_setup_data(data, _fake_encrypt) is True
540 assert data["providers"]["filesystem_smb"]["setup_data"]["smb_version"] == ENCRYPT_SUFFIX
541
542
543def test_migrate_receiver_and_connect_setup_values() -> None:
544 """New setup-flow fields move to setup_data without losing typed values."""
545 data: dict[str, Any] = {
546 "providers": {
547 "airplay_receiver--1": {
548 "domain": "airplay_receiver",
549 "values": {
550 "mass_player_id": "kitchen",
551 "airplay_name": "Kitchen AirPlay",
552 "log_level": "INFO",
553 },
554 },
555 "ariacast_receiver": {
556 "domain": "ariacast_receiver",
557 "values": {"mass_player_id": "office"},
558 },
559 "spotify_connect--1": {
560 "domain": "spotify_connect",
561 "values": {
562 "mass_player_id": "living-room",
563 "publish_name": "Living Room Spotify",
564 },
565 },
566 "vban_receiver--1": {
567 "domain": "vban_receiver",
568 "values": {
569 "bind_ip": "192.0.2.2",
570 "bind_port": 6981,
571 "sender_host": "192.0.2.10",
572 "vban_stream_name": "Studio",
573 "audio_format": "S16LE",
574 "sample_rate": 48000,
575 "audio_channels": 2,
576 "vban_queue_size": 100,
577 },
578 },
579 "yandex_ynison--1": {
580 "domain": "yandex_ynison",
581 "values": {
582 "mass_player_id": "office",
583 "publish_name": "Office Yandex",
584 "allow_player_switch": False,
585 },
586 },
587 }
588 }
589
590 assert migrate_provider_setup_data(data, _fake_encrypt) is True
591
592 providers = data["providers"]
593 assert providers["airplay_receiver--1"]["values"] == {"log_level": "INFO"}
594 assert providers["airplay_receiver--1"]["setup_data"] == {
595 "mass_player_id": ENCRYPT_SUFFIX + "kitchen",
596 "airplay_name": ENCRYPT_SUFFIX + "Kitchen AirPlay",
597 }
598 assert providers["ariacast_receiver"]["values"] == {}
599 assert providers["ariacast_receiver"]["setup_data"] == {
600 "mass_player_id": ENCRYPT_SUFFIX + "office"
601 }
602 assert providers["spotify_connect--1"]["values"] == {}
603 assert providers["spotify_connect--1"]["setup_data"] == {
604 "mass_player_id": ENCRYPT_SUFFIX + "living-room",
605 "publish_name": ENCRYPT_SUFFIX + "Living Room Spotify",
606 }
607 assert providers["vban_receiver--1"]["values"] == {"vban_queue_size": 100}
608 assert providers["vban_receiver--1"]["setup_data"] == {
609 "bind_ip": ENCRYPT_SUFFIX + "192.0.2.2",
610 "bind_port": 6981,
611 "sender_host": ENCRYPT_SUFFIX + "192.0.2.10",
612 "vban_stream_name": ENCRYPT_SUFFIX + "Studio",
613 "audio_format": ENCRYPT_SUFFIX + "S16LE",
614 "sample_rate": 48000,
615 "audio_channels": 2,
616 }
617 assert providers["yandex_ynison--1"]["values"] == {"allow_player_switch": False}
618 assert providers["yandex_ynison--1"]["setup_data"] == {
619 "mass_player_id": ENCRYPT_SUFFIX + "office",
620 "publish_name": ENCRYPT_SUFFIX + "Office Yandex",
621 }
622
623
624def test_migrate_provider_setup_data_idempotent_and_preserves_existing(
625 monkeypatch: pytest.MonkeyPatch,
626) -> None:
627 """Already-encrypted values move unchanged, existing setup_data wins, second run is a no-op."""
628 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("token", "secret"))
629 data: dict[str, Any] = {
630 "providers": {
631 "demo": {
632 "domain": "demo",
633 "values": {"token": ENCRYPT_SUFFIX + "abc", "secret": "raw"},
634 # a value already collected into setup_data must not be clobbered
635 "setup_data": {"secret": ENCRYPT_SUFFIX + "kept"},
636 }
637 }
638 }
639 assert migrate_provider_setup_data(data, _fake_encrypt) is True
640 cfg = data["providers"]["demo"]
641 # an already-encrypted value is moved without re-encrypting (no double prefix)
642 assert cfg["setup_data"]["token"] == ENCRYPT_SUFFIX + "abc"
643 # the pre-existing setup_data value survives; the stale values copy is dropped
644 assert cfg["setup_data"]["secret"] == ENCRYPT_SUFFIX + "kept"
645 assert cfg["values"] == {}
646 # a second pass finds nothing left to move
647 assert migrate_provider_setup_data(data, _fake_encrypt) is False
648
649
650def test_migrate_provider_setup_data_multi_instance_and_scope(
651 monkeypatch: pytest.MonkeyPatch,
652) -> None:
653 """All instances of a mapped domain migrate; unmapped domains are left untouched."""
654 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("host",))
655 data: dict[str, Any] = {
656 "providers": {
657 "demo--a": {"domain": "demo", "values": {"host": "h1"}},
658 "demo--b": {"domain": "demo", "values": {"host": "h2", "quality": "x"}},
659 "other": {"domain": "other", "values": {"host": "keep"}},
660 }
661 }
662 assert migrate_provider_setup_data(data, _fake_encrypt) is True
663 prov = data["providers"]
664 assert prov["demo--a"]["setup_data"]["host"] == ENCRYPT_SUFFIX + "h1"
665 assert prov["demo--a"]["values"] == {}
666 assert prov["demo--b"]["setup_data"]["host"] == ENCRYPT_SUFFIX + "h2"
667 assert prov["demo--b"]["values"] == {"quality": "x"}
668 # a provider whose domain is not in the map is never touched
669 assert prov["other"]["values"] == {"host": "keep"}
670 assert "setup_data" not in prov["other"]
671
672
673def test_migrate_provider_setup_data_noop() -> None:
674 """Missing or empty provider config store reports no change."""
675 assert migrate_provider_setup_data({}, _fake_encrypt) is False
676 assert migrate_provider_setup_data({"providers": {}}, _fake_encrypt) is False
677
678
679def test_migrate_provider_setup_data_real_domain_opensubsonic() -> None:
680 """A real mapped domain moves its setup keys (incl. the redefined baseURL literal)."""
681 data: dict[str, Any] = {
682 "providers": {
683 "opensubsonic--x": {
684 "domain": "opensubsonic",
685 "values": {
686 "username": "alice",
687 "password": "pw",
688 "baseURL": "https://music.example",
689 "port": 4533,
690 "enable_podcasts": True,
691 },
692 }
693 }
694 }
695 assert migrate_provider_setup_data(data, _fake_encrypt) is True
696 cfg = data["providers"]["opensubsonic--x"]
697 assert cfg["setup_data"]["username"] == ENCRYPT_SUFFIX + "alice"
698 assert cfg["setup_data"]["baseURL"] == ENCRYPT_SUFFIX + "https://music.example"
699 assert cfg["setup_data"]["port"] == 4533
700 # a genuine provider option is not part of the setup-flow key set and stays put
701 assert cfg["values"] == {"enable_podcasts": True}
702
703
704def test_migrate_player_setup_data_moves_credentials() -> None:
705 """Player-owned credential/pairing keys move from values into setup_data."""
706 data: dict[str, Any] = {
707 "players": {
708 "ap1": {
709 "player_id": "ap1",
710 "provider": "airplay",
711 "values": {
712 "airplay_credentials": "ENC_ap2creds",
713 "companion_credentials": "ENC_companion",
714 "ap2password": "ENC_dead",
715 "password": "ENC_devpw",
716 "ignore_volume": True,
717 },
718 },
719 "fk1": {
720 "player_id": "fk1",
721 "provider": "fully_kiosk",
722 "values": {"password": "ENC_fk", "use_ssl": True},
723 },
724 "mpd1": {"player_id": "mpd1", "provider": "mpd", "values": {}},
725 "sonos1": {
726 "player_id": "sonos1",
727 "provider": "sonos",
728 "values": {"password": "keepme"},
729 },
730 }
731 }
732 assert _migrate_player_setup_data(data) is True
733 ap1 = data["players"]["ap1"]
734 # credentials moved to setup_data (already-encrypted, moved as-is)
735 assert ap1["setup_data"] == {
736 "airplay_credentials": "ENC_ap2creds",
737 "companion_credentials": "ENC_companion",
738 }
739 # the RAOP device password (a genuine user option) and other options stay in values;
740 # the vestigial ap2password is dropped entirely
741 assert ap1["values"] == {"password": "ENC_devpw", "ignore_volume": True}
742 # fully_kiosk password moves, unrelated option stays
743 assert data["players"]["fk1"]["setup_data"] == {"password": "ENC_fk"}
744 assert data["players"]["fk1"]["values"] == {"use_ssl": True}
745 # a provider not in the map is untouched
746 assert data["players"]["sonos1"]["values"] == {"password": "keepme"}
747 assert "setup_data" not in data["players"]["sonos1"]
748 # idempotent second run
749 assert _migrate_player_setup_data(data) is False
750
751
752def test_migrate_player_setup_data_preserves_existing_and_drops_null() -> None:
753 """An existing setup_data value is never clobbered and stored nulls are dropped."""
754 data: dict[str, Any] = {
755 "players": {
756 "ap1": {
757 "player_id": "ap1",
758 "provider": "airplay",
759 "setup_data": {"airplay_credentials": "ENC_existing"},
760 "values": {"airplay_credentials": "ENC_stale", "raop_credentials": None},
761 }
762 }
763 }
764 assert _migrate_player_setup_data(data) is True
765 ap1 = data["players"]["ap1"]
766 # the pre-existing setup_data value wins; the null raop value is just dropped
767 assert ap1["setup_data"] == {"airplay_credentials": "ENC_existing"}
768 assert ap1["values"] == {}
769
770
771def test_migrate_player_setup_data_multi_instance_domain() -> None:
772 """Domain matching handles multi-instance provider ids (<domain>--<id>)."""
773 data: dict[str, Any] = {
774 "players": {
775 "ap1": {
776 "player_id": "ap1",
777 "provider": "airplay--2",
778 "values": {"raop_credentials": "ENC_raop"},
779 }
780 }
781 }
782 assert _migrate_player_setup_data(data) is True
783 assert data["players"]["ap1"]["setup_data"] == {"raop_credentials": "ENC_raop"}
784 assert data["players"]["ap1"]["values"] == {}
785
786
787def test_migrate_bose_soundtouch_presets_drops_player_values() -> None:
788 """The per-player preset mappings are removed, other player values are kept."""
789 data: dict[str, Any] = {
790 "players": {
791 "bose_soundtouch_a": {
792 "player_id": "bose_soundtouch_a",
793 "provider": "bose_soundtouch",
794 "values": {
795 "preset_1_media": "library://playlist/12",
796 "preset_1_media_type": "playlist",
797 "preset_1_search": "morning",
798 "preset_1_selected_media": "library://playlist/12",
799 "volume_normalization": True,
800 },
801 },
802 "bose_soundtouch_b": {
803 "player_id": "bose_soundtouch_b",
804 "provider": "bose_soundtouch--2",
805 "values": {"preset_6_media": "library://radio/3"},
806 },
807 }
808 }
809 assert _migrate_bose_soundtouch_presets(data) is True
810 assert data["players"]["bose_soundtouch_a"]["values"] == {"volume_normalization": True}
811 assert data["players"]["bose_soundtouch_b"]["values"] == {}
812
813
814def test_migrate_bose_soundtouch_presets_scoped_to_provider() -> None:
815 """Players of other providers keep any similarly named values."""
816 data: dict[str, Any] = {
817 "players": {
818 "other": {
819 "player_id": "other",
820 "provider": "sonos",
821 "values": {"preset_1_media": "library://playlist/12"},
822 }
823 }
824 }
825 assert _migrate_bose_soundtouch_presets(data) is False
826 assert data["players"]["other"]["values"] == {"preset_1_media": "library://playlist/12"}
827
828
829def test_migrate_bose_soundtouch_presets_noop_when_absent() -> None:
830 """Migration reports no change when no SoundTouch player stored presets."""
831 data: dict[str, Any] = {
832 "players": {
833 "bose_soundtouch_a": {
834 "player_id": "bose_soundtouch_a",
835 "provider": "bose_soundtouch",
836 "values": {"volume_normalization": True},
837 }
838 }
839 }
840 assert _migrate_bose_soundtouch_presets(data) is False
841
842
843def test_migrate_nfs_subfolder_folds_into_export_path() -> None:
844 """The subfolder is appended to the export path and the now-obsolete key is dropped."""
845 data: dict[str, Any] = {
846 "providers": {
847 "filesystem_nfs--1": {
848 "domain": "filesystem_nfs",
849 "setup_data": {
850 "host": ENCRYPT_SUFFIX + "nas.local",
851 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
852 "subfolder": ENCRYPT_SUFFIX + "Music",
853 "nfs_version": ENCRYPT_SUFFIX + "3",
854 },
855 "values": {"sync_tracks": True},
856 }
857 }
858 }
859
860 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
861
862 setup_data = data["providers"]["filesystem_nfs--1"]["setup_data"]
863 assert setup_data["export_path"] == ENCRYPT_SUFFIX + "/mnt/vault/Music"
864 assert "subfolder" not in setup_data
865 assert setup_data["host"] == ENCRYPT_SUFFIX + "nas.local"
866 assert setup_data["nfs_version"] == ENCRYPT_SUFFIX + "3"
867 assert data["providers"]["filesystem_nfs--1"]["values"] == {"sync_tracks": True}
868 # the marker stops a second pass
869 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
870 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is False
871 assert setup_data["export_path"] == ENCRYPT_SUFFIX + "/mnt/vault/Music"
872
873
874def test_migrate_nfs_subfolder_normalizes_leading_slash_and_nesting() -> None:
875 """A leading slash is tolerated and a nested subfolder keeps its full depth."""
876 data: dict[str, Any] = {
877 "providers": {
878 "rooted": {
879 "domain": "filesystem_nfs",
880 "setup_data": {
881 "export_path": ENCRYPT_SUFFIX + "/volume1",
882 "subfolder": ENCRYPT_SUFFIX + "/music",
883 },
884 },
885 "nested": {
886 "domain": "filesystem_nfs",
887 "setup_data": {
888 "export_path": ENCRYPT_SUFFIX + "/exports/media/",
889 "subfolder": ENCRYPT_SUFFIX + " albums/A-K ",
890 },
891 },
892 }
893 }
894
895 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
896
897 providers = data["providers"]
898 assert providers["rooted"]["setup_data"]["export_path"] == ENCRYPT_SUFFIX + "/volume1/music"
899 assert (
900 providers["nested"]["setup_data"]["export_path"]
901 == ENCRYPT_SUFFIX + "/exports/media/albums/A-K"
902 )
903
904
905def test_migrate_nfs_subfolder_noop_cases() -> None:
906 """Instances without a usable subfolder, and other providers, are left untouched."""
907 data: dict[str, Any] = {
908 "providers": {
909 "empty_subfolder": {
910 "domain": "filesystem_nfs",
911 "setup_data": {
912 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
913 "subfolder": ENCRYPT_SUFFIX + "",
914 },
915 },
916 "no_subfolder_key": {
917 "domain": "filesystem_nfs",
918 "setup_data": {"export_path": ENCRYPT_SUFFIX + "/mnt/vault"},
919 },
920 "no_export_path": {
921 "domain": "filesystem_nfs",
922 "setup_data": {"subfolder": ENCRYPT_SUFFIX + "Music"},
923 },
924 "smb": {
925 "domain": "filesystem_smb",
926 "setup_data": {
927 "share": ENCRYPT_SUFFIX + "media",
928 "subfolder": ENCRYPT_SUFFIX + "Music",
929 },
930 },
931 }
932 }
933 providers_before = deepcopy(data["providers"])
934
935 # only the one-shot marker is written; no provider config is touched
936 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
937 assert data["providers"] == providers_before
938 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
939
940
941def test_migrate_nfs_subfolder_never_folds_a_post_fix_config() -> None:
942 """A subfolder stored after the marker is set is never folded, however often this runs."""
943 data: dict[str, Any] = {
944 CONF_NFS_SUBFOLDER_MIGRATED: True,
945 "providers": {
946 "filesystem_nfs--1": {
947 "domain": "filesystem_nfs",
948 "setup_data": {
949 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
950 "subfolder": ENCRYPT_SUFFIX + "Music",
951 },
952 }
953 },
954 }
955 before = deepcopy(data)
956
957 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is False
958 assert data == before
959
960
961def test_migrate_nfs_subfolder_handles_plaintext_values() -> None:
962 """A value that was never encrypted at rest folds just the same."""
963 data: dict[str, Any] = {
964 "providers": {
965 "filesystem_nfs--1": {
966 "domain": "filesystem_nfs",
967 "setup_data": {"export_path": "/mnt/vault", "subfolder": "Music"},
968 }
969 }
970 }
971
972 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
973 assert (
974 data["providers"]["filesystem_nfs--1"]["setup_data"]["export_path"]
975 == ENCRYPT_SUFFIX + "/mnt/vault/Music"
976 )
977
978
979def test_migrate_nfs_subfolder_skips_an_undecryptable_instance() -> None:
980 """An unreadable value costs that instance its migration, not the server its startup."""
981
982 def _failing_decrypt(_value: str) -> str:
983 raise InvalidDataError("Password decryption failed")
984
985 data: dict[str, Any] = {
986 "providers": {
987 "broken": {
988 "domain": "filesystem_nfs",
989 "setup_data": {
990 "export_path": ENCRYPT_SUFFIX + "unreadable",
991 "subfolder": ENCRYPT_SUFFIX + "unreadable",
992 },
993 }
994 }
995 }
996
997 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _failing_decrypt) is True
998
999 # left as it was, but the marker is still claimed
1000 assert data["providers"]["broken"]["setup_data"] == {
1001 "export_path": ENCRYPT_SUFFIX + "unreadable",
1002 "subfolder": ENCRYPT_SUFFIX + "unreadable",
1003 }
1004 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
1005
1006
1007def test_migrate_nfs_subfolder_round_trips_through_real_encryption(tmp_path: Path) -> None:
1008 """The folded export path is re-encrypted with the live key, so a migrated config loads."""
1009 mass = SimpleNamespace(storage_path=str(tmp_path))
1010 controller = ConfigController(mass) # type: ignore[arg-type]
1011 controller.initialized = True
1012 controller.save = lambda **_kwargs: None # type: ignore[method-assign]
1013 controller.set(CONF_SERVER_ID, uuid4().hex)
1014 controller._init_encryption()
1015
1016 data: dict[str, Any] = {
1017 "providers": {
1018 "filesystem_nfs--1": {
1019 "domain": "filesystem_nfs",
1020 "setup_data": {
1021 "export_path": controller.encrypt_string("/mnt/vault"),
1022 "subfolder": controller.encrypt_string("Music"),
1023 },
1024 }
1025 }
1026 }
1027
1028 assert (
1029 migrate_nfs_subfolder_into_export_path(
1030 data, controller.encrypt_string, controller.decrypt_string
1031 )
1032 is True
1033 )
1034
1035 stored = data["providers"]["filesystem_nfs--1"]["setup_data"]["export_path"]
1036 assert stored.startswith(ENCRYPT_SUFFIX)
1037 assert controller.decrypt_string(stored) == "/mnt/vault/Music"
1038
1039
1040def _connected_plugins_data() -> dict[str, Any]:
1041 """Build a config store with legacy per-instance spotify_connect configurations."""
1042 return {
1043 "providers": {
1044 "spotify_connect--a": {
1045 "domain": "spotify_connect",
1046 "instance_id": "spotify_connect--a",
1047 "values": {"log_level": "DEBUG"},
1048 "setup_data": {
1049 "backend": ENCRYPT_SUFFIX + "go_librespot",
1050 "mass_player_id": ENCRYPT_SUFFIX + "kitchen",
1051 "publish_name": ENCRYPT_SUFFIX + "Kitchen Spotify",
1052 },
1053 },
1054 "spotify_connect--b": {
1055 "domain": "spotify_connect",
1056 "instance_id": "spotify_connect--b",
1057 "values": {},
1058 "setup_data": {
1059 "backend": ENCRYPT_SUFFIX + "soloist",
1060 "soloist_api_key": ENCRYPT_SUFFIX + "secret-key",
1061 "soloist_download_consent": True,
1062 "mass_player_id": ENCRYPT_SUFFIX + "living-room",
1063 "publish_name": ENCRYPT_SUFFIX + "Living Room Spotify",
1064 },
1065 },
1066 },
1067 "players": {
1068 "kitchen": {"player_id": "kitchen"},
1069 "living-room": {"player_id": "living-room"},
1070 },
1071 }
1072
1073
1074def test_migrate_connected_player_plugins_prefers_soloist_survivor(tmp_path: Path) -> None:
1075 """The soloist instance survives the collapse under the domain key, secrets intact."""
1076 data = _connected_plugins_data()
1077
1078 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1079
1080 providers = data["providers"]
1081 assert set(providers) == {"spotify_connect"}
1082 survivor = providers["spotify_connect"]
1083 assert survivor["instance_id"] == "spotify_connect"
1084 # explicit players carried over in stored order, legacy keys dropped, secrets kept
1085 assert survivor["values"]["connected_players"] == ["kitchen", "living-room"]
1086 assert survivor["setup_data"] == {
1087 "backend": ENCRYPT_SUFFIX + "soloist",
1088 "soloist_api_key": ENCRYPT_SUFFIX + "secret-key",
1089 "soloist_download_consent": True,
1090 }
1091 # a second run finds the marker and changes nothing
1092 before = deepcopy(data)
1093 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is False
1094 assert data == before
1095
1096
1097def test_migrate_connected_player_plugins_skips_bare_domain_instance(tmp_path: Path) -> None:
1098 """
1099 A single instance keyed by the bare domain is never re-collapsed.
1100
1101 The connected_players marker is dropped by the config store when it equals the
1102 entry default (empty selection), so the bare-domain id is the durable signal.
1103 """
1104 data: dict[str, Any] = {
1105 "providers": {
1106 "spotify_connect": {
1107 "domain": "spotify_connect",
1108 "instance_id": "spotify_connect",
1109 "values": {},
1110 "setup_data": {"backend": ENCRYPT_SUFFIX + "go_librespot"},
1111 }
1112 },
1113 "players": {},
1114 }
1115 before = deepcopy(data)
1116
1117 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is False
1118 assert data == before
1119
1120
1121def test_migrate_connected_player_plugins_ignores_disabled_instances(tmp_path: Path) -> None:
1122 """A disabled instance neither survives the collapse nor contributes its player."""
1123 data = _connected_plugins_data()
1124 # the soloist instance was disabled by the user: the enabled go-librespot one
1125 # must survive and the disabled instance's player must not start advertising
1126 data["providers"]["spotify_connect--b"]["enabled"] = False
1127
1128 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1129
1130 survivor = data["providers"]["spotify_connect"]
1131 assert survivor["setup_data"]["backend"] == ENCRYPT_SUFFIX + "go_librespot"
1132 assert survivor.get("enabled", True) is True
1133 assert survivor["values"]["connected_players"] == ["kitchen"]
1134
1135
1136def test_migrate_connected_player_plugins_all_disabled_stays_disabled(tmp_path: Path) -> None:
1137 """When every instance is disabled the collapsed provider stays disabled."""
1138 data = _connected_plugins_data()
1139 data["providers"]["spotify_connect--a"]["enabled"] = False
1140 data["providers"]["spotify_connect--b"]["enabled"] = False
1141
1142 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1143
1144 survivor = data["providers"]["spotify_connect"]
1145 assert survivor["enabled"] is False
1146 # soloist preference still applies within the disabled pool
1147 assert survivor["setup_data"]["backend"] == ENCRYPT_SUFFIX + "soloist"
1148 assert survivor["values"]["connected_players"] == []
1149
1150
1151def test_migrate_connected_player_plugins_drops_auto_and_unknown_players(
1152 tmp_path: Path,
1153) -> None:
1154 """The removed automatic selection and vanished players contribute nothing."""
1155 data: dict[str, Any] = {
1156 "providers": {
1157 "airplay_receiver--1": {
1158 "domain": "airplay_receiver",
1159 "instance_id": "airplay_receiver--1",
1160 "setup_data": {"mass_player_id": ENCRYPT_SUFFIX + "__auto__"},
1161 },
1162 "airplay_receiver--2": {
1163 "domain": "airplay_receiver",
1164 "instance_id": "airplay_receiver--2",
1165 "setup_data": {
1166 "mass_player_id": ENCRYPT_SUFFIX + "vanished",
1167 "airplay_name": ENCRYPT_SUFFIX + "Garage [AirPlay]",
1168 },
1169 },
1170 "airplay_receiver--3": {
1171 "domain": "airplay_receiver",
1172 "instance_id": "airplay_receiver--3",
1173 "setup_data": {"mass_player_id": ENCRYPT_SUFFIX + "office"},
1174 },
1175 },
1176 "players": {"office": {"player_id": "office"}},
1177 }
1178
1179 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1180
1181 providers = data["providers"]
1182 assert set(providers) == {"airplay_receiver"}
1183 survivor = providers["airplay_receiver"]
1184 # the first instance survives; only the still-existing explicit player carries over
1185 assert survivor["instance_id"] == "airplay_receiver"
1186 assert survivor["values"]["connected_players"] == ["office"]
1187 assert survivor["setup_data"] == {}
1188
1189
1190def test_migrate_connected_player_plugins_empty_selection_still_marks(tmp_path: Path) -> None:
1191 """With nothing to carry over the marker is still stored (empty selection)."""
1192 data: dict[str, Any] = {
1193 "providers": {
1194 "spotify_connect--1": {
1195 "domain": "spotify_connect",
1196 "instance_id": "spotify_connect--1",
1197 "setup_data": {"mass_player_id": ENCRYPT_SUFFIX + "__auto__"},
1198 }
1199 },
1200 "players": {},
1201 }
1202
1203 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1204 survivor = data["providers"]["spotify_connect"]
1205 assert survivor["values"]["connected_players"] == []
1206 assert survivor["setup_data"] == {}
1207 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is False
1208
1209
1210def test_migrate_connected_player_plugins_skips_undecryptable_instance(
1211 tmp_path: Path,
1212) -> None:
1213 """An unreadable instance contributes no player but never fails the migration."""
1214
1215 def _failing_decrypt(value: str) -> str:
1216 if "broken" in value:
1217 raise InvalidDataError("Password decryption failed")
1218 return value.removeprefix(ENCRYPT_SUFFIX)
1219
1220 data: dict[str, Any] = {
1221 "providers": {
1222 "spotify_connect--a": {
1223 "domain": "spotify_connect",
1224 "instance_id": "spotify_connect--a",
1225 "setup_data": {"mass_player_id": ENCRYPT_SUFFIX + "broken"},
1226 },
1227 "spotify_connect--b": {
1228 "domain": "spotify_connect",
1229 "instance_id": "spotify_connect--b",
1230 "setup_data": {"mass_player_id": ENCRYPT_SUFFIX + "kitchen"},
1231 },
1232 },
1233 "players": {"kitchen": {"player_id": "kitchen"}, "broken": {"player_id": "broken"}},
1234 }
1235
1236 assert migrate_connected_player_plugins(data, _failing_decrypt, str(tmp_path)) is True
1237 survivor = data["providers"]["spotify_connect"]
1238 assert survivor["values"]["connected_players"] == ["kitchen"]
1239
1240
1241def test_migrate_connected_player_plugins_moves_soloist_data(tmp_path: Path) -> None:
1242 """The soloist data dir of an instance whose player survived moves to its new home."""
1243 data = _connected_plugins_data()
1244 old_dir = tmp_path / "spotify_connect" / "spotify_connect--b" / "soloist-data"
1245 old_dir.mkdir(parents=True)
1246 (old_dir / "credentials.json").write_text("{}")
1247 # the go-librespot instance's dir must stay where it is
1248 other_dir = tmp_path / "spotify_connect" / "spotify_connect--a" / "soloist-data"
1249 other_dir.mkdir(parents=True)
1250
1251 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1252
1253 new_dir = tmp_path / "spotify_connect" / "spotify_connect_living-room" / "soloist-data"
1254 assert (new_dir / "credentials.json").is_file()
1255 assert not old_dir.exists()
1256 assert other_dir.is_dir()
1257
1258
1259def test_migrate_connected_player_plugins_sanitizes_player_id_in_data_path(
1260 tmp_path: Path,
1261) -> None:
1262 """Unsafe characters in the player id are sanitized in the soloist data dir name."""
1263 data: dict[str, Any] = {
1264 "providers": {
1265 "spotify_connect--a": {
1266 "domain": "spotify_connect",
1267 "instance_id": "spotify_connect--a",
1268 "setup_data": {
1269 "backend": ENCRYPT_SUFFIX + "soloist",
1270 "mass_player_id": ENCRYPT_SUFFIX + "media_player.living room",
1271 },
1272 }
1273 },
1274 "players": {"media_player.living room": {}},
1275 }
1276 old_dir = tmp_path / "spotify_connect" / "spotify_connect--a" / "soloist-data"
1277 old_dir.mkdir(parents=True)
1278
1279 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1280 new_dir = (
1281 tmp_path / "spotify_connect" / "spotify_connect_media_player.living_room" / "soloist-data"
1282 )
1283 assert new_dir.is_dir()
1284
1285
1286def test_migrate_connected_player_plugins_mandatory_player_cleanup(tmp_path: Path) -> None:
1287 """AriaCast/Ynison auto or vanished players are cleared, valid ones kept, names dropped."""
1288 data: dict[str, Any] = {
1289 "providers": {
1290 "ariacast_receiver": {
1291 "domain": "ariacast_receiver",
1292 "instance_id": "ariacast_receiver",
1293 "setup_data": {
1294 "mass_player_id": ENCRYPT_SUFFIX + "__auto__",
1295 "ariacast_name": ENCRYPT_SUFFIX + "Garage Cast",
1296 },
1297 },
1298 "yandex_ynison--1": {
1299 "domain": "yandex_ynison",
1300 "instance_id": "yandex_ynison--1",
1301 "setup_data": {
1302 "mass_player_id": ENCRYPT_SUFFIX + "vanished",
1303 "publish_name": ENCRYPT_SUFFIX + "Office Yandex",
1304 "token": ENCRYPT_SUFFIX + "music-token",
1305 },
1306 },
1307 "yandex_ynison--2": {
1308 "domain": "yandex_ynison",
1309 "instance_id": "yandex_ynison--2",
1310 "setup_data": {"mass_player_id": ENCRYPT_SUFFIX + "office"},
1311 },
1312 },
1313 "players": {"office": {"player_id": "office"}},
1314 }
1315
1316 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is True
1317
1318 providers = data["providers"]
1319 assert providers["ariacast_receiver"]["setup_data"] == {}
1320 # the vanished player is cleared, the unrelated auth values stay untouched
1321 assert providers["yandex_ynison--1"]["setup_data"] == {"token": ENCRYPT_SUFFIX + "music-token"}
1322 assert providers["yandex_ynison--2"]["setup_data"] == {
1323 "mass_player_id": ENCRYPT_SUFFIX + "office"
1324 }
1325 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is False
1326
1327
1328def test_migrate_connected_player_plugins_noop(tmp_path: Path) -> None:
1329 """Missing or empty provider config store reports no change."""
1330 assert migrate_connected_player_plugins({}, _fake_decrypt, str(tmp_path)) is False
1331 data: dict[str, Any] = {"providers": {}}
1332 assert migrate_connected_player_plugins(data, _fake_decrypt, str(tmp_path)) is False
1333
1334
1335def test_airplay_ghost_cleanup_skips_collapsed_instance() -> None:
1336 """The collapsed receiver instance is never treated as a legacy default-named one."""
1337 data: dict[str, Any] = {
1338 "providers": {
1339 "airplay_receiver": {
1340 "domain": "airplay_receiver",
1341 "instance_id": "airplay_receiver",
1342 "values": {"connected_players": []},
1343 "setup_data": {},
1344 }
1345 },
1346 "players": {
1347 "aplegitimate": {
1348 "player_id": "aplegitimate",
1349 "provider": "airplay",
1350 "default_name": "Music Assistant",
1351 "values": {},
1352 }
1353 },
1354 }
1355
1356 assert _migrate_airplay_receiver_ghost_players(data) is False
1357 assert "aplegitimate" in data["players"]
1358
1359
1360def _hass_engine_data(instance_id: str = "hass", **hass_values: Any) -> dict[str, Any]:
1361 """Build a config store with a hass provider and all three engine consumers."""
1362 return {
1363 "providers": {
1364 instance_id: {
1365 "domain": "hass",
1366 "instance_id": instance_id,
1367 "values": {"verify_ssl": True, **hass_values},
1368 },
1369 "ai_radio": {"domain": "ai_radio", "instance_id": "ai_radio", "values": {}},
1370 "music_quiz": {"domain": "music_quiz", "instance_id": "music_quiz", "values": {}},
1371 "smart_playlist": {
1372 "domain": "smart_playlist",
1373 "instance_id": "smart_playlist",
1374 "values": {},
1375 },
1376 }
1377 }
1378
1379
1380def test_migrate_hass_engine_selection_fans_out_to_all_consumers() -> None:
1381 """Both stored entity ids become engine uids on every consumer, encrypted for ai_radio."""
1382 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
1383 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1384 prov = data["providers"]
1385 assert prov["ai_radio"]["setup_data"] == {
1386 "ai_engine": ENCRYPT_SUFFIX + "hass/ai_task.google",
1387 "tts_engine": ENCRYPT_SUFFIX + "hass/tts.piper",
1388 }
1389 # the plain providers store their pick unencrypted in values
1390 assert prov["music_quiz"]["values"] == {"ai_engine": "hass/ai_task.google"}
1391 assert prov["smart_playlist"]["values"] == {"ai_engine": "hass/ai_task.google"}
1392 # the dead keys are consumed, the hass config is otherwise untouched
1393 assert prov["hass"]["values"] == {"verify_ssl": True}
1394 # a second pass finds nothing left to migrate
1395 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1396
1397
1398def test_migrate_hass_engine_selection_uses_own_instance_id() -> None:
1399 """The engine uid is built from the hass config's own instance id, not its domain."""
1400 data = _hass_engine_data("hass--abc", ai_task_entity="ai_task.google")
1401 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1402 assert data["providers"]["music_quiz"]["values"] == {"ai_engine": "hass--abc/ai_task.google"}
1403
1404
1405def test_migrate_hass_engine_selection_migrates_keys_independently() -> None:
1406 """A single stored key migrates on its own; the other selection stays unset."""
1407 data = _hass_engine_data(tts_entity="tts.piper")
1408 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1409 prov = data["providers"]
1410 assert prov["ai_radio"]["setup_data"] == {"tts_engine": ENCRYPT_SUFFIX + "hass/tts.piper"}
1411 assert prov["music_quiz"]["values"] == {}
1412 assert prov["smart_playlist"]["values"] == {}
1413 assert prov["hass"]["values"] == {"verify_ssl": True}
1414
1415
1416def test_migrate_hass_engine_selection_without_consumers() -> None:
1417 """The dead hass keys are dropped even when no consuming provider is installed."""
1418 data: dict[str, Any] = {
1419 "providers": {
1420 "hass": {
1421 "domain": "hass",
1422 "instance_id": "hass",
1423 "values": {"tts_entity": "tts.piper", "ai_task_entity": "ai_task.google"},
1424 },
1425 "music_quiz": {"domain": "music_quiz", "instance_id": "music_quiz", "values": {}},
1426 }
1427 }
1428 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1429 assert data["providers"]["hass"]["values"] == {}
1430 assert data["providers"]["music_quiz"]["values"] == {"ai_engine": "hass/ai_task.google"}
1431 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1432
1433
1434def test_migrate_hass_engine_selection_preserves_existing_choice() -> None:
1435 """A selection the user already made on a consumer is never overwritten."""
1436 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
1437 prov = data["providers"]
1438 prov["music_quiz"]["values"]["ai_engine"] = "openai/gpt"
1439 prov["ai_radio"]["setup_data"] = {"tts_engine": ENCRYPT_SUFFIX + "elevenlabs/voice"}
1440 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
1441 assert prov["music_quiz"]["values"] == {"ai_engine": "openai/gpt"}
1442 assert prov["ai_radio"]["setup_data"] == {
1443 "ai_engine": ENCRYPT_SUFFIX + "hass/ai_task.google",
1444 "tts_engine": ENCRYPT_SUFFIX + "elevenlabs/voice",
1445 }
1446 # the consumer that had no choice yet still gets the migrated one
1447 assert prov["smart_playlist"]["values"] == {"ai_engine": "hass/ai_task.google"}
1448 assert prov["hass"]["values"] == {"verify_ssl": True}
1449
1450
1451def test_migrate_hass_engine_selection_noop_without_stored_values() -> None:
1452 """Nothing stored (or no hass provider at all) reports no change."""
1453 assert migrate_hass_engine_selection({}, _fake_encrypt) is False
1454 assert migrate_hass_engine_selection({"providers": {}}, _fake_encrypt) is False
1455 data = _hass_engine_data()
1456 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1457 assert "setup_data" not in data["providers"]["ai_radio"]
1458 assert data["providers"]["music_quiz"]["values"] == {}
1459
1460
1461def test_migrate_hass_engine_selection_skips_multiple_hass_configs() -> None:
1462 """With several hass configurations there is no correct winner, so nothing is touched."""
1463 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
1464 data["providers"]["hass--second"] = {
1465 "domain": "hass",
1466 "instance_id": "hass--second",
1467 "values": {"tts_entity": "tts.cloud"},
1468 }
1469 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1470 prov = data["providers"]
1471 assert prov["hass"]["values"]["tts_entity"] == "tts.piper"
1472 assert prov["hass"]["values"]["ai_task_entity"] == "ai_task.google"
1473 assert prov["music_quiz"]["values"] == {}
1474 assert "setup_data" not in prov["ai_radio"]
1475
1476
1477def test_migrate_player_icons_rewrites_legacy_values() -> None:
1478 """Legacy mdi-* and pre-1.0 picker icon values are rewritten to canonical ids."""
1479 data: dict[str, Any] = {
1480 "players": {
1481 "p1": {"player_id": "p1", "values": {"icon": "mdi-speaker-multiple"}},
1482 "p2": {"player_id": "p2", "values": {"icon": "sofa"}},
1483 "p3": {"player_id": "p3", "values": {"icon": "mdi-television-classic"}},
1484 }
1485 }
1486 assert _migrate_player_icons(data) is True
1487 assert data["players"]["p1"]["values"]["icon"] == "speakers"
1488 assert data["players"]["p2"]["values"]["icon"] == "living-room"
1489 assert data["players"]["p3"]["values"]["icon"] == "tv"
1490
1491
1492def test_migrate_player_icons_drops_unmappable_mdi_values() -> None:
1493 """An mdi-* icon with no close equivalent is dropped so the default applies."""
1494 data: dict[str, Any] = {
1495 "players": {
1496 "p1": {"player_id": "p1", "values": {"icon": "mdi-pac-man", "flow_mode": True}},
1497 }
1498 }
1499 assert _migrate_player_icons(data) is True
1500 assert data["players"]["p1"]["values"] == {"flow_mode": True}
1501
1502
1503def test_migrate_player_icons_keeps_canonical_and_unknown_ids() -> None:
1504 """Canonical ids are never touched; unknown non-mdi values are left in place."""
1505 data: dict[str, Any] = {
1506 "players": {
1507 "p1": {"player_id": "p1", "values": {"icon": "sonos"}},
1508 "p2": {"player_id": "p2", "values": {"icon": "kitchen"}},
1509 "p3": {"player_id": "p3", "values": {"icon": "dog"}},
1510 }
1511 }
1512 assert _migrate_player_icons(data) is False
1513 assert data["players"]["p1"]["values"]["icon"] == "sonos"
1514 assert data["players"]["p2"]["values"]["icon"] == "kitchen"
1515 assert data["players"]["p3"]["values"]["icon"] == "dog"
1516
1517
1518def test_migrate_player_icons_noop_when_absent() -> None:
1519 """Migration reports no change for players without a stored icon."""
1520 data: dict[str, Any] = {
1521 "players": {
1522 "p1": {"player_id": "p1", "values": {"flow_mode": True}},
1523 "p2": {"player_id": "p2"},
1524 }
1525 }
1526 assert _migrate_player_icons(data) is False
1527
1528
1529def test_migrate_player_icons_idempotent() -> None:
1530 """A second run over already-migrated data reports no change."""
1531 data: dict[str, Any] = {
1532 "players": {
1533 "p1": {"player_id": "p1", "values": {"icon": "mdi-speaker-multiple"}},
1534 "p2": {"player_id": "p2", "values": {"icon": "mdi-pac-man"}},
1535 }
1536 }
1537 assert _migrate_player_icons(data) is True
1538 assert _migrate_player_icons(data) is False
1539 assert data["players"]["p1"]["values"]["icon"] == "speakers"
1540 assert "icon" not in data["players"]["p2"]["values"]
1541
1542
1543def test_migrate_player_icons_tolerates_malformed_data() -> None:
1544 """Non-dict player configs/values and non-string icon values are skipped."""
1545 data: dict[str, Any] = {
1546 "players": {
1547 "p1": "not-a-dict",
1548 "p2": {"player_id": "p2", "values": "not-a-dict"},
1549 "p3": {"player_id": "p3", "values": {"icon": None}},
1550 "p4": {"player_id": "p4", "values": {"icon": 123}},
1551 }
1552 }
1553 assert _migrate_player_icons(data) is False
1554 assert data["players"]["p3"]["values"]["icon"] is None
1555 assert data["players"]["p4"]["values"]["icon"] == 123
1556
1557
1558def _orphaned_protocol_data() -> dict[str, Any]:
1559 """Build a config store with a disabled protocol player whose parent was removed."""
1560 return {
1561 "players": {
1562 "spb_esp32": {
1563 "player_id": "spb_esp32",
1564 "provider": "sendspin",
1565 "player_type": "protocol",
1566 "enabled": False,
1567 "values": {"protocol_parent_id": "up_esp32"},
1568 },
1569 "spb_kitchen": {
1570 "player_id": "spb_kitchen",
1571 "provider": "sendspin",
1572 "player_type": "protocol",
1573 "enabled": False,
1574 "values": {"protocol_parent_id": "up_kitchen"},
1575 },
1576 # only the parent side of the link survived
1577 "ap_office": {
1578 "player_id": "ap_office",
1579 "provider": "airplay",
1580 "player_type": "protocol",
1581 "enabled": False,
1582 "values": {},
1583 },
1584 "cast_office": {
1585 "player_id": "cast_office",
1586 "provider": "chromecast",
1587 "player_type": "player",
1588 "enabled": True,
1589 "values": {"linked_protocol_ids": ["ap_office"]},
1590 },
1591 # neither side of the link survived
1592 "ap_ghost": {
1593 "player_id": "ap_ghost",
1594 "provider": "airplay",
1595 "player_type": "protocol",
1596 "enabled": False,
1597 "values": {},
1598 },
1599 "up_kitchen": {
1600 "player_id": "up_kitchen",
1601 "provider": "universal_player",
1602 "player_type": "player",
1603 "enabled": False,
1604 "values": {"linked_protocol_ids": ["spb_kitchen"]},
1605 },
1606 },
1607 "player_dsp": {"spb_esp32": {"enabled": True}, "spb_kitchen": {"enabled": True}},
1608 }
1609
1610
1611def test_migrate_orphaned_disabled_protocol_configs() -> None:
1612 """A disabled protocol player without a parent config is dropped, others are kept."""
1613 data = _orphaned_protocol_data()
1614 assert _migrate_orphaned_disabled_protocol_configs(data) is True
1615 assert "spb_esp32" not in data["players"]
1616 assert "spb_esp32" not in data["player_dsp"]
1617 assert "ap_ghost" not in data["players"]
1618 # protocol players that are still owned by a player are left alone
1619 assert "spb_kitchen" in data["players"]
1620 assert "spb_kitchen" in data["player_dsp"]
1621 assert "ap_office" in data["players"]
1622 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1623
1624
1625def test_migrate_orphaned_disabled_protocol_configs_keeps_enabled_players() -> None:
1626 """An enabled protocol player is kept: it can register and find a new parent."""
1627 data = _orphaned_protocol_data()
1628 data["players"]["spb_esp32"]["enabled"] = True
1629 data["players"]["ap_ghost"]["enabled"] = True
1630 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1631 assert "spb_esp32" in data["players"]
1632
1633
1634def test_migrate_orphaned_disabled_protocol_configs_tolerates_malformed_data() -> None:
1635 """Non-dict player configs/values and non-protocol players are skipped."""
1636 data: dict[str, Any] = {
1637 "players": {
1638 "p1": "not-a-dict",
1639 "p2": {"player_id": "p2", "player_type": "player", "enabled": False, "values": None},
1640 "p3": {"player_id": "p3", "player_type": "player", "enabled": False, "values": {}},
1641 }
1642 }
1643 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1644 assert len(data["players"]) == 3
1645
1646
1647def test_migrate_unrenamed_player_names() -> None:
1648 """A stored name that merely repeats the default name is cleared, real renames stay."""
1649 data: dict[str, Any] = {
1650 "players": {
1651 "never_renamed": {"name": "Living Room", "default_name": "Living Room"},
1652 "user_renamed": {"name": "Bathroom", "default_name": "solarium-bath-sl"},
1653 # without a default name, clearing the name would leave no name at all
1654 "without_default_name": {"name": "Kitchen"},
1655 "not_a_dict": "malformed",
1656 }
1657 }
1658
1659 assert _migrate_unrenamed_player_names(data) is True
1660 assert data["players"]["never_renamed"]["name"] is None
1661 assert data["players"]["user_renamed"]["name"] == "Bathroom"
1662 assert data["players"]["without_default_name"]["name"] == "Kitchen"
1663 # the cleared name must not be picked up again on the next start
1664 assert _migrate_unrenamed_player_names(data) is False
1665