/
/
/
1"""Normalized models shared between the Spotify Connect provider and its backends."""
2
3from __future__ import annotations
4
5from collections.abc import Awaitable, Callable
6from dataclasses import dataclass, field
7from enum import StrEnum
8from typing import TYPE_CHECKING
9
10from music_assistant_models.enums import RepeatMode
11
12if TYPE_CHECKING:
13 from music_assistant_models.enums import StreamType
14
15
16class BackendEventType(StrEnum):
17 """Type discriminator for the normalized events a backend emits to the provider."""
18
19 # session lifecycle: this device became / stopped being the active Spotify device
20 SESSION_ACTIVE = "session_active"
21 SESSION_INACTIVE = "session_inactive"
22 # playback state reported by the backend (BUFFERING is informational: reserved
23 # for backends that report it, the provider does not act on it)
24 PLAYING = "playing"
25 PAUSED = "paused"
26 STOPPED = "stopped"
27 BUFFERING = "buffering"
28 # track metadata and playback position updates
29 METADATA = "metadata"
30 POSITION = "position"
31 # Spotify-side volume change (normalized to a 0-100 percentage)
32 VOLUME = "volume"
33 # queue listing / playback options reported by the session (only emitted
34 # by backends with ``supports_queue_control``)
35 QUEUE_CHANGED = "queue_changed"
36 OPTIONS_CHANGED = "options_changed"
37 # the backend lost its Spotify connection (e.g. daemon exit) and will recover
38 # on its own; any session/playback state is gone until a new SESSION_ACTIVE
39 CONNECTION_LOST = "connection_lost"
40 # a non-fatal backend error worth surfacing (message in the ``error`` field)
41 ERROR = "error"
42 # the backend failed permanently: the provider gives up this daemon, or unloads
43 # entirely when the event's ``provider_wide`` flag is set
44 FATAL_ERROR = "fatal_error"
45 # any other backend activity; carries at most refreshed context/track uris
46 OTHER = "other"
47
48
49@dataclass(slots=True, frozen=True)
50class BackendStreamSource:
51 """
52 How a backend delivers its audio to the streams controller.
53
54 ``path`` is only set for path-based stream types (e.g. NAMED_PIPE); CUSTOM
55 sources deliver their audio through the backend's audio reader instead.
56 ``extra_input_args`` are passed to ffmpeg for the audio input.
57 """
58
59 stream_type: StreamType
60 path: str | None = None
61 extra_input_args: list[str] = field(default_factory=list)
62
63
64@dataclass(slots=True)
65class BackendTrackMetadata:
66 """
67 Normalized track metadata carried by a METADATA event.
68
69 ``duration`` and ``position`` are in seconds. A None ``title`` means the
70 backend did not report one (the provider keeps the previous title).
71 """
72
73 track_uri: str | None = None
74 title: str | None = None
75 artist: str | None = None
76 album: str | None = None
77 image_url: str | None = None
78 duration: int | None = None
79 position: int = 0
80
81
82class QueueEntrySource(StrEnum):
83 """Where an entry in the session's queue listing comes from."""
84
85 # part of the playing context (album/playlist/artist)
86 CONTEXT = "context"
87 # explicitly queued by the user
88 QUEUE = "queue"
89 # session autoplay continuation
90 AUTOPLAY = "autoplay"
91 UNKNOWN = "unknown"
92
93 @classmethod
94 def _missing_(cls, value: object) -> QueueEntrySource: # noqa: ARG003
95 """Return UNKNOWN if an unknown value is provided."""
96 return cls.UNKNOWN
97
98
99@dataclass(slots=True)
100class BackendQueueEntry:
101 """
102 One entry in the session's queue listing.
103
104 ``uid`` is the session's stable handle for this entry (two occurrences of
105 the same track carry distinct uids). ``name`` is the display title when
106 the backend reported one.
107 """
108
109 uid: str
110 uri: str
111 source: QueueEntrySource
112 name: str | None = None
113
114
115@dataclass(slots=True)
116class BackendQueueState:
117 """The session's queue view: recently played and upcoming entries, in play order."""
118
119 previous: list[BackendQueueEntry] = field(default_factory=list)
120 upcoming: list[BackendQueueEntry] = field(default_factory=list)
121
122
123@dataclass(slots=True)
124class BackendPlaybackOptions:
125 """Playback options of the session."""
126
127 shuffle: bool = False
128 repeat: RepeatMode = RepeatMode.OFF
129
130
131@dataclass(slots=True)
132class BackendEvent:
133 """
134 A single normalized event emitted by a backend to the provider.
135
136 ``context_uri`` / ``track_uri`` piggyback on every event type: they carry
137 the latest context/track seen by the backend so the provider can take
138 playback back after the user moved the active device away. ``position`` is
139 the elapsed time in seconds (POSITION events), ``volume`` a 0-100
140 percentage (VOLUME events), ``error`` the failure description (ERROR and
141 FATAL_ERROR events), ``queue`` the session's queue view (QUEUE_CHANGED
142 events) and ``options`` the session's playback options (OPTIONS_CHANGED
143 events). ``provider_wide`` applies to FATAL_ERROR events only: it marks a
144 failure of the engine as a whole rather than of this one daemon.
145 """
146
147 type: BackendEventType
148 context_uri: str | None = None
149 track_uri: str | None = None
150 metadata: BackendTrackMetadata | None = None
151 position: int | None = None
152 volume: int | None = None
153 error: str | None = None
154 queue: BackendQueueState | None = None
155 options: BackendPlaybackOptions | None = None
156 provider_wide: bool = False
157
158
159# Awaited by the backend for every normalized event, in emit order.
160BackendEventCallback = Callable[[BackendEvent], Awaitable[None]]
161
162# Reads the next chunk of decoded PCM; returns b"" once the audio pipe closes.
163AudioChunkReader = Callable[[], Awaitable[bytes]]
164