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,237 @@
|
|
|
1
|
+
"""PlayRadio - plays the radio station linked to a tag (the counterpart of STMPlayAlbum for radio stations)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tinyjukebox.albumplayer.radioplayer import RadioPlayerInterface, RadioState, StateHook
|
|
9
|
+
from tinyjukebox.core.logging import get_child
|
|
10
|
+
from tinyjukebox.core.pubsub import PubSubClient
|
|
11
|
+
from tinyjukebox.data.target import TargetRadio
|
|
12
|
+
from tinyjukebox.messages import BUTTON_PRESSED, PLAYBACK, Button, PlaybackStatus
|
|
13
|
+
from tinyjukebox.rgbled import WAITING, Color, RGBLed
|
|
14
|
+
from tinyjukebox.stm.base import OutputPause, PauseReason, State, create_machine
|
|
15
|
+
|
|
16
|
+
__all__ = ["PlayRadio", "RadioPlayerFactory", "States", "Transitions"]
|
|
17
|
+
|
|
18
|
+
RadioPlayerFactory = Callable[[logging.Logger, str, StateHook], RadioPlayerInterface]
|
|
19
|
+
"""(logger, url, on_state) -> radio player"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class States(Enum):
|
|
23
|
+
PLAY = "play" # the stream plays (or connects)
|
|
24
|
+
PAUSED = "paused" # stream ended by the pause button or the loss of the headset
|
|
25
|
+
RESUME = "resume" # deciding after the headset came back: connect again or stay paused
|
|
26
|
+
END = "end"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Transitions(Enum):
|
|
30
|
+
PLAY = "play"
|
|
31
|
+
PAUSE = "pause"
|
|
32
|
+
OUTPUT_LOST = "output_lost"
|
|
33
|
+
OUTPUT_CHANGED = "output_changed"
|
|
34
|
+
STAY = "stay"
|
|
35
|
+
END = "end"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Play(State):
|
|
39
|
+
def __init__(self, logger: logging.Logger, owner: "PlayRadio") -> None:
|
|
40
|
+
super().__init__(States.PLAY, logger)
|
|
41
|
+
self._owner = owner
|
|
42
|
+
|
|
43
|
+
def _on_entry(self) -> Enum | None:
|
|
44
|
+
self._owner.start_player()
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def _on_exit(self) -> None:
|
|
48
|
+
self._owner.stop_player()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Paused(State):
|
|
52
|
+
def __init__(self, logger: logging.Logger, owner: "PlayRadio", rgbled: RGBLed) -> None:
|
|
53
|
+
super().__init__(States.PAUSED, logger)
|
|
54
|
+
self._owner = owner
|
|
55
|
+
self._rgbled = rgbled
|
|
56
|
+
|
|
57
|
+
def _on_entry(self) -> Enum | None:
|
|
58
|
+
self._rgbled.set_alternate_colors(Color.WHITE, Color.BLUE, 1)
|
|
59
|
+
self._owner.report(PlaybackStatus.PAUSED)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Resume(State):
|
|
64
|
+
def __init__(self, logger: logging.Logger, owner: "PlayRadio") -> None:
|
|
65
|
+
super().__init__(States.RESUME, logger)
|
|
66
|
+
self._owner = owner
|
|
67
|
+
|
|
68
|
+
def _on_entry(self) -> Enum | None:
|
|
69
|
+
return Transitions.PLAY if self._owner.pause.resumes_on_output() else Transitions.STAY
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _End(State):
|
|
73
|
+
def __init__(self, logger: logging.Logger, owner: "PlayRadio") -> None:
|
|
74
|
+
super().__init__(States.END, logger)
|
|
75
|
+
self._owner = owner
|
|
76
|
+
|
|
77
|
+
def _on_entry(self) -> Enum | None:
|
|
78
|
+
self._owner.report(PlaybackStatus.STOPPED)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PlayRadio:
|
|
83
|
+
"""Plays ``station`` until :meth:`stop`. LED: green, aqua, blue flowing back and forth from the start of a
|
|
84
|
+
connection until the sound is heard (as while an album waits for a file: it plays as soon as it is there), blue
|
|
85
|
+
while it plays, aqua while it waits to connect again.
|
|
86
|
+
|
|
87
|
+
There is no position: pause ends the stream, play/next/prev connect again (live). Otherwise next/prev do nothing
|
|
88
|
+
(radio stations are changed with the tags). A pause caused by the loss of the headset ends when it comes back
|
|
89
|
+
within :data:`~tinyjukebox.stm.base.RESUME_WINDOW`; a new output while playing connects again on it.
|
|
90
|
+
|
|
91
|
+
All commands are queued (state machine) - stopping the stream may take a moment and never blocks the caller.
|
|
92
|
+
Without ``pubsub_client`` the buttons are not handled and no playback status is published.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
logger: logging.Logger,
|
|
98
|
+
rgbled: RGBLed,
|
|
99
|
+
station: TargetRadio,
|
|
100
|
+
player_factory: RadioPlayerFactory,
|
|
101
|
+
pubsub_client: PubSubClient | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
104
|
+
self._rgbled = rgbled
|
|
105
|
+
self._pubsub_client = pubsub_client
|
|
106
|
+
self.station = station
|
|
107
|
+
self._url = station.url # the station may be changed while it plays - get_info shows what plays
|
|
108
|
+
self._player_factory = player_factory
|
|
109
|
+
self._player: RadioPlayerInterface | None = None
|
|
110
|
+
self.pause = OutputPause()
|
|
111
|
+
self._reported: PlaybackStatus | None = None
|
|
112
|
+
|
|
113
|
+
self._machine = create_machine(self._logger)
|
|
114
|
+
for state in [_Play(self._logger, self), _Paused(self._logger, self, rgbled), _Resume(self._logger, self)]:
|
|
115
|
+
self._machine.addstate(state)
|
|
116
|
+
self._machine.addstate(_End(self._logger, self))
|
|
117
|
+
machine = self._machine
|
|
118
|
+
machine.setstartstate(States.PLAY)
|
|
119
|
+
machine.addtransition(States.PLAY, Transitions.PAUSE, States.PAUSED, self.pause.paused, (PauseReason.USER,))
|
|
120
|
+
machine.addtransition(
|
|
121
|
+
States.PLAY, Transitions.OUTPUT_LOST, States.PAUSED, self.pause.paused, (PauseReason.OUTPUT,)
|
|
122
|
+
)
|
|
123
|
+
machine.addtransition(States.PLAY, Transitions.OUTPUT_CHANGED, States.PLAY) # connect again on the new output
|
|
124
|
+
machine.addtransition(States.PAUSED, Transitions.PLAY, States.PLAY)
|
|
125
|
+
machine.addtransition(States.PAUSED, Transitions.OUTPUT_CHANGED, States.RESUME)
|
|
126
|
+
machine.addtransition(States.RESUME, Transitions.PLAY, States.PLAY)
|
|
127
|
+
machine.addtransition(States.RESUME, Transitions.STAY, States.PAUSED)
|
|
128
|
+
for state_id in (States.PLAY, States.PAUSED, States.RESUME):
|
|
129
|
+
machine.addtransition(state_id, Transitions.END, States.END)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def machine(self) -> object:
|
|
133
|
+
"""The tantamount machine (diagram export, tests)."""
|
|
134
|
+
return self._machine
|
|
135
|
+
|
|
136
|
+
def _state_changed(self, state: RadioState) -> None:
|
|
137
|
+
if state == RadioState.CONNECTING:
|
|
138
|
+
self._rgbled.set_sequence(WAITING)
|
|
139
|
+
elif state == RadioState.PLAYING:
|
|
140
|
+
self._rgbled.set_color(Color.BLUE)
|
|
141
|
+
elif state == RadioState.RETRYING:
|
|
142
|
+
self._rgbled.set_color(Color.AQUA)
|
|
143
|
+
|
|
144
|
+
def start_player(self) -> None:
|
|
145
|
+
"""PLAY: a new player connects to the stream (returns immediately)."""
|
|
146
|
+
self._logger.info("playing radio station %s", self.station.to_text())
|
|
147
|
+
player = self._player_factory(self._logger, self.station.url, self._state_changed)
|
|
148
|
+
self._player = player
|
|
149
|
+
self._url = self.station.url
|
|
150
|
+
player.start()
|
|
151
|
+
self.report(PlaybackStatus.PLAYING)
|
|
152
|
+
|
|
153
|
+
def stop_player(self) -> None:
|
|
154
|
+
player, self._player = self._player, None
|
|
155
|
+
if player is not None:
|
|
156
|
+
player.stop()
|
|
157
|
+
|
|
158
|
+
def report(self, status: PlaybackStatus) -> None:
|
|
159
|
+
"""Publish the playback status (for the headset) if it changed."""
|
|
160
|
+
if status != self._reported and self._pubsub_client is not None:
|
|
161
|
+
self._reported = status
|
|
162
|
+
self._pubsub_client.publish(PLAYBACK, status)
|
|
163
|
+
|
|
164
|
+
def get_state(self) -> States:
|
|
165
|
+
return self._machine.get_active_state().id # type: ignore[no-any-return]
|
|
166
|
+
|
|
167
|
+
def wait_until_idle(self) -> None:
|
|
168
|
+
"""Block until all queued commands have been processed (tests)."""
|
|
169
|
+
self._machine.wait_until_idle()
|
|
170
|
+
|
|
171
|
+
def start(self) -> None:
|
|
172
|
+
"""Connect to the stream. :raises Exception: if the player cannot be started (machine stopped)."""
|
|
173
|
+
try:
|
|
174
|
+
self._machine.start()
|
|
175
|
+
except Exception:
|
|
176
|
+
self._machine.stop()
|
|
177
|
+
raise
|
|
178
|
+
if self._pubsub_client is not None:
|
|
179
|
+
self._pubsub_client.subscribe(BUTTON_PRESSED, self._button_handler)
|
|
180
|
+
|
|
181
|
+
def stop(self) -> None:
|
|
182
|
+
if self._pubsub_client is not None:
|
|
183
|
+
self._pubsub_client.unsubscribe(BUTTON_PRESSED, self._button_handler, ignore_not_found=True)
|
|
184
|
+
self._machine.operate(Transitions.END)
|
|
185
|
+
self._machine.stop()
|
|
186
|
+
|
|
187
|
+
def _button_handler(self, message: object) -> None:
|
|
188
|
+
"""Handler for :data:`~tinyjukebox.messages.BUTTON_PRESSED` - play/pause; next/prev only end a pause."""
|
|
189
|
+
if message == Button.PAUSE:
|
|
190
|
+
self.cmd_pause()
|
|
191
|
+
elif message in (Button.PLAY, Button.NEXT, Button.PREV):
|
|
192
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
193
|
+
|
|
194
|
+
def cmd_pause(self) -> None:
|
|
195
|
+
self._machine.asyncoperate(Transitions.PAUSE)
|
|
196
|
+
|
|
197
|
+
def cmd_play(self) -> None:
|
|
198
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
199
|
+
|
|
200
|
+
def cmd_next_handler(self) -> None:
|
|
201
|
+
self._logger.info("next - only ends a pause for radio stations")
|
|
202
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
203
|
+
|
|
204
|
+
def cmd_prev_handler(self) -> None:
|
|
205
|
+
self._logger.info("prev - only ends a pause for radio stations")
|
|
206
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
207
|
+
|
|
208
|
+
def cmd_restart_album(self) -> None:
|
|
209
|
+
self._logger.info("restart - ignored for radio stations")
|
|
210
|
+
|
|
211
|
+
def cmd_output_lost(self, then: Callable[[], None] | None = None) -> None:
|
|
212
|
+
"""The headset has gone: pause. ``then`` is called in the worker once that has been handled."""
|
|
213
|
+
self._machine.asyncoperate(Transitions.OUTPUT_LOST)
|
|
214
|
+
if then is not None:
|
|
215
|
+
self._machine.call_soon(then)
|
|
216
|
+
|
|
217
|
+
def cmd_output_changed(self) -> None:
|
|
218
|
+
self._machine.asyncoperate(Transitions.OUTPUT_CHANGED)
|
|
219
|
+
|
|
220
|
+
def get_info(self) -> dict[str, Any]:
|
|
221
|
+
player = self._player
|
|
222
|
+
if self.get_state() == States.END:
|
|
223
|
+
state, error = RadioState.STOPPED.value, None
|
|
224
|
+
elif self.get_state() != States.PLAY:
|
|
225
|
+
state, error = "paused", None
|
|
226
|
+
elif player is None:
|
|
227
|
+
state, error = RadioState.CONNECTING.value, None
|
|
228
|
+
else:
|
|
229
|
+
state, error = player.state.value, player.error
|
|
230
|
+
return {
|
|
231
|
+
"target_id": self.station.target_id,
|
|
232
|
+
"target_type": "radio",
|
|
233
|
+
"name": self.station.name,
|
|
234
|
+
"url": self._url,
|
|
235
|
+
"state": state,
|
|
236
|
+
"error": error,
|
|
237
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""PlaySpotify - plays the entry of Spotify linked to a tag (the counterpart of STMPlayAlbum for Spotify).
|
|
2
|
+
|
|
3
|
+
Like an album it has a position, which is saved every ``update-schedule`` seconds and when it is left, and the buttons
|
|
4
|
+
work as with an album: play/pause, "next", "back" (Soloist decides: the track before within the first seconds,
|
|
5
|
+
otherwise the start of the track), "back" held: from the beginning. The last track ended: finished. The playback
|
|
6
|
+
itself is :class:`~tinyjukebox.albumplayer.spotifyplayer.SpotifyPlayer`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from tinyjukebox.albumplayer.spotifyplayer import SpotifyState, Track
|
|
15
|
+
from tinyjukebox.core.logging import get_child
|
|
16
|
+
from tinyjukebox.core.pubsub import PubSubClient
|
|
17
|
+
from tinyjukebox.data.activity import ActivityAlbum
|
|
18
|
+
from tinyjukebox.data.target import TargetSpotify
|
|
19
|
+
from tinyjukebox.messages import (
|
|
20
|
+
BUTTON_HELD,
|
|
21
|
+
BUTTON_PRESSED,
|
|
22
|
+
BUTTON_RELEASED,
|
|
23
|
+
JOURNAL,
|
|
24
|
+
PLAYBACK,
|
|
25
|
+
Button,
|
|
26
|
+
JournalEvent,
|
|
27
|
+
PlaybackStatus,
|
|
28
|
+
)
|
|
29
|
+
from tinyjukebox.rgbled import Color, RGBLed
|
|
30
|
+
from tinyjukebox.stm.base import OutputPause, PauseReason, PeriodicTask, State, create_machine
|
|
31
|
+
from tinyjukebox.stm.stmplayalbum import BothButtons
|
|
32
|
+
from tinyjukebox.sync import SOFTWARE, actor
|
|
33
|
+
|
|
34
|
+
__all__ = ["PlaySpotify", "SpotifyPlayerFactory", "SpotifyPlayerInterface", "States", "Transitions"]
|
|
35
|
+
|
|
36
|
+
_LED = {
|
|
37
|
+
SpotifyState.CONNECTING: Color.AQUA,
|
|
38
|
+
SpotifyState.RETRYING: Color.AQUA,
|
|
39
|
+
SpotifyState.PLAYING: Color.BLUE,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SpotifyPlayerInterface(Protocol):
|
|
44
|
+
"""What PlaySpotify needs of :class:`~tinyjukebox.albumplayer.spotifyplayer.SpotifyPlayer`."""
|
|
45
|
+
|
|
46
|
+
on_state: Callable[[SpotifyState], None]
|
|
47
|
+
on_track: Callable[[Track, float, bool], None]
|
|
48
|
+
on_finished: Callable[[], None]
|
|
49
|
+
error: str | None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def state(self) -> SpotifyState: ...
|
|
53
|
+
|
|
54
|
+
def start(self, track: Track, seconds: float) -> None: ...
|
|
55
|
+
|
|
56
|
+
def pause(self) -> None: ...
|
|
57
|
+
|
|
58
|
+
def play(self) -> None: ...
|
|
59
|
+
|
|
60
|
+
def next(self) -> None: ...
|
|
61
|
+
|
|
62
|
+
def prev(self) -> None: ...
|
|
63
|
+
|
|
64
|
+
def restart(self) -> None: ...
|
|
65
|
+
|
|
66
|
+
def output_changed(self) -> None: ...
|
|
67
|
+
|
|
68
|
+
def stop(self) -> None: ...
|
|
69
|
+
|
|
70
|
+
def position(self) -> tuple[Track, float]: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
SpotifyPlayerFactory = Callable[[logging.Logger, str], SpotifyPlayerInterface]
|
|
74
|
+
"""(logger, uri) -> player"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class States(Enum):
|
|
78
|
+
PLAY = "play" # plays (or connects, or goes to the saved position)
|
|
79
|
+
PAUSED = "paused" # pause button, the headset lost, or the account plays elsewhere
|
|
80
|
+
RESUME = "resume" # deciding after the headset came back: continue or stay paused
|
|
81
|
+
FINISHED = "finished"
|
|
82
|
+
END = "end"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Transitions(Enum):
|
|
86
|
+
PLAY = "play"
|
|
87
|
+
PAUSE = "pause"
|
|
88
|
+
ELSEWHERE = "elsewhere"
|
|
89
|
+
OUTPUT_LOST = "output_lost"
|
|
90
|
+
OUTPUT_CHANGED = "output_changed"
|
|
91
|
+
STAY = "stay"
|
|
92
|
+
FINISHED = "finished"
|
|
93
|
+
RESTART = "restart"
|
|
94
|
+
END = "end"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Play(State):
|
|
98
|
+
def __init__(self, logger: logging.Logger, owner: "PlaySpotify", update_schedule: float) -> None:
|
|
99
|
+
super().__init__(States.PLAY, logger)
|
|
100
|
+
self._owner = owner
|
|
101
|
+
self._update_schedule = update_schedule
|
|
102
|
+
self._task: PeriodicTask | None = None
|
|
103
|
+
|
|
104
|
+
def _on_entry(self) -> Enum | None:
|
|
105
|
+
self._owner.playing()
|
|
106
|
+
self._task = PeriodicTask(self._update_schedule, self._owner.save, "PlaySpotify.update", self._logger)
|
|
107
|
+
self._task.start()
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _on_exit(self) -> None:
|
|
111
|
+
if self._task is not None:
|
|
112
|
+
self._task.stop()
|
|
113
|
+
self._task = None
|
|
114
|
+
self._owner.save()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _Paused(State):
|
|
118
|
+
def __init__(self, logger: logging.Logger, owner: "PlaySpotify", rgbled: RGBLed) -> None:
|
|
119
|
+
super().__init__(States.PAUSED, logger)
|
|
120
|
+
self._owner = owner
|
|
121
|
+
self._rgbled = rgbled
|
|
122
|
+
|
|
123
|
+
def _on_entry(self) -> Enum | None:
|
|
124
|
+
self._owner.player.pause()
|
|
125
|
+
self._rgbled.set_alternate_colors(Color.WHITE, Color.BLUE, 1)
|
|
126
|
+
self._owner.report(PlaybackStatus.PAUSED)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _Resume(State):
|
|
131
|
+
def __init__(self, logger: logging.Logger, owner: "PlaySpotify") -> None:
|
|
132
|
+
super().__init__(States.RESUME, logger)
|
|
133
|
+
self._owner = owner
|
|
134
|
+
|
|
135
|
+
def _on_entry(self) -> Enum | None:
|
|
136
|
+
return Transitions.PLAY if self._owner.pause.resumes_on_output() else Transitions.STAY
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Finished(State):
|
|
140
|
+
def __init__(self, logger: logging.Logger, owner: "PlaySpotify", rgbled: RGBLed) -> None:
|
|
141
|
+
super().__init__(States.FINISHED, logger)
|
|
142
|
+
self._owner = owner
|
|
143
|
+
self._rgbled = rgbled
|
|
144
|
+
|
|
145
|
+
def _on_entry(self) -> Enum | None:
|
|
146
|
+
self._rgbled.set_blink_color(Color.BLUE, 1)
|
|
147
|
+
self._owner.save(finished=True)
|
|
148
|
+
self._owner.note("end", reason="album finished")
|
|
149
|
+
self._owner.report(PlaybackStatus.STOPPED)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _End(State):
|
|
154
|
+
def __init__(self, logger: logging.Logger, owner: "PlaySpotify") -> None:
|
|
155
|
+
super().__init__(States.END, logger)
|
|
156
|
+
self._owner = owner
|
|
157
|
+
|
|
158
|
+
def _on_entry(self) -> Enum | None:
|
|
159
|
+
self._owner.report(PlaybackStatus.STOPPED)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class PlaySpotify:
|
|
164
|
+
"""Plays ``entry`` from the position of ``activity`` until :meth:`stop` (``activity`` None: a test - from the
|
|
165
|
+
beginning, nothing is saved). LED: aqua while it connects and goes to the position, blue while it plays.
|
|
166
|
+
|
|
167
|
+
``check()`` raises if Spotify cannot play on this box (no key, not connected, no player): :meth:`start` raises
|
|
168
|
+
then, and the tag ends as "cannot be played". ``lost()`` is called when Soloist says that the box has no login.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
logger: logging.Logger,
|
|
174
|
+
rgbled: RGBLed,
|
|
175
|
+
entry: TargetSpotify,
|
|
176
|
+
activity: ActivityAlbum | None,
|
|
177
|
+
player_factory: SpotifyPlayerFactory,
|
|
178
|
+
pubsub_client: PubSubClient | None = None,
|
|
179
|
+
update_schedule: float = 5.0,
|
|
180
|
+
check: Callable[[], object] = lambda: None,
|
|
181
|
+
lost: Callable[[], None] = lambda: None,
|
|
182
|
+
test: bool = False,
|
|
183
|
+
) -> None:
|
|
184
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
185
|
+
self._rgbled = rgbled
|
|
186
|
+
self._pubsub_client = pubsub_client
|
|
187
|
+
self.entry = entry
|
|
188
|
+
self._uri = entry.uri
|
|
189
|
+
self.activity = activity
|
|
190
|
+
self._check = check
|
|
191
|
+
self.test = test
|
|
192
|
+
self.player = player_factory(self._logger, entry.uri)
|
|
193
|
+
self.player.on_state = self._state_changed
|
|
194
|
+
self.player.on_track = self._track_left
|
|
195
|
+
self.player.on_finished = lambda: self._machine.asyncoperate(Transitions.FINISHED)
|
|
196
|
+
if hasattr(self.player, "on_lost"):
|
|
197
|
+
self.player.on_lost = lost
|
|
198
|
+
if hasattr(self.player, "on_output_lost"):
|
|
199
|
+
self.player.on_output_lost = self.cmd_output_lost
|
|
200
|
+
self.pause = OutputPause()
|
|
201
|
+
self._both_buttons = BothButtons()
|
|
202
|
+
self._reported: PlaybackStatus | None = None
|
|
203
|
+
self._started = False
|
|
204
|
+
|
|
205
|
+
self._machine = create_machine(self._logger)
|
|
206
|
+
for state in (
|
|
207
|
+
_Play(self._logger, self, update_schedule),
|
|
208
|
+
_Paused(self._logger, self, rgbled),
|
|
209
|
+
_Resume(self._logger, self),
|
|
210
|
+
_Finished(self._logger, self, rgbled),
|
|
211
|
+
_End(self._logger, self),
|
|
212
|
+
):
|
|
213
|
+
self._machine.addstate(state)
|
|
214
|
+
machine = self._machine
|
|
215
|
+
machine.setstartstate(States.PLAY)
|
|
216
|
+
machine.addtransition(States.PLAY, Transitions.PAUSE, States.PAUSED, self.pause.paused, (PauseReason.USER,))
|
|
217
|
+
machine.addtransition(States.PLAY, Transitions.ELSEWHERE, States.PAUSED, self.pause.paused, (PauseReason.USER,))
|
|
218
|
+
machine.addtransition(
|
|
219
|
+
States.PLAY, Transitions.OUTPUT_LOST, States.PAUSED, self.pause.paused, (PauseReason.OUTPUT,)
|
|
220
|
+
)
|
|
221
|
+
machine.addtransition(States.PAUSED, Transitions.PLAY, States.PLAY)
|
|
222
|
+
machine.addtransition(States.PAUSED, Transitions.OUTPUT_CHANGED, States.RESUME)
|
|
223
|
+
machine.addtransition(States.RESUME, Transitions.PLAY, States.PLAY)
|
|
224
|
+
machine.addtransition(States.RESUME, Transitions.STAY, States.PAUSED)
|
|
225
|
+
for state_id in (States.PLAY, States.PAUSED):
|
|
226
|
+
machine.addtransition(state_id, Transitions.FINISHED, States.FINISHED)
|
|
227
|
+
for state_id in (States.PAUSED, States.FINISHED):
|
|
228
|
+
machine.addtransition(state_id, Transitions.RESTART, States.PLAY, self.player.restart, ())
|
|
229
|
+
machine.addtransition(States.PLAY, Transitions.RESTART, States.PLAY, self.player.restart, ())
|
|
230
|
+
for state_id in (States.PLAY, States.PAUSED, States.RESUME, States.FINISHED):
|
|
231
|
+
machine.addtransition(state_id, Transitions.END, States.END)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def machine(self) -> object:
|
|
235
|
+
return self._machine
|
|
236
|
+
|
|
237
|
+
# --- for the states ----------------------------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def playing(self) -> None:
|
|
240
|
+
"""PLAY: the first time the playback starts at the saved position, later it continues."""
|
|
241
|
+
if self._started:
|
|
242
|
+
self.player.play()
|
|
243
|
+
else:
|
|
244
|
+
self._started = True
|
|
245
|
+
activity = self.activity
|
|
246
|
+
if activity is None or activity.finished:
|
|
247
|
+
track, seconds = Track(), 0.0
|
|
248
|
+
else:
|
|
249
|
+
track, seconds = Track(activity.track_id, activity.track_uri), activity.track_time
|
|
250
|
+
self._logger.info("playing %s from track %d, %.0f s", self.entry.to_text(), track.index, seconds)
|
|
251
|
+
self.player.start(track, seconds)
|
|
252
|
+
self._state_changed(self.player.state)
|
|
253
|
+
self.report(PlaybackStatus.PLAYING)
|
|
254
|
+
|
|
255
|
+
def save(self, finished: bool = False) -> None:
|
|
256
|
+
"""The position into the activity of the tag."""
|
|
257
|
+
if self.activity is None or self.player.state in (SpotifyState.CONNECTING, SpotifyState.RETRYING):
|
|
258
|
+
return
|
|
259
|
+
track, seconds = self.player.position()
|
|
260
|
+
if track.uri is None:
|
|
261
|
+
return
|
|
262
|
+
self.activity.update(track.index, round(seconds, 1), finished, track.uri)
|
|
263
|
+
|
|
264
|
+
def report(self, status: PlaybackStatus) -> None:
|
|
265
|
+
if status != self._reported and self._pubsub_client is not None:
|
|
266
|
+
self._reported = status
|
|
267
|
+
self._pubsub_client.publish(PLAYBACK, status)
|
|
268
|
+
|
|
269
|
+
def note(self, event: str, **fields: object) -> None:
|
|
270
|
+
"""Something for the journal of the box - not for a test."""
|
|
271
|
+
if self._pubsub_client is not None and not self.test:
|
|
272
|
+
self._pubsub_client.publish(JOURNAL, JournalEvent.of(event, album=str(self.entry.target_id), **fields))
|
|
273
|
+
|
|
274
|
+
def _control(self, action: str) -> None:
|
|
275
|
+
who = actor()
|
|
276
|
+
self.note("control", action=action, by="button" if who == SOFTWARE else who)
|
|
277
|
+
|
|
278
|
+
def _state_changed(self, state: SpotifyState) -> None:
|
|
279
|
+
"""From the player: the LED; the account plays elsewhere: pause."""
|
|
280
|
+
color = _LED.get(state)
|
|
281
|
+
if self.get_state() != States.PLAY:
|
|
282
|
+
return
|
|
283
|
+
if color is not None:
|
|
284
|
+
self._rgbled.set_color(color)
|
|
285
|
+
elif state == SpotifyState.ERROR:
|
|
286
|
+
self._rgbled.set_alternate_colors(Color.RED, Color.BLACK, 1)
|
|
287
|
+
elif state == SpotifyState.ELSEWHERE:
|
|
288
|
+
self._machine.asyncoperate(Transitions.ELSEWHERE)
|
|
289
|
+
|
|
290
|
+
def _track_left(self, track: Track, seconds: float, to_end: bool) -> None:
|
|
291
|
+
self.note("track", track=track.index, seconds=round(seconds, 1), to_end=to_end, uri=track.uri,
|
|
292
|
+
title=track.title) # fmt: skip
|
|
293
|
+
|
|
294
|
+
# --- commands ----------------------------------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def get_state(self) -> States:
|
|
297
|
+
return self._machine.get_active_state().id # type: ignore[no-any-return]
|
|
298
|
+
|
|
299
|
+
def wait_until_idle(self) -> None:
|
|
300
|
+
self._machine.wait_until_idle()
|
|
301
|
+
|
|
302
|
+
def start(self) -> None:
|
|
303
|
+
"""Start the playback. :raises Exception: if Spotify cannot play on this box."""
|
|
304
|
+
self._check()
|
|
305
|
+
try:
|
|
306
|
+
self._machine.start()
|
|
307
|
+
except Exception:
|
|
308
|
+
self._machine.stop()
|
|
309
|
+
raise
|
|
310
|
+
if self._pubsub_client is not None:
|
|
311
|
+
self._pubsub_client.subscribe(BUTTON_PRESSED, self._button_handler)
|
|
312
|
+
self._pubsub_client.subscribe(BUTTON_HELD, self._held_handler)
|
|
313
|
+
self._pubsub_client.subscribe(BUTTON_RELEASED, self._released_handler)
|
|
314
|
+
|
|
315
|
+
def stop(self) -> None:
|
|
316
|
+
if self._pubsub_client is not None:
|
|
317
|
+
self._pubsub_client.unsubscribe(BUTTON_PRESSED, self._button_handler, ignore_not_found=True)
|
|
318
|
+
self._pubsub_client.unsubscribe(BUTTON_HELD, self._held_handler, ignore_not_found=True)
|
|
319
|
+
self._pubsub_client.unsubscribe(BUTTON_RELEASED, self._released_handler, ignore_not_found=True)
|
|
320
|
+
self._machine.operate(Transitions.END)
|
|
321
|
+
self._machine.stop()
|
|
322
|
+
self.player.stop()
|
|
323
|
+
|
|
324
|
+
def _button_handler(self, message: object) -> None:
|
|
325
|
+
self._both_buttons.pressed(message)
|
|
326
|
+
command = {
|
|
327
|
+
Button.NEXT: self.cmd_next_handler,
|
|
328
|
+
Button.PREV: self.cmd_prev_handler,
|
|
329
|
+
Button.PLAY: self.cmd_play,
|
|
330
|
+
Button.PAUSE: self.cmd_pause,
|
|
331
|
+
}.get(message) # type: ignore[call-overload]
|
|
332
|
+
if command is not None:
|
|
333
|
+
command()
|
|
334
|
+
|
|
335
|
+
def _released_handler(self, message: object) -> None:
|
|
336
|
+
self._both_buttons.released(message)
|
|
337
|
+
|
|
338
|
+
def _held_handler(self, message: object) -> None:
|
|
339
|
+
if message == Button.PREV and not self._both_buttons.next_is_pressed():
|
|
340
|
+
self.cmd_restart_album()
|
|
341
|
+
|
|
342
|
+
def cmd_pause(self) -> None:
|
|
343
|
+
self._control("pause")
|
|
344
|
+
self._machine.asyncoperate(Transitions.PAUSE)
|
|
345
|
+
|
|
346
|
+
def cmd_play(self) -> None:
|
|
347
|
+
self._control("play")
|
|
348
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
349
|
+
|
|
350
|
+
def cmd_next_handler(self) -> None:
|
|
351
|
+
""" "Next": the next track; in a pause it continues (as an album)."""
|
|
352
|
+
self._control("next")
|
|
353
|
+
if self.get_state() == States.PLAY:
|
|
354
|
+
self.player.next()
|
|
355
|
+
else:
|
|
356
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
357
|
+
|
|
358
|
+
def cmd_prev_handler(self) -> None:
|
|
359
|
+
""" "Back": Soloist goes to the start of the track or to the one before; finished: from the beginning."""
|
|
360
|
+
self._control("prev")
|
|
361
|
+
state = self.get_state()
|
|
362
|
+
if state == States.FINISHED:
|
|
363
|
+
self._machine.asyncoperate(Transitions.RESTART)
|
|
364
|
+
return
|
|
365
|
+
self.player.prev()
|
|
366
|
+
if state == States.PAUSED:
|
|
367
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
368
|
+
|
|
369
|
+
def cmd_restart_album(self) -> None:
|
|
370
|
+
self._control("restart")
|
|
371
|
+
self._machine.asyncoperate(Transitions.RESTART)
|
|
372
|
+
|
|
373
|
+
def cmd_output_lost(self, then: Callable[[], None] | None = None) -> None:
|
|
374
|
+
"""The headset has gone: pause. ``then`` is called in the worker once that has been handled - the player is
|
|
375
|
+
silent then (:meth:`SpotifyPlayer.pause` mutes at once)."""
|
|
376
|
+
self._machine.asyncoperate(Transitions.OUTPUT_LOST)
|
|
377
|
+
if then is not None:
|
|
378
|
+
self._machine.call_soon(then)
|
|
379
|
+
|
|
380
|
+
def cmd_output_changed(self) -> None:
|
|
381
|
+
"""Another output (headset or speaker) - decided in the worker, after what was queued before (a loss of the
|
|
382
|
+
headset): in PLAY the sound goes on there, in a pause RESUME decides. Returns at once."""
|
|
383
|
+
self._machine.call_soon(self._output_changed)
|
|
384
|
+
|
|
385
|
+
def _output_changed(self) -> None:
|
|
386
|
+
if self.get_state() == States.PLAY:
|
|
387
|
+
self.player.output_changed() # another aplay, in the thread of the player
|
|
388
|
+
else:
|
|
389
|
+
self._machine.operate(Transitions.OUTPUT_CHANGED)
|
|
390
|
+
|
|
391
|
+
def get_info(self) -> dict[str, Any]:
|
|
392
|
+
track, seconds = self.player.position()
|
|
393
|
+
state = self.get_state()
|
|
394
|
+
if state == States.PLAY:
|
|
395
|
+
shown = self.player.state.value
|
|
396
|
+
elif state == States.FINISHED:
|
|
397
|
+
shown = "finished"
|
|
398
|
+
elif state == States.END:
|
|
399
|
+
shown = "stopped"
|
|
400
|
+
else:
|
|
401
|
+
shown = "paused"
|
|
402
|
+
return {
|
|
403
|
+
"target_id": self.entry.target_id,
|
|
404
|
+
"target_type": "spotify",
|
|
405
|
+
"name": self.entry.name,
|
|
406
|
+
"uri": self._uri,
|
|
407
|
+
"kind": self.entry.kind,
|
|
408
|
+
"state": shown,
|
|
409
|
+
"error": self.player.error,
|
|
410
|
+
"test": self.test,
|
|
411
|
+
"track": {
|
|
412
|
+
"track_id": track.index,
|
|
413
|
+
"uri": track.uri,
|
|
414
|
+
"title": track.title,
|
|
415
|
+
"pos": seconds,
|
|
416
|
+
"length": track.length,
|
|
417
|
+
},
|
|
418
|
+
}
|