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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- 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)
|