tinyjukebox 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,671 @@
1
+ """The playback of one entry of Spotify with Soloist (docs/developer/architecture.md, section 4.1).
2
+
3
+ Everything happens in a thread of its own, one thing after the other: the commands of the box and the events of
4
+ Soloist are queued. What the player does:
5
+
6
+ - **Start:** Soloist and its sound (:class:`~tinyjukebox.spotify.engine.SpotifyEngine`), shuffle and repeat off,
7
+ ``play`` with the address of the entry. With a saved position the sound is dropped meanwhile: as many times ``next``
8
+ as the saved place says, then the address of the track there is compared with the saved one. If it differs, the track
9
+ is looked up in the queue (what came before, up to 80 ahead); if it is not there, the entry starts from the
10
+ beginning. Then ``seek`` and sound.
11
+ - **The place of the track** follows from the queue: the track that starts is the next one or the one before.
12
+ - **The end:** a track that Spotify added by itself (``source: autoplay``), or nothing plays any more after the last
13
+ track - the player pauses there: finished.
14
+ - **The phone has no influence**: what the box did not ask for itself is undone - a pause, another track,
15
+ shuffle, repeat, the volume. Music started elsewhere with the account of the box takes the playback away: the
16
+ player is "elsewhere" until "play" takes it back.
17
+ - **No sound while "playing"** (open report no. 5) for :data:`STALL_SECONDS`: the track is started again at its
18
+ position.
19
+ - **Soloist ends:** connecting again after :data:`RETRY_DELAYS` - in a loop, for hours if the box has no internet; an
20
+ expired build is fetched anew (exit code 10). In a pause it is started again only with "play". While "Connect" runs,
21
+ no Soloist may run: the player waits, trying every :data:`BUSY_WAIT` seconds - that is no failure.
22
+ - **A pause is silent at once:** :meth:`SpotifyPlayer.pause` mutes the sound in the thread of the caller; the player
23
+ takes the sound back only with "play" or "restart".
24
+ """
25
+
26
+ import logging
27
+ import queue
28
+ import threading
29
+ import time
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass, replace
32
+ from enum import Enum
33
+ from typing import Any, Protocol
34
+
35
+ from tinyjukebox.core.logging import get_child
36
+ from tinyjukebox.spotify.account import EXPIRED, Busy, Unavailable
37
+ from tinyjukebox.spotify.soloist import Soloist, SoloistError
38
+ from tinyjukebox.spotify.sound import PipeReader
39
+
40
+ __all__ = [
41
+ "BUSY_WAIT",
42
+ "OWN_WINDOW",
43
+ "RETRY_DELAYS",
44
+ "STALL_SECONDS",
45
+ "Engine",
46
+ "SpotifyPlayer",
47
+ "SpotifyState",
48
+ "Track",
49
+ ]
50
+
51
+ RETRY_DELAYS = (1.0, 2.0, 5.0, 10.0, 30.0)
52
+ BUSY_WAIT = 1.0
53
+ """Seconds between two tries while no Soloist may run ("Connect")."""
54
+ STALL_SECONDS = 15.0
55
+ OWN_WINDOW = 3.0
56
+ """Seconds after a command of the box in which what Soloist reports counts as its answer, not as the phone."""
57
+ WAIT = 10.0
58
+ """Seconds the player waits for an answer of Soloist (a track that starts, the login)."""
59
+ END_TOLERANCE = 3.0
60
+ """A track counts as played to its end if no more than this is left."""
61
+
62
+
63
+ class SpotifyState(Enum):
64
+ CONNECTING = "connecting" # Soloist starts, the box goes to the saved position
65
+ PLAYING = "playing"
66
+ PAUSED = "paused"
67
+ ELSEWHERE = "elsewhere" # the account of the box plays somewhere else now
68
+ RETRYING = "retrying" # Soloist has ended - starting it again
69
+ FINISHED = "finished"
70
+ STOPPED = "stopped"
71
+ ERROR = "error" # cannot play: no key, not connected, no player ...
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Track:
76
+ """The track (chapter, episode) of the entry: its place, address, title and length in seconds."""
77
+
78
+ index: int = 0
79
+ uri: str | None = None
80
+ title: str | None = None
81
+ length: float | None = None
82
+
83
+
84
+ class Engine(Protocol):
85
+ """What the player needs of :class:`~tinyjukebox.spotify.engine.SpotifyEngine`."""
86
+
87
+ @property
88
+ def reader(self) -> PipeReader | None: ...
89
+
90
+ def open(self, listener: Any) -> Soloist: ...
91
+
92
+ def close(self, listener: Any) -> None: ...
93
+
94
+
95
+ class _Stopped(Exception): # noqa: N818
96
+ pass
97
+
98
+
99
+ class _Again(Exception): # noqa: N818
100
+ """Soloist has ended or cannot be started: start it again, from the position the player has. ``counted``: a
101
+ failure, which makes the next wait longer; ``code``: the exit code of Soloist."""
102
+
103
+ def __init__(self, error: str, counted: bool = True, code: int | None = None) -> None:
104
+ super().__init__(error)
105
+ self.counted = counted
106
+ self.code = code
107
+
108
+
109
+ def _track_of(item: object, index: int) -> Track | None:
110
+ if not isinstance(item, dict) or not isinstance(item.get("uri"), str):
111
+ return None
112
+ decorations = item.get("decorations") or {}
113
+ duration = (decorations.get("playback") or {}).get("duration_ms")
114
+ return Track(
115
+ index,
116
+ item["uri"],
117
+ (decorations.get("identity") or {}).get("name"),
118
+ duration / 1000 if isinstance(duration, int | float) else None,
119
+ )
120
+
121
+
122
+ class SpotifyPlayer:
123
+ """Plays the entry ``uri`` with the Soloist of ``engine`` until :meth:`stop`."""
124
+
125
+ def __init__(
126
+ self,
127
+ logger: logging.Logger,
128
+ uri: str,
129
+ engine: Engine,
130
+ clock: Callable[[], float] = time.monotonic,
131
+ wall: Callable[[], float] = time.time,
132
+ retry_delays: tuple[float, ...] = RETRY_DELAYS,
133
+ stall: float = STALL_SECONDS,
134
+ wait: float = WAIT,
135
+ busy_wait: float = BUSY_WAIT,
136
+ ) -> None:
137
+ self._logger = get_child(logger, type(self).__name__)
138
+ self.uri = uri
139
+ self._engine = engine
140
+ self._clock = clock
141
+ self._wall = wall
142
+ self._retry_delays = retry_delays
143
+ self._stall = stall
144
+ self._wait = wait
145
+ self._busy_wait = busy_wait
146
+ self._queue: queue.Queue[tuple[str, Any]] = queue.Queue()
147
+ self._deferred: list[tuple[str, Any]] = []
148
+ self._thread: threading.Thread | None = None
149
+ self._lock = threading.Lock()
150
+ self._soloist: Soloist | None = None
151
+ self._state = SpotifyState.STOPPED
152
+ self.error: str | None = None
153
+ self._track = Track()
154
+ self._anchor: tuple[float, float, float] = (0.0, 0.0, 0.0) # position ms, at epoch ms, speed
155
+ self._status: str | None = None
156
+ self._logged_in: bool | None = None
157
+ self._previous: list[dict[str, Any]] = []
158
+ self._upcoming: list[dict[str, Any]] = []
159
+ self._own_until = 0.0
160
+ self._skipping = False
161
+ self._attempt = 0
162
+ self._quiet = False # paused by the box: no sound until "play" or "restart" (under _lock)
163
+ self.on_state: Callable[[SpotifyState], None] = lambda state: None
164
+ self.on_track: Callable[[Track, float, bool], None] = lambda track, seconds, to_end: None
165
+ """A track ended or was left: the track, the seconds listened, whether it was played to its end."""
166
+ self.on_finished: Callable[[], None] = lambda: None
167
+ self.on_lost: Callable[[], None] = lambda: None
168
+ """Soloist says that the box has no login: the account is not connected any more."""
169
+ self.on_output_lost: Callable[[], None] = lambda: None
170
+ """``aplay`` failed on the headset: it has gone."""
171
+
172
+ # --- what the box asks for ---------------------------------------------------------------------------------------
173
+
174
+ @property
175
+ def state(self) -> SpotifyState:
176
+ return self._state
177
+
178
+ def start(self, track: Track, seconds: float) -> None:
179
+ """Start the playback at ``track`` (its place and address) and ``seconds`` - returns at once."""
180
+ with self._lock:
181
+ self._track, self._anchor = track, (seconds * 1000, self._wall() * 1000, 0.0)
182
+ self._thread = threading.Thread(target=self._run, name="SpotifyPlayer", daemon=True)
183
+ self._thread.start()
184
+
185
+ def pause(self) -> None:
186
+ """Pause - silent at once, before Soloist is paused in the thread of the player (the headset may have gone,
187
+ and the speaker may become the output as soon as this returns)."""
188
+ with self._lock:
189
+ self._quiet = True
190
+ reader = self._engine.reader
191
+ if reader is not None:
192
+ reader.muted = True
193
+ self._queue.put(("pause", None))
194
+
195
+ def play(self) -> None:
196
+ with self._lock:
197
+ self._quiet = False
198
+ self._queue.put(("play", None))
199
+
200
+ def next(self) -> None:
201
+ self._queue.put(("next", None))
202
+
203
+ def prev(self) -> None:
204
+ self._queue.put(("prev", None))
205
+
206
+ def restart(self) -> None:
207
+ """From the beginning of the entry."""
208
+ with self._lock:
209
+ self._quiet = False
210
+ self._queue.put(("restart", None))
211
+
212
+ def output_changed(self) -> None:
213
+ """Another output (headset or speaker): the sound continues there - another ``aplay``, started in the thread
214
+ of the player."""
215
+ self._queue.put(("output", None))
216
+
217
+ def stop(self, timeout: float = 10.0) -> None:
218
+ self._queue.put(("stop", None))
219
+ thread = self._thread
220
+ if thread is not None and thread is not threading.current_thread():
221
+ thread.join(timeout)
222
+
223
+ def position(self) -> tuple[Track, float]:
224
+ """The track and the seconds within it."""
225
+ with self._lock:
226
+ track, (position, at, speed) = self._track, self._anchor
227
+ seconds = (position + max(0.0, self._wall() * 1000 - at) * speed) / 1000
228
+ if track.length is not None:
229
+ seconds = min(seconds, track.length)
230
+ return track, max(0.0, seconds)
231
+
232
+ # --- what Soloist says (engine.Listener) -------------------------------------------------------------------------
233
+
234
+ def event(self, event: dict[str, Any]) -> None:
235
+ self._queue.put(("event", event))
236
+
237
+ def exited(self, code: int) -> None:
238
+ self._queue.put(("exited", code))
239
+
240
+ def suspended(self) -> None:
241
+ self._queue.put(("suspended", None))
242
+
243
+ def output_lost(self) -> None:
244
+ self.on_output_lost()
245
+
246
+ # --- the thread --------------------------------------------------------------------------------------------------
247
+
248
+ def _set_state(self, state: SpotifyState, error: str | None = None) -> None:
249
+ if state == self._state and error == self.error:
250
+ return
251
+ self._state, self.error = state, error
252
+ self._logger.info("state: %s%s", state.value, f" ({error})" if error else "")
253
+ self.on_state(state)
254
+
255
+ def _run(self) -> None:
256
+ try:
257
+ begin = True
258
+ while True: # a loop, never a recursion: a box without the internet tries again for hours
259
+ try:
260
+ if begin:
261
+ self._begin()
262
+ while True:
263
+ self._handle(*self._next_item())
264
+ except _Again as again:
265
+ begin = self._recover(again)
266
+ except _Stopped:
267
+ pass
268
+ except Exception:
269
+ self._logger.exception("the playback of %s failed", self.uri)
270
+ self._set_state(SpotifyState.ERROR, "unexpected")
271
+ finally:
272
+ self._finish()
273
+
274
+ def _next_item(self) -> tuple[str, Any]:
275
+ if self._deferred:
276
+ return self._deferred.pop(0)
277
+ try:
278
+ return self._queue.get(timeout=1.0)
279
+ except queue.Empty:
280
+ return "tick", None
281
+
282
+ def _finish(self) -> None:
283
+ track, seconds = self.position()
284
+ if self._state in (SpotifyState.PLAYING, SpotifyState.PAUSED) and track.uri is not None:
285
+ self.on_track(track, seconds, False)
286
+ soloist, self._soloist = self._soloist, None
287
+ if soloist is not None and soloist.running:
288
+ try:
289
+ soloist.ctl("pause")
290
+ except SoloistError as e:
291
+ self._logger.info("pause at the end: %s", e)
292
+ reader = self._engine.reader
293
+ if reader is not None:
294
+ reader.silence()
295
+ self._engine.close(self)
296
+ if self._state not in (SpotifyState.ERROR, SpotifyState.FINISHED):
297
+ self._set_state(SpotifyState.STOPPED)
298
+
299
+ def _handle(self, kind: str, value: Any) -> None: # noqa: PLR0912 - one branch per command
300
+ """One command or event. :raises _Again: Soloist has ended (or could not be started again)."""
301
+ if kind == "stop":
302
+ raise _Stopped
303
+ if kind == "event":
304
+ self._event(value)
305
+ elif kind in ("exited", "suspended"):
306
+ if self._state not in (SpotifyState.STOPPED, SpotifyState.ERROR):
307
+ raise self._ended(kind, value)
308
+ elif kind == "tick":
309
+ self._tick()
310
+ elif kind == "output":
311
+ self._restart_output()
312
+ elif self._state in (SpotifyState.ERROR, SpotifyState.RETRYING, SpotifyState.CONNECTING):
313
+ return
314
+ elif kind == "pause" and self._state == SpotifyState.PLAYING:
315
+ self._own("pause")
316
+ self._silence()
317
+ self._set_state(SpotifyState.PAUSED)
318
+ elif kind == "play" and self._state in (SpotifyState.PAUSED, SpotifyState.ELSEWHERE):
319
+ if self._soloist is None: # Soloist ended in the pause
320
+ self._begin()
321
+ return
322
+ if self._state == SpotifyState.ELSEWHERE:
323
+ self._own("activate")
324
+ self._own("play")
325
+ self._sound()
326
+ self._set_state(SpotifyState.PLAYING)
327
+ elif kind in ("next", "prev") and self._state in (SpotifyState.PLAYING, SpotifyState.PAUSED):
328
+ self._own(kind)
329
+ if self._state == SpotifyState.PAUSED:
330
+ self._own("pause")
331
+ elif kind == "restart" or (kind == "prev" and self._state == SpotifyState.FINISHED):
332
+ with self._lock:
333
+ self._track, self._anchor = Track(), (0.0, self._wall() * 1000, 0.0)
334
+ if self._soloist is None: # Soloist ended in the pause
335
+ self._begin()
336
+ return
337
+ self._set_state(SpotifyState.CONNECTING) # if Soloist ends on the way, it is started again
338
+ self._go_to(Track(), 0.0)
339
+ self._set_state(SpotifyState.PLAYING)
340
+
341
+ @staticmethod
342
+ def _ended(kind: str, code: Any) -> _Again:
343
+ if kind == "suspended":
344
+ return _Again("Soloist was ended by the box", counted=False)
345
+ return _Again(f"Soloist has ended ({code})", code=code)
346
+
347
+ def _own(self, *command: str) -> None:
348
+ """A command of the box: what Soloist reports in the next seconds is its answer."""
349
+ soloist = self._soloist
350
+ if soloist is None:
351
+ return
352
+ self._own_until = self._clock() + OWN_WINDOW
353
+ try:
354
+ soloist.ctl(*command)
355
+ except SoloistError as e:
356
+ self._logger.warning("%s", e)
357
+
358
+ def _is_own(self) -> bool:
359
+ return self._skipping or self._clock() < self._own_until
360
+
361
+ def _silence(self) -> None:
362
+ reader = self._engine.reader
363
+ if reader is not None:
364
+ reader.silence()
365
+
366
+ def _sound(self) -> None:
367
+ """The sound is heard again - unless the box has paused meanwhile."""
368
+ with self._lock:
369
+ reader = self._engine.reader
370
+ if reader is not None and not self._quiet:
371
+ reader.sound()
372
+
373
+ def _restart_output(self) -> None:
374
+ reader = self._engine.reader
375
+ if reader is not None:
376
+ reader.restart_output()
377
+
378
+ # --- start and going to a position ------------------------------------------------------------------------------
379
+
380
+ def _begin(self) -> None:
381
+ """Soloist, the login, the entry at the saved position. :raises _Again: Soloist has to be started again"""
382
+ if self._state != SpotifyState.RETRYING: # RETRYING stays until Soloist runs: no change every second
383
+ self._set_state(SpotifyState.CONNECTING)
384
+ try:
385
+ self._soloist = self._engine.open(self)
386
+ except Busy as e:
387
+ raise _Again(str(e), counted=False) from e
388
+ except Unavailable as e:
389
+ if e.reason == "not_connected":
390
+ self.on_lost()
391
+ self._set_state(SpotifyState.ERROR, e.reason)
392
+ return
393
+ except OSError as e:
394
+ self._logger.error("cannot start Soloist: %s", e)
395
+ raise _Again(str(e)) from e
396
+ self._set_state(SpotifyState.CONNECTING)
397
+ self._silence()
398
+ if not self._await(lambda: self._logged_in is not None):
399
+ raise _Again("Soloist does not answer")
400
+ if not self._logged_in:
401
+ self.on_lost()
402
+ self._set_state(SpotifyState.ERROR, "not_connected")
403
+ return
404
+ track, seconds = self.position()
405
+ self._own("shuffle", "off")
406
+ self._own("repeat", "off")
407
+ self._own("volume", "100")
408
+ if not self._go_to(track, seconds):
409
+ raise _Again("the entry does not start")
410
+ self._attempt = 0
411
+ self._set_state(SpotifyState.PLAYING)
412
+
413
+ def _await(self, condition: Callable[[], bool], timeout: float | None = None) -> bool:
414
+ """Handle the events until ``condition()`` holds - commands of the box wait. :raises _Stopped:"""
415
+ end = self._clock() + (self._wait if timeout is None else timeout)
416
+ while not condition():
417
+ if self._clock() >= end:
418
+ return False
419
+ try:
420
+ kind, value = self._queue.get(timeout=0.2)
421
+ except queue.Empty:
422
+ continue
423
+ if kind == "stop":
424
+ raise _Stopped
425
+ if kind == "event":
426
+ self._event(value)
427
+ elif kind in ("exited", "suspended"):
428
+ raise self._ended(kind, value)
429
+ elif kind == "output":
430
+ self._restart_output()
431
+ else:
432
+ self._deferred.append((kind, value))
433
+ return True
434
+
435
+ def _go_to(self, track: Track, seconds: float) -> bool:
436
+ """The entry from the beginning, then to ``track`` and ``seconds``, without sound on the way."""
437
+ self._silence()
438
+ self._skipping = True
439
+ try:
440
+ started = self._play_entry()
441
+ if started and track.index > 0:
442
+ started = self._skip_to(track)
443
+ if started and track.uri is not None and self._track.uri != track.uri:
444
+ started = self._find(track)
445
+ if started and seconds > 1 and self._track.uri == track.uri:
446
+ self._own("seek", str(round(seconds * 1000)))
447
+ with self._lock:
448
+ self._anchor = (seconds * 1000, self._wall() * 1000, 1.0)
449
+ except _Again:
450
+ self._skipping = False
451
+ with self._lock: # Soloist ended on the way: the next one goes there again, not to where this one was
452
+ self._track, self._anchor = track, (seconds * 1000, self._wall() * 1000, 0.0)
453
+ raise
454
+ self._skipping = False
455
+ if started:
456
+ self._sound() # not if the box paused on the way
457
+ return started
458
+
459
+ def _play_entry(self) -> bool:
460
+ with self._lock:
461
+ self._track = Track()
462
+ self._own("play", self.uri)
463
+ return self._await(lambda: self._track.uri is not None)
464
+
465
+ def _skip_to(self, track: Track) -> bool:
466
+ for _ in range(track.index):
467
+ index = self._track.index
468
+ self._own("next")
469
+ if not self._await(lambda index=index: self._track.index > index): # type: ignore[misc]
470
+ return False
471
+ return True
472
+
473
+ def _find(self, track: Track) -> bool:
474
+ """The saved track is not at its place (the playlist was changed): look for it around it."""
475
+ soloist = self._soloist
476
+ if soloist is None:
477
+ return False
478
+ try:
479
+ queued = soloist.json("queue", "80", "--json")
480
+ except SoloistError as e:
481
+ self._logger.warning("%s", e)
482
+ queued = {}
483
+ for name, command in (("upcoming", "next"), ("previous", "prev")):
484
+ uris = [entry.get("item", {}).get("uri") for entry in queued.get(name) or []]
485
+ if track.uri in uris:
486
+ for _ in range(uris.index(track.uri) + 1):
487
+ current = self._track.uri
488
+ self._own(command)
489
+ if not self._await(lambda current=current: self._track.uri != current): # type: ignore[misc]
490
+ return False
491
+ return True
492
+ self._logger.info("the saved track %s is not in the entry any more - from the beginning", track.uri)
493
+ return self._play_entry()
494
+
495
+ def _recover(self, again: _Again) -> bool:
496
+ """Soloist has ended, or cannot be started now - whether to start it again now (after a wait). In a pause it
497
+ is started again only with "play"; the position stays where the sound stopped."""
498
+ self._soloist = None
499
+ self._logged_in = None # the next Soloist answers for itself
500
+ self._engine.close(self)
501
+ _, seconds = self.position()
502
+ with self._lock:
503
+ self._anchor = (seconds * 1000, self._wall() * 1000, 0.0)
504
+ quiet = self._quiet
505
+ if again.code == EXPIRED:
506
+ self._set_state(SpotifyState.ERROR, "expired")
507
+ return False
508
+ if self._state in (SpotifyState.STOPPED, SpotifyState.ERROR):
509
+ return False
510
+ if quiet or self._state in (SpotifyState.PAUSED, SpotifyState.ELSEWHERE, SpotifyState.FINISHED):
511
+ self._logger.info("%s - it is started again with the next command", again)
512
+ if self._state not in (SpotifyState.ELSEWHERE, SpotifyState.FINISHED):
513
+ self._set_state(SpotifyState.PAUSED)
514
+ return False
515
+ return self._wait_to_retry(again)
516
+
517
+ def _wait_to_retry(self, again: _Again) -> bool:
518
+ """RETRYING: wait the next of the retry delays after a failure, :data:`BUSY_WAIT` while no Soloist may run -
519
+ True: start it again; False: the box paused meanwhile (Soloist starts again with "play")."""
520
+ if again.counted:
521
+ delay = self._retry_delays[min(self._attempt, len(self._retry_delays) - 1)]
522
+ self._attempt += 1
523
+ self._logger.warning("%s - starting again in %.0f s", again, delay)
524
+ else:
525
+ delay = self._busy_wait
526
+ self._set_state(SpotifyState.RETRYING, str(again))
527
+ end = self._clock() + delay
528
+ while self._clock() < end:
529
+ try:
530
+ kind, value = self._queue.get(timeout=min(0.5, max(0.01, end - self._clock())))
531
+ except queue.Empty:
532
+ continue
533
+ if kind == "stop":
534
+ raise _Stopped
535
+ if kind == "pause":
536
+ self._set_state(SpotifyState.PAUSED)
537
+ return False
538
+ if kind not in ("event", "exited", "suspended", "output"):
539
+ self._deferred.append((kind, value))
540
+ return True
541
+
542
+ # --- what Soloist reports ----------------------------------------------------------------------------------------
543
+
544
+ def _event(self, event: dict[str, Any]) -> None: # noqa: PLR0912 - one branch per kind of event
545
+ kind = event.get("type")
546
+ if kind == "auth_state":
547
+ self._logged_in = bool(event.get("logged_in"))
548
+ if not self._logged_in and self._state in (SpotifyState.PLAYING, SpotifyState.PAUSED):
549
+ self.on_lost()
550
+ self._set_state(SpotifyState.ERROR, "not_connected")
551
+ elif kind == "playback_state":
552
+ self._logged_in = True
553
+ self._queue_entries(event)
554
+ if event.get("item") is not None:
555
+ self._changed_track(event["item"])
556
+ self._position(event.get("position"))
557
+ self._playback(event.get("status"))
558
+ elif kind == "track_changed":
559
+ self._changed_track(event.get("item"))
560
+ elif kind == "position_sync":
561
+ self._position(event.get("position"))
562
+ elif kind == "playback_changed":
563
+ self._playback(event.get("status"))
564
+ elif kind == "queue_changed":
565
+ self._queue_entries(event)
566
+ elif kind == "options_changed":
567
+ self._options(event.get("options") or {})
568
+ elif kind == "volume_changed":
569
+ if event.get("volume") not in (None, 100) and not self._skipping:
570
+ self._own("volume", "100")
571
+ elif kind == "device_changed" and event.get("is_active") is False:
572
+ if self._state == SpotifyState.PLAYING:
573
+ self._logger.info("the account of the box plays somewhere else now")
574
+ self._silence()
575
+ self._set_state(SpotifyState.ELSEWHERE)
576
+
577
+ def _queue_entries(self, event: dict[str, Any]) -> None:
578
+ if isinstance(event.get("upcoming"), list):
579
+ self._upcoming = event["upcoming"]
580
+ if isinstance(event.get("previous"), list):
581
+ self._previous = event["previous"]
582
+
583
+ def _position(self, position: object) -> None:
584
+ if isinstance(position, dict) and isinstance(position.get("position_ms"), int | float):
585
+ with self._lock:
586
+ speed = position.get("speed")
587
+ self._anchor = (
588
+ float(position["position_ms"]),
589
+ float(position.get("timestamp_ms") or self._wall() * 1000),
590
+ float(speed) if isinstance(speed, int | float) else 1.0,
591
+ )
592
+
593
+ def _options(self, options: dict[str, Any]) -> None:
594
+ if self._skipping:
595
+ return
596
+ if options.get("shuffle"):
597
+ self._own("shuffle", "off")
598
+ if options.get("repeat") not in (None, "off", False):
599
+ self._own("repeat", "off")
600
+
601
+ def _changed_track(self, item: object) -> None:
602
+ uri = item.get("uri") if isinstance(item, dict) else None
603
+ if uri is None:
604
+ return
605
+ current = self._track
606
+ if uri == current.uri:
607
+ new = _track_of(item, current.index)
608
+ with self._lock:
609
+ self._track = replace(current, title=new.title, length=new.length) if new else current
610
+ return
611
+ upcoming = [entry for entry in self._upcoming if isinstance(entry, dict)]
612
+ previous = [entry for entry in self._previous if isinstance(entry, dict)]
613
+ source = None
614
+ if current.uri is None:
615
+ index = 0
616
+ elif upcoming and upcoming[0].get("item", {}).get("uri") == uri:
617
+ index, source = current.index + 1, upcoming[0].get("source")
618
+ elif previous and previous[0].get("item", {}).get("uri") == uri:
619
+ index = max(0, current.index - 1)
620
+ elif self._is_own():
621
+ index = current.index + 1
622
+ else:
623
+ self._logger.info("another track was started from the Spotify app - back to the track of the box")
624
+ track, seconds = self.position()
625
+ self._go_to(track, seconds)
626
+ return
627
+ if current.uri is not None and not self._skipping:
628
+ track, seconds = self.position()
629
+ self.on_track(track, seconds, track.length is not None and seconds >= track.length - END_TOLERANCE)
630
+ running = self._state in (SpotifyState.PLAYING, SpotifyState.CONNECTING) # Soloist plays, if silently
631
+ with self._lock:
632
+ self._track = _track_of(item, index) or Track(index, uri)
633
+ self._anchor = (0.0, self._wall() * 1000, 1.0 if running else 0.0)
634
+ if source == "autoplay" and not self._skipping:
635
+ self._finished()
636
+
637
+ def _playback(self, status: object) -> None:
638
+ self._status = status if isinstance(status, str) else self._status
639
+ if self._skipping or self._state != SpotifyState.PLAYING:
640
+ return
641
+ if status == "idle":
642
+ self._finished()
643
+ elif status == "paused" and not self._is_own():
644
+ track, seconds = self.position()
645
+ if track.length is not None and seconds >= track.length - END_TOLERANCE and not self._upcoming:
646
+ self._finished()
647
+ else:
648
+ self._logger.info("paused from the Spotify app - the box plays on")
649
+ self._own("play")
650
+
651
+ def _finished(self) -> None:
652
+ if self._state == SpotifyState.FINISHED:
653
+ return
654
+ track, seconds = self.position()
655
+ self._own("pause")
656
+ self._silence()
657
+ if track.uri is not None:
658
+ self.on_track(track, seconds, True)
659
+ self._set_state(SpotifyState.FINISHED)
660
+ self.on_finished()
661
+
662
+ def _tick(self) -> None:
663
+ """Once a second: does sound come while Soloist says "playing"?"""
664
+ reader = self._engine.reader
665
+ if self._state != SpotifyState.PLAYING or reader is None or reader.muted:
666
+ return
667
+ if self._clock() - reader.last_sound > self._stall:
668
+ self._logger.warning("no sound for %.0f s while playing - starting the track again", self._stall)
669
+ track, seconds = self.position()
670
+ reader.last_sound = self._clock()
671
+ self._go_to(track, seconds)