/
/
/
1"""
2One-shot cleanup of the retired local_audio provider on installs that never used it.
3
4The provider was builtin, so every install carries an auto-created config for it, and it
5enumerated every output device of the host into a player config of its own. Now that it is
6retired and its `setup()` fails with the retirement notice, those artefacts raise a red
7"this provider requires attention" banner on machines that merely happened to have a sound
8card. The banner is only worth showing to someone who actually played through one, so this
9decides on evidence of playback and removes everything where there is none.
10
11Unlike the `settings.json` migrations in `migrations.py`, answering that question needs the
12library and cache databases, so this runs from `MusicAssistant.start()` once the core
13controllers are up - and before the providers load, so the tombstone never gets the chance
14to record an INCOMPATIBLE status.
15
16TODO: remove after 2.11 release
17"""
18
19from __future__ import annotations
20
21import logging
22from typing import TYPE_CHECKING
23
24from music_assistant.constants import (
25 CONF_PLAYERS,
26 CONF_PROVIDERS,
27 CONF_RETIRED_LOCAL_AUDIO_CLEANED,
28 DB_TABLE_PLAYLOG,
29)
30from music_assistant.controllers.player_queues.constants import (
31 CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
32 CACHE_CATEGORY_PLAYER_QUEUE_STATE,
33)
34
35if TYPE_CHECKING:
36 from music_assistant.mass import MusicAssistant
37
38LOGGER = logging.getLogger(__name__)
39
40LOCAL_AUDIO_DOMAIN = "local_audio"
41
42
43async def cleanup_retired_local_audio(mass: MusicAssistant) -> None:
44 """
45 Remove the retired local_audio provider and its players when they were never used.
46
47 Runs at most once per install and never raises: an install whose databases cannot
48 answer the question keeps everything and gets the retirement notice instead.
49
50 :param mass: The MusicAssistant instance, with its core controllers set up.
51 """
52 if mass.config.get(CONF_RETIRED_LOCAL_AUDIO_CLEANED, False):
53 return
54 instance_ids = _local_audio_provider_instance_ids(mass)
55 player_ids = _local_audio_player_ids(mass)
56 if not instance_ids and not player_ids:
57 _mark_cleanup_done(mass)
58 return
59 try:
60 if (used_by := await _find_playback_evidence(mass, player_ids)) is not None:
61 LOGGER.debug(
62 "Keeping the config of the retired %s provider: player %s was played to",
63 LOCAL_AUDIO_DOMAIN,
64 used_by,
65 )
66 else:
67 await _remove_local_audio_config(mass, instance_ids, player_ids)
68 except Exception as err:
69 # keeping a config costs a banner, removing it wrongly costs the user their
70 # settings. Broad and around the removal too: an escape here would abort the boot.
71 # The flag stays unset, so the next startup retries the (idempotent) removal.
72 LOGGER.warning(
73 "Unable to clean up the retired %s provider, keeping its configuration - %s: %s",
74 LOCAL_AUDIO_DOMAIN,
75 type(err).__name__,
76 err,
77 exc_info=err,
78 )
79 return
80 _mark_cleanup_done(mass)
81
82
83def _local_audio_provider_instance_ids(mass: MusicAssistant) -> list[str]:
84 """Return the instance ids of all stored local_audio provider configs."""
85 all_provider_configs = mass.config.get(CONF_PROVIDERS, {})
86 if not isinstance(all_provider_configs, dict):
87 return []
88 return [
89 instance_id
90 for instance_id, prov_cfg in all_provider_configs.items()
91 if isinstance(prov_cfg, dict) and prov_cfg.get("domain") == LOCAL_AUDIO_DOMAIN
92 ]
93
94
95def _local_audio_player_ids(mass: MusicAssistant) -> list[str]:
96 """Return the player ids of all stored local_audio player configs."""
97 all_player_configs = mass.config.get(CONF_PLAYERS, {})
98 if not isinstance(all_player_configs, dict):
99 return []
100 return [
101 player_id
102 for player_id, player_cfg in all_player_configs.items()
103 if isinstance(player_cfg, dict) and player_cfg.get("provider") == LOCAL_AUDIO_DOMAIN
104 ]
105
106
107async def _remove_local_audio_config(
108 mass: MusicAssistant, instance_ids: list[str], player_ids: list[str]
109) -> None:
110 """
111 Wipe every trace of the retired provider from the config.
112
113 :param mass: The MusicAssistant instance to remove the configuration from.
114 :param instance_ids: Instance ids of the local_audio provider configs to remove.
115 :param player_ids: Player ids of the local_audio player configs to remove.
116 """
117 if instance_ids:
118 await mass.webserver.auth.remove_from_user_filters(provider_instance_ids=instance_ids)
119 for player_id in player_ids:
120 # also drops its DSP/queue settings, saved queue and bridged spb_* children
121 mass.players.delete_player_config(player_id)
122 # no config names the obsolete wrapper anymore, so nothing else would purge it
123 mass.player_queues.purge_saved_queue(_legacy_universal_player_id(player_id))
124 for instance_id in instance_ids:
125 mass.config.remove(f"{CONF_PROVIDERS}/{instance_id}")
126 LOGGER.info(
127 "Removed the config of the retired %s provider and its %s unused player(s)",
128 LOCAL_AUDIO_DOMAIN,
129 len(player_ids),
130 )
131
132
133async def _find_playback_evidence(mass: MusicAssistant, player_ids: list[str]) -> str | None:
134 """
135 Return the id a playback was recorded under, or None when there is no evidence.
136
137 :param mass: The MusicAssistant instance to query the library and cache of.
138 :param player_ids: The player ids to look for evidence of playback of.
139 """
140 # playback from before the stubs were promoted is still keyed to the universal
141 # player that wrapped them, which is not the player_id its settings ended up on
142 queue_ids = list(
143 dict.fromkeys(
144 queue_id
145 for player_id in player_ids
146 for queue_id in (player_id, _legacy_universal_player_id(player_id))
147 )
148 )
149 if not queue_ids:
150 return None
151 played = await _queue_ids_in_playlog(mass, queue_ids)
152 for queue_id in queue_ids:
153 if queue_id in played or await _has_saved_queue_content(mass, queue_id):
154 return queue_id
155 return None
156
157
158def _legacy_universal_player_id(player_id: str) -> str:
159 """Return the id of the universal player that used to wrap the given local_audio player."""
160 # mirrors the key _migrate_local_audio_attribution_stubs derives to find the wrapper
161 return f"up{player_id.replace('-', '').lower()}"
162
163
164async def _queue_ids_in_playlog(mass: MusicAssistant, queue_ids: list[str]) -> set[str]:
165 """
166 Return the subset of the given queue ids that something was ever played on.
167
168 :param mass: The MusicAssistant instance to query the library of.
169 :param queue_ids: The queue ids to look for, which for a player are its player ids.
170 """
171 # one query: playlog.queue_id has no index, so every lookup is a full scan
172 params = {f"id_{index}": queue_id for index, queue_id in enumerate(queue_ids)}
173 placeholders = ",".join(f":{name}" for name in params)
174 rows = await mass.music.database.get_rows_from_query(
175 f"SELECT DISTINCT queue_id FROM {DB_TABLE_PLAYLOG} WHERE queue_id IN ({placeholders})",
176 params,
177 limit=0,
178 )
179 return {str(row["queue_id"]) for row in rows}
180
181
182async def _has_saved_queue_content(mass: MusicAssistant, queue_id: str) -> bool:
183 """
184 Return whether the given queue has a persisted payload that holds anything.
185
186 :param mass: The MusicAssistant instance to query the cache of.
187 :param queue_id: The queue id to read the persisted state and items of.
188 """
189 # the entry's presence proves nothing: every registered queue is flushed on shutdown
190 state = await mass.cache.get(
191 key=queue_id,
192 provider=mass.player_queues.domain,
193 category=CACHE_CATEGORY_PLAYER_QUEUE_STATE,
194 allow_expired_cache=True,
195 )
196 if isinstance(state, dict) and (state.get("enqueued_media_items") or state.get("source_items")):
197 return True
198 items = await mass.cache.get(
199 key=queue_id,
200 provider=mass.player_queues.domain,
201 category=CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
202 allow_expired_cache=True,
203 )
204 return bool(items)
205
206
207def _mark_cleanup_done(mass: MusicAssistant) -> None:
208 """Record that this cleanup ran, so a next startup skips it without querying."""
209 mass.config.set(CONF_RETIRED_LOCAL_AUDIO_CLEANED, True, immediate=True)
210