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,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
+ }