music-assistant-server

32.7 KBPY
flows.py
32.7 KB749 lines • python
1"""Setup flow engine: registry and API commands for interactive provider/player setup."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import time
8from dataclasses import dataclass
9from functools import partial
10from typing import TYPE_CHECKING, Any
11from uuid import uuid4
12
13from music_assistant_models.auth import Scope
14from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
15from music_assistant_models.enums import (
16    ConfigEntryType,
17    EventType,
18    FlowStepType,
19    MediaType,
20    ProviderStage,
21)
22from music_assistant_models.errors import (
23    ActionUnavailable,
24    InsufficientPermissions,
25    SetupFailedError,
26)
27from music_assistant_models.setup_flow import SetupFlowStep
28
29from music_assistant.constants import CONF_PLAYERS, CONF_PROVIDERS
30from music_assistant.controllers.config.helpers import _AUTH_ERROR_CODES
31from music_assistant.helpers.api import api_command
32from music_assistant.helpers.util import import_module_in_thread, load_provider_module
33from music_assistant.models.player import Player
34from music_assistant.models.setup_flow import (
35    AbortFlow,
36    FlowReason,
37    SetupFlowContext,
38    SetupFlowError,
39    SetupSession,
40    StepExpiredError,
41)
42
43if TYPE_CHECKING:
44    from collections.abc import Awaitable, Callable
45
46    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
47    from music_assistant_models.provider import ProviderManifest
48
49    from music_assistant import MusicAssistant
50
51LOGGER = logging.getLogger(__name__)
52
53# a flow with no client interaction or step changes for this long is garbage collected
54IDLE_FLOW_TTL = 15 * 60
55FLOW_SWEEP_INTERVAL = 60
56# how long start/submit wait for the flow coroutine to produce the (next) step;
57# generous because finish() may install requirements and load the provider
58NEXT_STEP_TIMEOUT = 120
59# bound on waiting for a cancelled flow's cleanup (author finally-blocks)
60FLOW_ABORT_CLEANUP_TIMEOUT = 10
61
62
63@dataclass
64class ActiveSetupFlow:
65    """Registry record for a running setup flow."""
66
67    session: SetupSession
68    # target_key: identifies what the flow is (re)configuring; one flow per target
69    target_key: str
70    # required_scope: the scope the starting command required; re-checked on
71    # every flows/* continuation command
72    required_scope: Scope
73    task: asyncio.Task[None] | None = None
74
75
76class SetupFlowMixin:
77    """Mixin providing the setup flow engine for the ConfigController."""
78
79    # registry of running flows, keyed by flow_id (lazily created per instance)
80    _flows: dict[str, ActiveSetupFlow] | None = None
81    _flow_sweep_handle: asyncio.TimerHandle | None = None
82    # required scopes of recently finished flows: terminal steps can publish after
83    # the registry pop (cancel-driven aborts), and the event-scope filter still
84    # needs to resolve them; bounded FIFO
85    _finished_flow_scopes: dict[str, Scope] | None = None
86
87    # Type hints for attributes/methods provided by the class this mixin is used with
88    if TYPE_CHECKING:
89        mass: MusicAssistant
90
91        @property
92        def onboard_done(self) -> bool: ...  # noqa: D102
93
94        def get(self, key: str, default: Any = None) -> Any: ...  # noqa: D102
95
96        def set(self, key: str, value: Any, immediate: bool = False) -> None: ...  # noqa: D102
97
98        def encrypt_string(self, str_value: str) -> str: ...  # noqa: D102
99
100        def decrypt_string(self, encrypted_str: str) -> str: ...  # noqa: D102
101
102        async def get_provider_configs(  # noqa: D102
103            self, provider_type: Any = None, provider_domain: str | None = None
104        ) -> list[ProviderConfig]: ...
105
106        async def get_provider_config(self, instance_id: str) -> ProviderConfig: ...  # noqa: D102
107
108        def update_provider_last_error(self, instance_id: str, error: Any) -> None: ...  # noqa: D102
109
110        async def get_player_config(self, player_id: str) -> Any: ...  # noqa: D102
111
112        async def _create_provider_instance(
113            self,
114            provider_domain: str,
115            values: dict[str, ConfigValueType],
116            setup_data: dict[str, Any] | None = None,
117        ) -> ProviderConfig: ...
118
119    @api_command("config/providers/setup", required_scope=Scope.CONFIG_PROVIDERS_WRITE)
120    async def setup_provider(self, provider_domain: str) -> SetupFlowStep:
121        """
122        Start the setup flow to add a new instance of the given provider.
123
124        For a provider without a setup flow (no user input needed) the instance is
125        created right away and a FINISH step is returned, so the add-provider path
126        is uniform for all providers.
127
128        :param provider_domain: Domain of the provider to add an instance of.
129        """
130        for manifest in self.mass.get_provider_manifests():
131            if manifest.domain == provider_domain:
132                break
133        else:
134            msg = f"Unknown provider domain: {provider_domain}"
135            raise KeyError(msg)
136        owner = f"provider.{provider_domain}"
137        # fail fast on conditions that would otherwise only surface at save
138        if manifest.stage == ProviderStage.DEPRECATED:
139            # a retired provider can never be set up again; its own strings explain
140            # what to use instead
141            return self._synthesized_step(FlowStepType.ABORT, owner, reason="provider_retired")
142        existing = await self.get_provider_configs(provider_domain=provider_domain)
143        if existing and not manifest.multi_instance:
144            return self._synthesized_step(FlowStepType.ABORT, owner, reason="already_configured")
145        if manifest.depends_on:
146            dep_configs = await self.get_provider_configs(provider_domain=manifest.depends_on)
147            if not any(dep_conf.enabled for dep_conf in dep_configs):
148                return self._synthesized_step(
149                    FlowStepType.ABORT, owner, reason="missing_dependency"
150                )
151        flow_module = await self._get_setup_flow_module(manifest)
152        if flow_module is None:
153            # zero-input provider: create the instance immediately and report it
154            # as an (already) finished flow
155            config = await self._create_provider_instance(provider_domain, {})
156            return self._synthesized_step(
157                FlowStepType.FINISH,
158                owner,
159                step_id=self._provider_finish_step_id(config.instance_id),
160                result={"instance_id": config.instance_id},
161            )
162        context = SetupFlowContext(kind="setup", reason="user", domain=provider_domain)
163        return await self._start_flow(
164            flow_coro=flow_module.run_setup,
165            context=context,
166            target_key=f"provider_setup:{provider_domain}",
167            required_scope=Scope.CONFIG_PROVIDERS_WRITE,
168            finish_handler=self._finish_provider_setup,
169        )
170
171    @api_command("config/providers/reconfigure", required_scope=Scope.CONFIG_PROVIDERS_WRITE)
172    async def reconfigure_provider(self, instance_id: str) -> SetupFlowStep:
173        """
174        Start the reconfigure flow on an existing provider instance (covers reauth).
175
176        :param instance_id: The provider instance to reconfigure.
177        """
178        raw_conf = self.get(f"{CONF_PROVIDERS}/{instance_id}")
179        if not raw_conf:
180            msg = f"No config found for provider id {instance_id}"
181            raise KeyError(msg)
182        domain: str = raw_conf["domain"]
183        manifest = self.mass.get_provider_manifest(domain)
184        owner = f"provider.{domain}"
185        flow_module = await self._get_setup_flow_module(manifest)
186        if flow_module is None:
187            # flow-less providers have nothing to reconfigure;
188            # their failures are environmental (reload/retry covers them)
189            return self._synthesized_step(FlowStepType.ABORT, owner, reason="nothing_to_configure")
190        context = SetupFlowContext(
191            kind="reconfigure",
192            reason=self._reconfigure_reason(raw_conf.get("last_error")),
193            domain=domain,
194            instance_id=instance_id,
195            setup_data=self._decrypt_values(raw_conf.get("setup_data") or {}),
196            values=self._decrypt_values(raw_conf.get("values") or {}),
197        )
198        return await self._start_flow(
199            flow_coro=flow_module.run_setup,
200            context=context,
201            target_key=f"provider_reconfigure:{instance_id}",
202            required_scope=Scope.CONFIG_PROVIDERS_WRITE,
203            finish_handler=self._finish_provider_reconfigure,
204        )
205
206    @api_command("config/players/setup", required_scope=Scope.CONFIG_PLAYERS_WRITE)
207    async def setup_player(self, player_id: str) -> SetupFlowStep:
208        """
209        Start the setup flow for a player (e.g. pairing).
210
211        A player that itself needs no setup but wraps protocol child player(s) that do
212        (universal players, or native players wrapping protocol children) delegates to
213        the child's setup flow: to the single child that needs setup directly, or - when
214        more than one does - via a form that lets the user pick which child to set up.
215        The child's flow persists to the child's own config.
216
217        Also serves on-demand re-runs: when nothing needs setup (anymore), delegation
218        falls back to any child that merely has a flow, so a step the user skipped
219        earlier - an optional pairing, say - remains reachable.
220
221        :param player_id: The player to set up.
222        """
223        # deliberately no raise_unavailable: a player that needs setup is serialized
224        # as unavailable, and that is exactly the player this command targets
225        player = self.mass.players.get_player(player_id)
226        if player is None:
227            msg = f"Player {player_id} not found"
228            raise KeyError(msg)
229        owner = f"provider.{player.provider.domain}"
230        target_key = f"player_setup:{player_id}"
231        if player.implements_setup_flow:
232            # the player implements its own setup flow: run it directly
233            return await self._start_flow(
234                flow_coro=player.run_setup_flow,
235                context=self._player_flow_context(player),
236                target_key=target_key,
237                required_scope=Scope.CONFIG_PLAYERS_WRITE,
238                finish_handler=self._finish_player_setup,
239            )
240        # no direct setup: delegate to protocol child player(s), preferring the ones
241        # that actually need setup and falling back to any that can re-run their flow
242        children = self._protocol_children_with_setup_flow(player, needing_only=True)
243        if not children:
244            children = self._protocol_children_with_setup_flow(player, needing_only=False)
245        if len(children) == 1:
246            child = children[0]
247            return await self._start_flow(
248                flow_coro=child.run_setup_flow,
249                context=self._player_flow_context(child),
250                # key on the child: a direct setup of the child must replace this flow
251                target_key=f"player_setup:{child.player_id}",
252                required_scope=Scope.CONFIG_PLAYERS_WRITE,
253                finish_handler=self._finish_player_setup,
254            )
255        if children:
256            return await self._start_flow(
257                flow_coro=partial(self._run_child_selection_flow, children),
258                context=self._player_flow_context(player),
259                target_key=target_key,
260                required_scope=Scope.CONFIG_PLAYERS_WRITE,
261                finish_handler=self._finish_player_setup,
262            )
263        # nothing on this player (or its children) to configure
264        return self._synthesized_step(FlowStepType.ABORT, owner, reason="nothing_to_configure")
265
266    @api_command("config/flows/submit")
267    async def submit_setup_flow(
268        self, flow_id: str, values: dict[str, ConfigValueType]
269    ) -> SetupFlowStep:
270        """
271        Submit the user's values for the flow's pending FORM step.
272
273        Returns the flow's next step, or the same FORM step (with per-field errors
274        set) when validation failed.
275
276        :param flow_id: The id of the running flow.
277        :param values: The raw values for the form's config entries.
278        """
279        flow = self._get_flow(flow_id)
280        self._check_flow_permission(flow)
281        if (error_step := flow.session.handle_submit(values)) is not None:
282            return error_step
283        # wait (bounded) for the coroutine to produce the next step
284        submitted_step = flow.session.current_step
285        await flow.session.wait_for_step_change(NEXT_STEP_TIMEOUT)
286        step = flow.session.current_step
287        assert step is not None  # an accepted submit implies a published FORM step
288        if step is submitted_step:
289            # rare: the coroutine is still working on the next step. The submitted
290            # form's input future is already consumed, so re-serving the form would
291            # invite a doomed resubmit - publish a progress step (so flows/get agrees)
292            # and let the coroutine's next publish deliver the real step
293            flow.session.progress("working")
294            step = flow.session.current_step
295            assert step is not None
296        return step
297
298    @api_command("config/flows/get")
299    async def get_setup_flow(self, flow_id: str) -> SetupFlowStep:
300        """
301        Return the current step of a running flow (idempotent re-render, never advances).
302
303        :param flow_id: The id of the running flow.
304        """
305        flow = self._get_flow(flow_id)
306        self._check_flow_permission(flow)
307        flow.session.last_activity = time.monotonic()
308        if (step := flow.session.current_step) is None:
309            raise ActionUnavailable("The setup flow has not produced a step yet")
310        return step
311
312    @api_command("config/flows/abort")
313    async def abort_setup_flow(self, flow_id: str) -> None:
314        """
315        Abort a running flow (user cancelled).
316
317        :param flow_id: The id of the running flow.
318        """
319        flow = self._get_flow(flow_id)
320        self._check_flow_permission(flow)
321        await self._abort_flow(flow, reason="aborted")
322
323    def get_setup_flow_required_scope(self, flow_id: str) -> Scope | None:
324        """
325        Return the scope required to receive/interact with the given setup flow.
326
327        Also resolves recently finished flows (their terminal step can publish
328        just after the registry pop). Returns None when the flow is unknown.
329
330        :param flow_id: The id of the flow.
331        """
332        if flow := self._setup_flows.get(flow_id):
333            return flow.required_scope
334        if self._finished_flow_scopes:
335            return self._finished_flow_scopes.get(flow_id)
336        return None
337
338    def _pop_flow(self, flow: ActiveSetupFlow) -> None:
339        """Remove a flow from the registry, retaining its scope for late events."""
340        self._setup_flows.pop(flow.session.flow_id, None)
341        if self._finished_flow_scopes is None:
342            self._finished_flow_scopes = {}
343        finished = self._finished_flow_scopes
344        finished[flow.session.flow_id] = flow.required_scope
345        while len(finished) > 64:
346            finished.pop(next(iter(finished)))
347
348    async def _start_flow(
349        self,
350        *,
351        flow_coro: Callable[[SetupSession], Awaitable[Any]],
352        context: SetupFlowContext,
353        target_key: str,
354        required_scope: Scope,
355        finish_handler: Callable[
356            [SetupSession, dict[str, ConfigValueType]], Awaitable[dict[str, str]]
357        ],
358    ) -> SetupFlowStep:
359        """Register and start a new flow, returning its first published step."""
360        # one flow per target: starting anew replaces (aborts) a lingering previous flow.
361        # re-scan after every await: the abort yields, so a concurrent start for the same
362        # target may have registered a new flow in the meantime
363        while existing_flow := next(
364            (f for f in self._setup_flows.values() if f.target_key == target_key), None
365        ):
366            await self._abort_flow(existing_flow, reason="replaced")
367        flow_id = uuid4().hex
368        session = SetupSession(self.mass, flow_id, context, finish_handler)
369        flow = ActiveSetupFlow(
370            session=session, target_key=target_key, required_scope=required_scope
371        )
372        self._setup_flows[flow_id] = flow
373        LOGGER.debug("Starting setup flow %s for %s", flow_id, target_key)
374        flow.task = self.mass.create_task(self._run_flow(flow, flow_coro))
375        self._schedule_flow_sweep()
376        if session.current_step is None:
377            await session.wait_for_step_change(NEXT_STEP_TIMEOUT)
378        if (step := session.current_step) is None:
379            await self._abort_flow(flow, reason="internal_error")
380            raise SetupFailedError("Setup flow did not produce a first step")
381        return step
382
383    async def _run_flow(
384        self, flow: ActiveSetupFlow, flow_coro: Callable[[SetupSession], Awaitable[Any]]
385    ) -> None:
386        """Drive the flow coroutine and convert its outcome into a terminal step."""
387        session = flow.session
388        try:
389            await flow_coro(session)
390        except AbortFlow as err:
391            session.publish_abort(err.reason)
392        except StepExpiredError:
393            session.publish_abort("timed_out")
394        except SetupFlowError as err:
395            # the author did not catch a finish failure: end with the failure message
396            session.publish_abort(str(err) or "internal_error")
397        except asyncio.CancelledError:
398            # abort/replace/shutdown: the author's cleanup (finally blocks) has run;
399            # the canceller publishes the ABORT step. Never swallow the cancellation.
400            raise
401        except Exception:
402            LOGGER.exception("Unhandled error in setup flow for %s", session.context.domain)
403            session.publish_abort("internal_error")
404        else:
405            if not session.finished:
406                LOGGER.error(
407                    "Setup flow for %s returned without calling finish()", session.context.domain
408                )
409                session.publish_abort("internal_error")
410        finally:
411            LOGGER.debug("Setup flow %s ended", session.flow_id)
412            session.close()
413            self._pop_flow(flow)
414
415    async def _abort_flow(self, flow: ActiveSetupFlow, reason: str) -> None:
416        """
417        Abort the given flow with the given reason and clean it up.
418
419        Cancelling raises CancelledError inside the flow coroutine so the author's
420        cleanup (try/finally around pairing sessions etc.) runs before the terminal
421        ABORT step goes out.
422        """
423        if flow.task is not None and not flow.task.done():
424            flow.task.cancel()
425            # wait() shields us from the task's CancelledError without
426            # masking a cancellation of the caller itself; the timeout keeps a
427            # wedged author cleanup (e.g. a hanging pairing teardown) from
428            # stalling the abort and any replacement flow indefinitely
429            _, pending = await asyncio.wait([flow.task], timeout=FLOW_ABORT_CLEANUP_TIMEOUT)
430            if pending:
431                LOGGER.warning(
432                    "Setup flow for %s did not clean up within %ss after cancellation",
433                    flow.session.context.domain,
434                    FLOW_ABORT_CLEANUP_TIMEOUT,
435                )
436                # the wedged task never reaches _run_flow's finally: close the
437                # session here so the unauthenticated callback route is dropped
438                flow.session.close()
439        self._pop_flow(flow)
440        current_step = flow.session.current_step
441        if current_step is None or current_step.type not in (
442            FlowStepType.FINISH,
443            FlowStepType.ABORT,
444        ):
445            flow.session.publish_abort(reason)
446
447    async def _finish_provider_setup(
448        self, session: SetupSession, values: dict[str, ConfigValueType]
449    ) -> dict[str, str]:
450        """Finish handler for provider setup flows: create, persist and load the instance."""
451        try:
452            config = await self._create_provider_instance(
453                session.context.domain, {}, setup_data=self._encrypt_values(values)
454            )
455        except Exception as err:
456            raise SetupFlowError(
457                str(err) or err.__class__.__name__,
458                translation_key=getattr(err, "translation_key", None),
459            ) from err
460        session.finish_step_id = self._provider_finish_step_id(config.instance_id)
461        return {"instance_id": config.instance_id}
462
463    async def _finish_provider_reconfigure(
464        self, session: SetupSession, values: dict[str, ConfigValueType]
465    ) -> dict[str, str]:
466        """Finish handler for provider reconfigure flows: merge setup_data and reload."""
467        instance_id = session.context.instance_id
468        assert instance_id is not None  # always set for reconfigure flows
469        conf_key = f"{CONF_PROVIDERS}/{instance_id}"
470        raw_conf = self.get(conf_key)
471        if not raw_conf:
472            raise SetupFlowError(f"Provider {instance_id} no longer exists")
473        snapshot = dict(raw_conf.get("setup_data") or {})
474        self.set(f"{conf_key}/setup_data", {**snapshot, **self._encrypt_values(values)})
475        try:
476            config = await self.get_provider_config(instance_id)
477            await self.mass.load_provider_config(config)
478        except asyncio.CancelledError:
479            self.set(f"{conf_key}/setup_data", snapshot)
480            raise
481        except Exception as err:
482            # reloading with the new values failed: restore the previous setup_data
483            self.set(f"{conf_key}/setup_data", snapshot)
484            raise SetupFlowError(
485                str(err) or err.__class__.__name__,
486                translation_key=getattr(err, "translation_key", None),
487            ) from err
488        self.update_provider_last_error(instance_id, None)
489        return {"instance_id": instance_id}
490
491    async def _finish_player_setup(
492        self, session: SetupSession, values: dict[str, ConfigValueType]
493    ) -> dict[str, str]:
494        """Finish handler for player setup flows: persist and apply the collected setup data."""
495        player_id = session.context.player_id
496        assert player_id is not None  # always set for player flows
497        conf_key = f"{CONF_PLAYERS}/{player_id}"
498        raw_conf = self.get(conf_key)
499        if not raw_conf:
500            raise SetupFlowError(f"No config found for player {player_id}")
501        snapshot = dict(raw_conf.get("setup_data") or {})
502        self.set(f"{conf_key}/setup_data", {**snapshot, **self._encrypt_values(values)})
503        try:
504            config = await self.get_player_config(player_id)
505            changed_keys = {f"setup_data/{key}" for key in values}
506            await self.mass.players.on_player_config_change(config, changed_keys)
507        except asyncio.CancelledError:
508            self.set(f"{conf_key}/setup_data", snapshot)
509            raise
510        except Exception as err:
511            # reading back or applying the updated config failed: restore the previous setup_data
512            self.set(f"{conf_key}/setup_data", snapshot)
513            raise SetupFlowError(
514                str(err) or err.__class__.__name__,
515                translation_key=getattr(err, "translation_key", None),
516            ) from err
517        self.mass.signal_event(EventType.PLAYER_CONFIG_UPDATED, object_id=player_id, data=config)
518        return {"player_id": player_id}
519
520    def _player_flow_context(self, player: Player) -> SetupFlowContext:
521        """Build the setup flow context (with decrypted prefill) for the given player."""
522        raw_conf = self.get(f"{CONF_PLAYERS}/{player.player_id}") or {}
523        return SetupFlowContext(
524            kind="setup",
525            reason="user",
526            domain=player.provider.domain,
527            instance_id=player.provider.instance_id,
528            player_id=player.player_id,
529            setup_data=self._decrypt_values(raw_conf.get("setup_data") or {}),
530            values=self._decrypt_values(raw_conf.get("values") or {}),
531        )
532
533    def _protocol_children_with_setup_flow(
534        self, player: Player, *, needing_only: bool
535    ) -> list[Player]:
536        """
537        Return the player's protocol child players whose setup flow is available.
538
539        Covers the wrapper case: a universal player, or a native player wrapping
540        protocol children, whose own setup is a no-op but whose linked protocol
541        outputs still require pairing/credentials.
542
543        :param player: The (wrapper) player whose protocol children to inspect.
544        :param needing_only: Only return children that currently need setup.
545        """
546        children: list[Player] = []
547        seen: set[str] = set()
548        for output_protocol in player.output_protocols:
549            child_id = output_protocol.output_protocol_id
550            if output_protocol.is_native or child_id in seen:
551                continue
552            seen.add(child_id)
553            child = self.mass.players.get_player(child_id)
554            if child is None or not child.has_setup_flow:
555                continue
556            if needing_only and not child.needs_setup:
557                continue
558            children.append(child)
559        return children
560
561    async def _run_child_selection_flow(
562        self, children: list[Player], session: SetupSession
563    ) -> None:
564        """
565        Run the wrapper flow that lets the user pick which protocol child to set up.
566
567        The selection form is owned by the parent; once a child is picked the session is
568        re-pointed at that child so its flow's steps localize and its ``finish()`` persists
569        under the child's own config.
570        """
571        options = [
572            ConfigValueOption(
573                value=child.player_id, title=f"{child.display_name} ({child.provider.name})"
574            )
575            for child in children
576        ]
577        values = await session.form(
578            [
579                ConfigEntry(
580                    key="child",
581                    type=ConfigEntryType.STRING,
582                    required=True,
583                    default_value=children[0].player_id,
584                    options=options,
585                )
586            ],
587            step_id="select_child",
588        )
589        child_id = str(values["child"])
590        child = next((candidate for candidate in children if candidate.player_id == child_id), None)
591        if child is None:
592            raise AbortFlow("nothing_to_configure")
593        child_raw = self.get(f"{CONF_PLAYERS}/{child_id}") or {}
594        session.retarget(
595            domain=child.provider.domain,
596            instance_id=child.provider.instance_id,
597            player_id=child_id,
598            setup_data=self._decrypt_values(child_raw.get("setup_data") or {}),
599            values=self._decrypt_values(child_raw.get("values") or {}),
600        )
601        await child.run_setup_flow(session)
602
603    async def _get_setup_flow_module(self, manifest: ProviderManifest) -> Any | None:
604        """
605        Import (lazily) the provider's setup_flow module, or None when it has none.
606
607        A provider without a setup_flow module needs no setup input at all.
608        """
609        # ensure the provider's requirements are installed and its package imports
610        # cleanly first: the setup_flow submodule may rely on those requirements
611        await load_provider_module(manifest.domain, manifest.requirements)
612        module_path = f"music_assistant.providers.{manifest.domain}.setup_flow"
613        try:
614            return await import_module_in_thread(module_path)
615        except ModuleNotFoundError as err:
616            if err.name == module_path:
617                # the provider ships no setup_flow module: it needs no setup input
618                return None
619            # an import *inside* setup_flow.py failed: an actual bug, surface it
620            raise
621
622    @property
623    def _setup_flows(self) -> dict[str, ActiveSetupFlow]:
624        """Return the registry of running flows (created lazily)."""
625        if self._flows is None:
626            self._flows = {}
627        return self._flows
628
629    def _get_flow(self, flow_id: str) -> ActiveSetupFlow:
630        """Return the running flow for the given id."""
631        if flow := self._setup_flows.get(flow_id):
632            return flow
633        msg = f"Unknown (or finished) setup flow: {flow_id}"
634        raise KeyError(msg)
635
636    def _check_flow_permission(self, flow: ActiveSetupFlow) -> None:
637        """Verify the calling user holds the scope the flow's start command required."""
638        # imported here: the webserver helpers pull in the full auth stack,
639        # which must not be imported with the config controller at startup
640        from music_assistant.controllers.webserver.helpers.auth_middleware import (  # noqa: PLC0415
641            get_current_user,
642            has_scope,
643        )
644
645        user = get_current_user()
646        # no user context means an internal (server-side) caller, which is trusted
647        if user is not None and not has_scope(user, flow.required_scope):
648            raise InsufficientPermissions(
649                f"This action requires the {flow.required_scope.value} scope"
650            )
651
652    def _synthesized_step(
653        self,
654        step_type: FlowStepType,
655        translation_owner: str,
656        *,
657        step_id: str | None = None,
658        result: dict[str, str] | None = None,
659        reason: str | None = None,
660    ) -> SetupFlowStep:
661        """
662        Return a terminal step for a flow that ended before a session was needed.
663
664        :param step_type: The terminal step type (FINISH or ABORT).
665        :param translation_owner: The namespace the step's strings resolve under.
666        :param step_id: Slug to serve the step's strings under; defaults to the one
667            implied by the step type.
668        :param result: Reference to the created/updated object (FINISH).
669        :param reason: Slug describing why the flow was aborted (ABORT).
670        """
671        default_step_id = "finish" if step_type == FlowStepType.FINISH else "abort"
672        return SetupFlowStep(
673            flow_id=uuid4().hex,
674            step_id=step_id or default_step_id,
675            type=step_type,
676            result=result,
677            reason=reason,
678            translation_owner=translation_owner,
679        )
680
681    def _provider_finish_step_id(self, instance_id: str) -> str:
682        """
683        Return the i18n slug of the FINISH step for a newly set up provider instance.
684
685        :param instance_id: The provider instance the flow just created.
686        """
687        # a provider that imports a library gets the variant explaining that the first import
688        # runs in the background, so a library that still looks empty right after setup does
689        # not read as a failed setup
690        provider = self.mass.get_provider(instance_id, return_unavailable=True)
691        if provider is not None and any(
692            self.mass.music.library_supported(provider, media_type) for media_type in MediaType.ALL
693        ):
694            return "finish_library_sync"
695        return "finish"
696
697    def _reconfigure_reason(self, last_error: Any) -> FlowReason:
698        """Derive the reconfigure flow reason from the provider's stored last_error."""
699        if not last_error:
700            return "user"
701        # legacy settings may still hold a plain string last_error
702        error_code = last_error.get("error_code") if isinstance(last_error, dict) else None
703        if error_code in _AUTH_ERROR_CODES:
704            return "auth"
705        return "error"
706
707    def _encrypt_values(self, values: dict[str, ConfigValueType]) -> dict[str, Any]:
708        """Return a copy of the values with all string values encrypted (at-rest form)."""
709        return {
710            key: self.encrypt_string(value) if isinstance(value, str) else value
711            for key, value in values.items()
712        }
713
714    def _decrypt_values(self, values: dict[str, Any]) -> dict[str, Any]:
715        """Return a copy of the values with all (encrypted) string values decrypted."""
716        return {
717            key: self.decrypt_string(value) if isinstance(value, str) else value
718            for key, value in values.items()
719        }
720
721    def _schedule_flow_sweep(self) -> None:
722        """Arm the periodic idle-flow sweeper (idempotent)."""
723        if self._flow_sweep_handle is not None:
724            return
725        self._flow_sweep_handle = self.mass.loop.call_later(
726            FLOW_SWEEP_INTERVAL, self._sweep_idle_flows
727        )
728
729    def _sweep_idle_flows(self) -> None:
730        """Abort flows that have been idle for longer than the TTL."""
731        self._flow_sweep_handle = None
732        if self.mass.closing:
733            return
734        now = time.monotonic()
735        for flow in list(self._setup_flows.values()):
736            current_step = flow.session.current_step
737            if (
738                current_step is not None
739                and current_step.expires_at is not None
740                and current_step.expires_at > time.time()
741            ):
742                # the step advertises a (longer) countdown to the user; the step
743                # deadline machinery guarantees the flow terminates on its own
744                continue
745            if now - flow.session.last_activity >= IDLE_FLOW_TTL:
746                self.mass.create_task(self._abort_flow(flow, "timed_out"))
747        if self._setup_flows:
748            self._schedule_flow_sweep()
749