/
/
/
1"""
2Tests for the one-shot cleanup of the retired local_audio provider.
3
4The provider was builtin and enumerated every output device of the host, so a machine
5that merely has a sound card carries a provider config and one player config per device.
6Now that the provider is retired and fails to load, those artefacts would raise a
7retirement banner at a user who never played a note through them. The cleanup decides on
8evidence of playback: a playlog row keyed to the player, or a persisted queue that holds
9something. Anything less and the whole lot is removed; anything more and it all stays.
10"""
11
12from __future__ import annotations
13
14import asyncio
15import json
16import sqlite3
17from typing import TYPE_CHECKING, Any
18from unittest.mock import AsyncMock, patch
19
20from music_assistant.constants import (
21 CONF_PLAYER_DSP,
22 CONF_PLAYER_QUEUES,
23 CONF_PLAYERS,
24 CONF_PROTOCOL_PARENT_ID,
25 CONF_PROVIDERS,
26 CONF_RETIRED_LOCAL_AUDIO_CLEANED,
27 DB_TABLE_PLAYLOG,
28)
29from music_assistant.controllers.config.retired_local_audio import cleanup_retired_local_audio
30from music_assistant.controllers.player_queues.constants import (
31 CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
32 CACHE_CATEGORY_PLAYER_QUEUE_STATE,
33)
34from tests.conftest import full_mass_context
35
36if TYPE_CHECKING:
37 import pathlib
38
39 import pytest
40
41 from music_assistant.mass import MusicAssistant
42
43ANALOG_ID = "local_audio_analog"
44# the universal player that wrapped ANALOG_ID before the stubs were promoted
45LEGACY_WRAPPER_ID = "uplocal_audio_analog"
46HDMI_ID = "local_audio_hdmi"
47BRIDGE_ID = "spb_analog"
48OTHER_PLAYER_ID = "cast_kitchen"
49
50
51def _store_install(mass: MusicAssistant, provider_enabled: bool = True) -> None:
52 """Store the config shape a pre-retirement install with a sound card carries."""
53 mass.config.set(
54 f"{CONF_PROVIDERS}/local_audio",
55 {
56 "type": "player",
57 "domain": "local_audio",
58 "instance_id": "local_audio",
59 "enabled": provider_enabled,
60 "name": "Local Audio Out",
61 "values": {},
62 },
63 )
64 for player_id in (ANALOG_ID, HDMI_ID):
65 mass.config.set(
66 f"{CONF_PLAYERS}/{player_id}",
67 {
68 "player_id": player_id,
69 "provider": "local_audio",
70 "player_type": "player",
71 "enabled": True,
72 "values": {},
73 },
74 )
75 mass.config.set(f"{CONF_PLAYER_QUEUES}/{player_id}", {"queue_id": player_id, "values": {}})
76 mass.config.set(f"{CONF_PLAYER_DSP}/{player_id}", {"enabled": True})
77 # the sendspin bridge child that the local audio player was linked to
78 mass.config.set(
79 f"{CONF_PLAYERS}/{BRIDGE_ID}",
80 {
81 "player_id": BRIDGE_ID,
82 "provider": "sendspin",
83 "player_type": "protocol",
84 "enabled": True,
85 "values": {CONF_PROTOCOL_PARENT_ID: ANALOG_ID},
86 },
87 )
88 # an unrelated player that must survive untouched
89 mass.config.set(
90 f"{CONF_PLAYERS}/{OTHER_PLAYER_ID}",
91 {
92 "player_id": OTHER_PLAYER_ID,
93 "provider": "chromecast",
94 "player_type": "player",
95 "enabled": True,
96 "values": {},
97 },
98 )
99 # the full boot of the fixture already ran (and marked) the cleanup
100 mass.config.remove(CONF_RETIRED_LOCAL_AUDIO_CLEANED)
101
102
103async def _store_playlog_entry(mass: MusicAssistant, player_id: str) -> None:
104 """Store a playlog row that credits the given player/queue with a playback."""
105 await mass.music.database.insert(
106 DB_TABLE_PLAYLOG,
107 {
108 "item_id": "track_1",
109 "provider": "spotify",
110 "media_type": "track",
111 "name": "Some Track",
112 "userid": "someuser",
113 "queue_id": player_id,
114 "timestamp": 1700000000,
115 "fully_played": True,
116 "seconds_played": 180,
117 },
118 )
119
120
121async def _store_queue_cache(
122 mass: MusicAssistant, player_id: str, state: dict[str, Any], items: list[Any]
123) -> None:
124 """Store the persisted queue state and items of the given player."""
125 await mass.cache.set(
126 key=player_id,
127 data=state,
128 provider="player_queues",
129 category=CACHE_CATEGORY_PLAYER_QUEUE_STATE,
130 persistent=True,
131 )
132 await mass.cache.set(
133 key=player_id,
134 data=items,
135 provider="player_queues",
136 category=CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
137 persistent=True,
138 )
139
140
141def _empty_queue_state(player_id: str) -> dict[str, Any]:
142 """Return the state a registered but never used queue is flushed with on shutdown."""
143 return {
144 "cache_format_version": 1,
145 "queue": {"queue_id": player_id, "active": False, "items": 0},
146 "enqueued_media_items": [],
147 "credited_albums": [],
148 "source_items": [],
149 "userid": None,
150 }
151
152
153async def _read_queue_cache(mass: MusicAssistant, player_id: str) -> list[Any]:
154 """Return the persisted queue state and items of the given player."""
155 return [
156 await mass.cache.get(key=player_id, provider="player_queues", category=category)
157 for category in (CACHE_CATEGORY_PLAYER_QUEUE_STATE, CACHE_CATEGORY_PLAYER_QUEUE_ITEMS)
158 ]
159
160
161async def _wait_for_queue_cache_purge(mass: MusicAssistant, player_id: str) -> None:
162 """Wait for the (scheduled) purge of the given player's persisted queue."""
163 deadline = asyncio.get_running_loop().time() + 5.0
164 while await _read_queue_cache(mass, player_id) != [None, None]:
165 assert asyncio.get_running_loop().time() < deadline, "saved queue was not purged"
166 await asyncio.sleep(0.01)
167
168
169def _assert_kept(mass: MusicAssistant) -> None:
170 """Assert the whole local_audio configuration is still in place."""
171 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is not None
172 assert mass.config.get(f"{CONF_PLAYERS}/{ANALOG_ID}") is not None
173 assert mass.config.get(f"{CONF_PLAYERS}/{HDMI_ID}") is not None
174
175
176async def test_unused_install_is_torched(mass: MusicAssistant) -> None:
177 """An install that never played through a sound card loses every local_audio artefact."""
178 _store_install(mass)
179 await _store_queue_cache(mass, ANALOG_ID, _empty_queue_state(ANALOG_ID), [])
180
181 await cleanup_retired_local_audio(mass)
182
183 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is None
184 for player_id in (ANALOG_ID, HDMI_ID):
185 assert mass.config.get(f"{CONF_PLAYERS}/{player_id}") is None
186 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{player_id}") is None
187 assert mass.config.get(f"{CONF_PLAYER_DSP}/{player_id}") is None
188 # the orphaned sendspin bridge child follows its dead parent
189 assert mass.config.get(f"{CONF_PLAYERS}/{BRIDGE_ID}") is None
190 assert mass.config.get(f"{CONF_PLAYERS}/{OTHER_PLAYER_ID}") is not None
191 assert mass.config.get(CONF_RETIRED_LOCAL_AUDIO_CLEANED) is True
192 await _wait_for_queue_cache_purge(mass, ANALOG_ID)
193
194
195async def test_empty_saved_queue_is_no_evidence(mass: MusicAssistant) -> None:
196 """
197 A persisted queue with an empty payload does not count as use.
198
199 The queues controller flushes the state of every registered queue on each clean
200 shutdown, so the row exists on any install that ever booted with a sound card.
201 """
202 _store_install(mass)
203 for player_id in (ANALOG_ID, HDMI_ID):
204 await _store_queue_cache(mass, player_id, _empty_queue_state(player_id), [])
205
206 await cleanup_retired_local_audio(mass)
207
208 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is None
209 assert mass.config.get(f"{CONF_PLAYERS}/{ANALOG_ID}") is None
210 assert mass.config.get(f"{CONF_PLAYERS}/{HDMI_ID}") is None
211
212
213async def test_disabled_provider_config_is_torched_too(mass: MusicAssistant) -> None:
214 """A disabled provider config is no signal of use; the evidence rule alone decides."""
215 _store_install(mass, provider_enabled=False)
216
217 await cleanup_retired_local_audio(mass)
218
219 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is None
220 assert mass.config.get(f"{CONF_PLAYERS}/{ANALOG_ID}") is None
221
222
223async def test_playlog_entry_keeps_the_config(mass: MusicAssistant) -> None:
224 """A playlog row keyed to a local_audio player proves it was used, so nothing goes."""
225 _store_install(mass)
226 await _store_playlog_entry(mass, HDMI_ID)
227
228 await cleanup_retired_local_audio(mass)
229
230 _assert_kept(mass)
231 assert mass.config.get(f"{CONF_PLAYERS}/{BRIDGE_ID}") is not None
232 assert mass.config.get(CONF_RETIRED_LOCAL_AUDIO_CLEANED) is True
233
234
235async def test_playlog_entry_of_another_player_is_no_evidence(mass: MusicAssistant) -> None:
236 """A playlog row credited to some other player says nothing about local audio."""
237 _store_install(mass)
238 await _store_playlog_entry(mass, OTHER_PLAYER_ID)
239
240 await cleanup_retired_local_audio(mass)
241
242 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is None
243
244
245async def test_non_empty_saved_queue_keeps_the_config(mass: MusicAssistant) -> None:
246 """A persisted queue that still holds media proves the player was used."""
247 _store_install(mass)
248 state = _empty_queue_state(ANALOG_ID)
249 state["enqueued_media_items"] = [{"item_id": "track_1", "provider": "spotify"}]
250 await _store_queue_cache(mass, ANALOG_ID, state, [])
251
252 await cleanup_retired_local_audio(mass)
253
254 _assert_kept(mass)
255
256
257async def test_saved_queue_items_keep_the_config(mass: MusicAssistant) -> None:
258 """Cached queue items count as evidence even when the state payload looks empty."""
259 _store_install(mass)
260 await _store_queue_cache(
261 mass, HDMI_ID, _empty_queue_state(HDMI_ID), [{"queue_item_id": "qi_1"}]
262 )
263
264 await cleanup_retired_local_audio(mass)
265
266 _assert_kept(mass)
267
268
269async def test_unreadable_library_keeps_the_config(
270 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
271) -> None:
272 """A question the databases cannot answer keeps everything, so the notice shows."""
273 _store_install(mass)
274
275 async def _raise(*_args: Any, **_kwargs: Any) -> list[Any]:
276 raise sqlite3.OperationalError("no such column: queue_id")
277
278 monkeypatch.setattr(mass.music.database, "get_rows_from_query", _raise)
279
280 await cleanup_retired_local_audio(mass)
281
282 _assert_kept(mass)
283 assert "no such column: queue_id" in caplog.text
284 # unanswered, so a later (healthy) startup gets to try again
285 assert mass.config.get(CONF_RETIRED_LOCAL_AUDIO_CLEANED) is None
286
287
288async def test_second_run_is_a_no_op(mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch) -> None:
289 """Once the flag is set the cleanup returns without touching the databases."""
290 _store_install(mass)
291 await _store_playlog_entry(mass, ANALOG_ID)
292 await cleanup_retired_local_audio(mass)
293 assert mass.config.get(CONF_RETIRED_LOCAL_AUDIO_CLEANED) is True
294
295 queried = False
296
297 async def _record(*_args: Any, **_kwargs: Any) -> list[Any]:
298 nonlocal queried
299 queried = True
300 return []
301
302 monkeypatch.setattr(mass.music.database, "get_rows_from_query", _record)
303
304 await cleanup_retired_local_audio(mass)
305
306 assert not queried
307 _assert_kept(mass)
308
309
310async def test_cleanup_runs_before_the_tombstone_loads(tmp_path: pathlib.Path) -> None:
311 """
312 A torched install never loads the tombstone, so no banner appears even briefly.
313
314 The end state alone cannot tell whether the cleanup beat the provider load - a
315 cleanup running after it would leave the same settings behind - so this watches
316 the tombstone's own setup(), which is what records the INCOMPATIBLE status.
317 """
318 storage_path = tmp_path / "data"
319 storage_path.mkdir(parents=True)
320 (storage_path / "settings.json").write_text(
321 json.dumps(
322 {
323 CONF_PROVIDERS: {
324 "local_audio": {
325 "type": "player",
326 "domain": "local_audio",
327 "instance_id": "local_audio",
328 "enabled": True,
329 "name": "Local Audio Out",
330 "values": {},
331 }
332 },
333 CONF_PLAYERS: {
334 ANALOG_ID: {
335 "player_id": ANALOG_ID,
336 "provider": "local_audio",
337 "player_type": "player",
338 "enabled": True,
339 "values": {},
340 }
341 },
342 }
343 ),
344 encoding="utf-8",
345 )
346
347 with patch("music_assistant.providers.local_audio.setup", new=AsyncMock()) as tombstone_setup:
348 async with full_mass_context(tmp_path) as mass:
349 assert tombstone_setup.call_count == 0
350 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is None
351 assert mass.config.get(f"{CONF_PLAYERS}/{ANALOG_ID}") is None
352 assert mass.get_provider("local_audio", return_unavailable=True) is None
353
354
355async def test_playback_on_the_legacy_universal_player_keeps_the_config(
356 mass: MusicAssistant,
357) -> None:
358 """
359 A playlog row from before the stubs were promoted still counts as use.
360
361 The universal player was the visible device the user played to; the promotion folded
362 its settings onto the stub under a new player_id, but the playlog it left behind is
363 still keyed to the old one.
364 """
365 _store_install(mass)
366 await _store_playlog_entry(mass, LEGACY_WRAPPER_ID)
367
368 await cleanup_retired_local_audio(mass)
369
370 _assert_kept(mass)
371
372
373async def test_legacy_universal_player_queue_keeps_the_config(mass: MusicAssistant) -> None:
374 """The persisted queue of the obsolete wrapper is evidence for the player that replaced it."""
375 _store_install(mass)
376 state = _empty_queue_state(LEGACY_WRAPPER_ID)
377 state["source_items"] = [{"item_id": "radio_1", "provider": "radiobrowser"}]
378 await _store_queue_cache(mass, LEGACY_WRAPPER_ID, state, [])
379
380 await cleanup_retired_local_audio(mass)
381
382 _assert_kept(mass)
383
384
385async def test_torch_purges_the_legacy_universal_player_queue(mass: MusicAssistant) -> None:
386 """No config names the obsolete wrapper anymore, so its empty saved queue goes here."""
387 _store_install(mass)
388 await _store_queue_cache(mass, LEGACY_WRAPPER_ID, _empty_queue_state(LEGACY_WRAPPER_ID), [])
389
390 await cleanup_retired_local_audio(mass)
391
392 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is None
393 await _wait_for_queue_cache_purge(mass, LEGACY_WRAPPER_ID)
394
395
396async def test_a_failing_removal_never_escapes_startup(
397 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
398) -> None:
399 """
400 A removal that fails part-way is logged and retried, not raised into start().
401
402 The user-filter rewrite is the fallible step, and it runs once the configs are already
403 gone; letting it escape would abort the boot on a half-deleted install. The flag stays
404 unset instead, so the next startup finishes what is left - every step is idempotent.
405 """
406 _store_install(mass)
407
408 async def _raise(*_args: Any, **_kwargs: Any) -> None:
409 raise sqlite3.OperationalError("database is locked")
410
411 monkeypatch.setattr(mass.webserver.auth, "remove_from_user_filters", _raise)
412
413 await cleanup_retired_local_audio(mass)
414
415 assert "keeping its configuration" in caplog.text
416 assert mass.config.get(CONF_RETIRED_LOCAL_AUDIO_CLEANED) is None
417
418 # the retry completes the removal once the fallible step works again
419 monkeypatch.undo()
420 await cleanup_retired_local_audio(mass)
421
422 assert mass.config.get(f"{CONF_PROVIDERS}/local_audio") is None
423 assert mass.config.get(f"{CONF_PLAYERS}/{ANALOG_ID}") is None
424 assert mass.config.get(CONF_RETIRED_LOCAL_AUDIO_CLEANED) is True
425