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,655 @@
1
+ """STMPlayAlbum - the state machine that plays one album (next/prev/play/pause buttons, prev held: from the
2
+ beginning, pause between tracks, resume, switching between speaker and headset, waiting for a track that is still
3
+ being fetched from the library)."""
4
+
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from tinyjukebox.albumplayer.player_interface import Hook, PlayerInterface
15
+ from tinyjukebox.core import unexpected
16
+ from tinyjukebox.core.logging import get_child
17
+ from tinyjukebox.core.pubsub import PubSubClient
18
+ from tinyjukebox.data.activity import ActivityAlbum
19
+ from tinyjukebox.data.target import TargetAlbum
20
+ from tinyjukebox.messages import (
21
+ BUTTON_HELD,
22
+ BUTTON_PRESSED,
23
+ BUTTON_RELEASED,
24
+ JOURNAL,
25
+ PLAYBACK,
26
+ Button,
27
+ JournalEvent,
28
+ PlaybackStatus,
29
+ )
30
+ from tinyjukebox.rgbled import WAITING, Color, RGBLed
31
+ from tinyjukebox.stm.base import OutputPause, PauseReason, PeriodicTask, State, create_machine, write_dot_file
32
+ from tinyjukebox.sync import SOFTWARE, actor
33
+
34
+ if TYPE_CHECKING:
35
+ from tinyjukebox.sync.files import AlbumFiles
36
+
37
+ __all__ = [
38
+ "BOTH_BUTTONS_WINDOW",
39
+ "OUTPUT_REWIND",
40
+ "BothButtons",
41
+ "PlayerFactory",
42
+ "STMPlayAlbum",
43
+ "States",
44
+ "Transitions",
45
+ ]
46
+
47
+ PlayerFactory = Callable[[logging.Logger, TargetAlbum, Hook, Hook], PlayerInterface]
48
+ """(logger, album, end_of_track_hook, end_of_album_hook) -> player"""
49
+
50
+ OUTPUT_REWIND = 1.0
51
+ """Seconds a track goes back when the output changes or is lost - the sound in the buffers has not been heard."""
52
+
53
+ MAX_ERROR_LENGTH = 500
54
+ """Characters of what the decoder said about a damaged file that are noted for the library - its end."""
55
+
56
+ BOTH_BUTTONS_WINDOW = 2.0
57
+ """Seconds: 'next' pressed at most this long before 'prev' (and not released) - or after it - makes a held 'prev' the
58
+ gesture of both buttons (pairing a headset, see :mod:`tinyjukebox.headset`) and not a restart of the album. Presses
59
+ without release (web page, buttons of the headset) are forgotten after it."""
60
+
61
+
62
+ class BothButtons:
63
+ """Tells whether 'next' is pressed together with a held 'prev'. Fed by the button handlers, which all run in the
64
+ pubsub dispatch thread."""
65
+
66
+ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
67
+ self._clock = clock
68
+ self._pressed_at: dict[Button, float] = {}
69
+
70
+ def pressed(self, button: object) -> None:
71
+ if isinstance(button, Button):
72
+ self._pressed_at[button] = self._clock()
73
+
74
+ def released(self, button: object) -> None:
75
+ if isinstance(button, Button):
76
+ self._pressed_at.pop(button, None)
77
+
78
+ def next_is_pressed(self) -> bool:
79
+ """Called when 'prev' is held."""
80
+ next_pressed_at = self._pressed_at.get(Button.NEXT)
81
+ if next_pressed_at is None:
82
+ return False
83
+ prev_pressed_at = self._pressed_at.get(Button.PREV, self._clock()) # held without press: web page
84
+ return next_pressed_at >= prev_pressed_at - BOTH_BUTTONS_WINDOW
85
+
86
+
87
+ class States(Enum):
88
+ """States of the STMPlayAlbum state machine."""
89
+
90
+ INIT = "init" # loading the album
91
+ PLAY = "play" # playing the current track
92
+ TONEXT = "tonext" # switching to the next track
93
+ DELAY = "delay" # pause between two tracks
94
+ TOPREV = "toprev" # switching to the previous track
95
+ PREV = "prev" # deciding: rewind the current track or go to the previous one
96
+ REWIND = "rewind" # rewinding the current track
97
+ FINISHED = "finished" # album played to its end
98
+ END = "end" # final state
99
+ FIRST = "first" # switching to the first track
100
+ PAUSED = "paused" # paused (play/pause button or headset lost) - the position is kept
101
+ RESUME = "resume" # deciding after the headset came back: continue or stay paused
102
+ WAIT = "wait" # the file of the current track has not arrived from the library yet
103
+
104
+
105
+ class Transitions(Enum):
106
+ """Transitions of the STMPlayAlbum state machine."""
107
+
108
+ END = "end"
109
+ PLAY = "play"
110
+ NEXT = "next"
111
+ DELAY = "delay"
112
+ PREV = "prev"
113
+ TOPREV = "toprev"
114
+ REWIND = "rewind"
115
+ FIRST = "first"
116
+ FINISHED = "finished"
117
+ PAUSE = "pause" # play/pause button
118
+ OUTPUT_LOST = "output_lost" # the headset has gone - pause
119
+ OUTPUT_CHANGED = "output_changed" # the box plays on another output now (headset connected)
120
+ STAY = "stay" # stay paused
121
+ WAIT = "wait" # the file of the current track is not there yet
122
+ ARRIVED = "arrived" # a file of the album has arrived
123
+
124
+
125
+ class _PlayerState(State):
126
+ """State with access to the album player (created in INIT)."""
127
+
128
+ def __init__(self, state_id: States, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
129
+ super().__init__(state_id, logger)
130
+ self._owner = owner
131
+
132
+ @property
133
+ def _player(self) -> PlayerInterface:
134
+ player = self._owner.player
135
+ if player is None:
136
+ raise RuntimeError("album player not initialized")
137
+ return player
138
+
139
+
140
+ class Init(_PlayerState):
141
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
142
+ super().__init__(States.INIT, logger, owner)
143
+ self._rgbled = rgbled
144
+
145
+ def _on_entry(self) -> Enum | None:
146
+ self._rgbled.set_color(Color.AQUA)
147
+ activity = self._owner.activity
148
+ self._logger.info("initializing album: %s", self._owner.album.to_text())
149
+ player = self._owner.create_player()
150
+ player.start_player()
151
+ if activity.finished:
152
+ self._logger.info("album has been finished last time - starting from the beginning")
153
+ elif not 0 <= activity.track_id < player.album_track_count():
154
+ # e.g. the playlist has been changed since the position has been stored
155
+ self._logger.warning(
156
+ "stored track id %s is not valid for an album with %s tracks - starting from the beginning",
157
+ activity.track_id,
158
+ player.album_track_count(),
159
+ )
160
+ else:
161
+ player.set_track(activity.track_id, activity.track_time)
162
+ self._owner.player = player
163
+ return Transitions.PLAY
164
+
165
+
166
+ class Play(_PlayerState):
167
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed, update_schedule: float) -> None:
168
+ super().__init__(States.PLAY, logger, owner)
169
+ self._rgbled = rgbled
170
+ self._update_schedule = update_schedule
171
+ self._update_task: PeriodicTask | None = None
172
+ self._started: tuple[int, float] | None = None # track and position at which the playback began
173
+
174
+ def _update_activity(self) -> None:
175
+ player = self._player
176
+ self._owner.save_position(player.current_track_id(), player.current_track_time(), player.album_end_reached())
177
+
178
+ def _on_entry(self) -> Enum | None:
179
+ if not self._player.current_track_ready():
180
+ return Transitions.WAIT
181
+ self._rgbled.set_color(Color.BLUE)
182
+ self._update_activity()
183
+ try:
184
+ self._player.play()
185
+ except OSError as error:
186
+ if not self._owner.started:
187
+ raise # the first track at the start: the album cannot be played (STMOperation shows it as broken)
188
+ # later ffmpeg or aplay cannot be started (removed meanwhile, no process left): paused - "play" tries again
189
+ self._logger.error("the track cannot be played: %s", error)
190
+ unexpected.report(error, "album player")
191
+ return Transitions.PAUSE
192
+ self._started = (self._player.current_track_id(), self._player.current_track_time())
193
+ self._owner.report(PlaybackStatus.PLAYING)
194
+ # started after play() succeeded - on_exit is not called if on_entry raises
195
+ self._update_task = PeriodicTask(
196
+ self._update_schedule, self._update_activity, "STMPlayAlbum.update", self._logger
197
+ )
198
+ self._update_task.start()
199
+ return None
200
+
201
+ def _on_exit(self) -> None:
202
+ self._player.pause()
203
+ if self._update_task is not None:
204
+ self._update_task.stop()
205
+ self._update_task = None
206
+ self._update_activity()
207
+ if self._started is not None:
208
+ # for the journal: which track was listened to, for how long, and whether to its end
209
+ player, (track, position) = self._player, self._started
210
+ self._started = None
211
+ self._owner.note(
212
+ "track",
213
+ track=track,
214
+ seconds=round(max(0.0, player.current_track_time() - position), 1),
215
+ to_end=player.current_track_id() == track and player.current_track_end_reached(),
216
+ )
217
+
218
+
219
+ class ToNext(_PlayerState):
220
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
221
+ super().__init__(States.TONEXT, logger, owner)
222
+
223
+ def _on_entry(self) -> Enum | None:
224
+ return Transitions.PLAY if self._player.to_next() else Transitions.FINISHED
225
+
226
+
227
+ class Prev(_PlayerState):
228
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rewind_max_time: float) -> None:
229
+ super().__init__(States.PREV, logger, owner)
230
+ self._rewind_max_time = rewind_max_time
231
+
232
+ def _on_entry(self) -> Enum | None:
233
+ # within the first seconds of a track 'prev' goes to the previous track, later it rewinds the current one
234
+ if self._player.current_track_time() <= self._rewind_max_time:
235
+ return Transitions.TOPREV
236
+ return Transitions.REWIND
237
+
238
+
239
+ class Rewind(_PlayerState):
240
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
241
+ super().__init__(States.REWIND, logger, owner)
242
+
243
+ def _on_entry(self) -> Enum | None:
244
+ self._player.rewind_track()
245
+ return Transitions.PLAY
246
+
247
+
248
+ class ToPrev(_PlayerState):
249
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
250
+ super().__init__(States.TOPREV, logger, owner)
251
+
252
+ def _on_entry(self) -> Enum | None:
253
+ self._player.to_prev()
254
+ return Transitions.PLAY
255
+
256
+
257
+ class First(_PlayerState):
258
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
259
+ super().__init__(States.FIRST, logger, owner)
260
+
261
+ def _on_entry(self) -> Enum | None:
262
+ self._player.to_first()
263
+ return Transitions.PLAY
264
+
265
+
266
+ class Finished(_PlayerState):
267
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
268
+ super().__init__(States.FINISHED, logger, owner)
269
+ self._rgbled = rgbled
270
+
271
+ def _on_entry(self) -> Enum | None:
272
+ self._rgbled.set_blink_color(Color.BLUE, 1)
273
+ player = self._player
274
+ self._owner.save_position(player.current_track_id(), player.current_track_time(), True)
275
+ self._owner.report(PlaybackStatus.STOPPED)
276
+ self._owner.note("end", reason="album finished")
277
+ return None
278
+
279
+
280
+ class Paused(_PlayerState):
281
+ """Nothing plays, the position is stored; the tag stays on the box. LED: white/blue, slowly alternating."""
282
+
283
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
284
+ super().__init__(States.PAUSED, logger, owner)
285
+ self._rgbled = rgbled
286
+
287
+ def _on_entry(self) -> Enum | None:
288
+ self._rgbled.set_alternate_colors(Color.WHITE, Color.BLUE, 1)
289
+ player = self._player
290
+ self._owner.save_position(player.current_track_id(), player.current_track_time(), False)
291
+ self._owner.report(PlaybackStatus.PAUSED)
292
+ return None
293
+
294
+
295
+ class Wait(_PlayerState):
296
+ """The playback is halted until the file of the current track has arrived from the library. LED: green, aqua,
297
+ blue flowing back and forth."""
298
+
299
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
300
+ super().__init__(States.WAIT, logger, owner)
301
+ self._rgbled = rgbled
302
+
303
+ def _on_entry(self) -> Enum | None:
304
+ self._rgbled.set_sequence(WAITING)
305
+ player = self._player
306
+ self._owner.save_position(player.current_track_id(), player.current_track_time(), False)
307
+ # "next" on a missing track pulls this track forward in the download
308
+ self._owner.want_track(player.current_track_id())
309
+ return Transitions.ARRIVED if player.current_track_ready() else None
310
+
311
+
312
+ class Resume(_PlayerState):
313
+ """The headset has come back: a pause caused by its loss is continued (within the resume window)."""
314
+
315
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
316
+ super().__init__(States.RESUME, logger, owner)
317
+
318
+ def _on_entry(self) -> Enum | None:
319
+ return Transitions.PLAY if self._owner.pause.resumes_on_output() else Transitions.STAY
320
+
321
+
322
+ class End(_PlayerState):
323
+ def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
324
+ super().__init__(States.END, logger, owner)
325
+
326
+ def _on_entry(self) -> Enum | None:
327
+ if self._owner.player is not None:
328
+ self._owner.player.stop_player()
329
+ self._owner.report(PlaybackStatus.STOPPED)
330
+ return None
331
+
332
+
333
+ class Delay(State):
334
+ def __init__(self, logger: logging.Logger) -> None:
335
+ super().__init__(States.DELAY, logger)
336
+
337
+
338
+ class STMPlayAlbum:
339
+ """Plays ``album`` from the position stored in ``activity`` and keeps the position up to date.
340
+
341
+ The next/prev/play/pause buttons arrive on :data:`~tinyjukebox.messages.BUTTON_PRESSED`. 'prev' held
342
+ (:data:`~tinyjukebox.messages.BUTTON_HELD`, after the ``hold_timeout`` of its input driver) plays the album from the
343
+ beginning - unless 'next' is pressed as well (pairing a headset). All events - buttons, end of
344
+ track, web page, headset - are queued (``asyncoperate``); handlers never block. The playback status is published
345
+ to :data:`~tinyjukebox.messages.PLAYBACK` (for the headset).
346
+
347
+ Pause: play and next continue at the same position, prev goes back at once (as while playing). A pause caused by
348
+ the loss of the headset (:meth:`cmd_output_lost`) is continued when the headset comes back within
349
+ :data:`~tinyjukebox.stm.base.RESUME_WINDOW` (:meth:`cmd_output_changed`).
350
+ """
351
+
352
+ def __init__(
353
+ self,
354
+ config: Mapping[str, Any],
355
+ pubsub_client: PubSubClient,
356
+ logger: logging.Logger,
357
+ rgbled: RGBLed,
358
+ album: TargetAlbum,
359
+ activity: ActivityAlbum,
360
+ player_factory: PlayerFactory,
361
+ files: "AlbumFiles | None" = None,
362
+ ) -> None:
363
+ """``files``: fetches the album from the library while it plays (:mod:`tinyjukebox.sync`)."""
364
+ self._logger = get_child(logger, type(self).__name__)
365
+ self._files = files
366
+ self._pubsub_client = pubsub_client
367
+ self._player_factory = player_factory
368
+ self._pause_between_tracks: float = config["pause-between-tracks"]
369
+ self.album = album
370
+ self.activity = activity
371
+ self.player: PlayerInterface | None = None
372
+ self.pause = OutputPause()
373
+ self._reported: PlaybackStatus | None = None
374
+ self._saving_failed = False # the position could not be written last time
375
+ self.started = False # the machine has started: a track that cannot be played pauses the album from then on
376
+ self._both_buttons = BothButtons()
377
+
378
+ self._machine = create_machine(self._logger)
379
+ for state in [
380
+ End(self._logger, self),
381
+ Finished(self._logger, self, rgbled),
382
+ Init(self._logger, self, rgbled),
383
+ ToNext(self._logger, self),
384
+ Play(self._logger, self, rgbled, config["update-schedule"]),
385
+ Prev(self._logger, self, config["rewind-max-time"]),
386
+ ToPrev(self._logger, self),
387
+ Rewind(self._logger, self),
388
+ First(self._logger, self),
389
+ Delay(self._logger),
390
+ Paused(self._logger, self, rgbled),
391
+ Resume(self._logger, self),
392
+ Wait(self._logger, self, rgbled),
393
+ ]:
394
+ self._machine.addstate(state)
395
+ self._add_transitions()
396
+
397
+ def _add_transitions(self) -> None:
398
+ machine = self._machine
399
+ machine.setstartstate(States.INIT)
400
+ machine.addtransition(States.INIT, Transitions.PLAY, States.PLAY)
401
+ machine.addtransition(States.PLAY, Transitions.FIRST, States.FIRST)
402
+ machine.addtransition(States.PLAY, Transitions.NEXT, States.TONEXT)
403
+ machine.addtransition(States.PLAY, Transitions.DELAY, States.DELAY)
404
+ machine.addtransition(States.PLAY, Transitions.PREV, States.PREV)
405
+ machine.addtransition(States.PLAY, Transitions.FINISHED, States.FINISHED)
406
+ machine.addtransition(States.PREV, Transitions.TOPREV, States.TOPREV)
407
+ machine.addtransition(States.PREV, Transitions.REWIND, States.REWIND)
408
+ machine.addtransition(States.TONEXT, Transitions.PLAY, States.PLAY)
409
+ machine.addtransition(States.TONEXT, Transitions.FINISHED, States.FINISHED)
410
+ machine.addtransition(States.TOPREV, Transitions.PLAY, States.PLAY)
411
+ machine.addtransition(States.DELAY, Transitions.NEXT, States.TONEXT)
412
+ machine.addtransition(States.DELAY, Transitions.PREV, States.TOPREV)
413
+ machine.addtransition(States.REWIND, Transitions.PLAY, States.PLAY)
414
+ machine.addtransition(States.FIRST, Transitions.PLAY, States.PLAY)
415
+ machine.addtransition(States.FINISHED, Transitions.PREV, States.REWIND) # the last track once more
416
+ machine.addtransition(States.FINISHED, Transitions.FIRST, States.FIRST)
417
+ # pause - the actions run between on_exit of the old state (the player is paused) and on_entry of PAUSED
418
+ machine.addtransition(States.PLAY, Transitions.PAUSE, States.PAUSED, self._paused, (PauseReason.USER, False))
419
+ machine.addtransition(
420
+ States.PLAY, Transitions.OUTPUT_LOST, States.PAUSED, self._paused, (PauseReason.OUTPUT, False)
421
+ )
422
+ # between two tracks the next track is selected - it starts when the pause ends
423
+ machine.addtransition(States.DELAY, Transitions.PAUSE, States.PAUSED, self._paused, (PauseReason.USER, True))
424
+ machine.addtransition(
425
+ States.DELAY, Transitions.OUTPUT_LOST, States.PAUSED, self._paused, (PauseReason.OUTPUT, True)
426
+ )
427
+ machine.addtransition(States.PAUSED, Transitions.PLAY, States.PLAY)
428
+ machine.addtransition(States.PAUSED, Transitions.NEXT, States.PLAY) # the 1st press continues
429
+ machine.addtransition(States.PAUSED, Transitions.PREV, States.PREV)
430
+ machine.addtransition(States.PAUSED, Transitions.FIRST, States.FIRST)
431
+ machine.addtransition(States.PAUSED, Transitions.OUTPUT_CHANGED, States.RESUME)
432
+ machine.addtransition(States.RESUME, Transitions.PLAY, States.PLAY)
433
+ machine.addtransition(States.RESUME, Transitions.STAY, States.PAUSED)
434
+ # the file of the track that is due has not arrived yet: PLAY sends on to WAIT, a file that arrives back
435
+ machine.addtransition(States.PLAY, Transitions.WAIT, States.WAIT)
436
+ machine.addtransition(States.WAIT, Transitions.ARRIVED, States.PLAY)
437
+ machine.addtransition(States.WAIT, Transitions.NEXT, States.TONEXT)
438
+ machine.addtransition(States.WAIT, Transitions.PREV, States.TOPREV)
439
+ machine.addtransition(States.WAIT, Transitions.FIRST, States.FIRST)
440
+ # the headset goes while the box waits for a file: paused, so that the file that arrives does not start the
441
+ # track on the speaker
442
+ machine.addtransition(
443
+ States.WAIT, Transitions.OUTPUT_LOST, States.PAUSED, self._paused, (PauseReason.OUTPUT, False, False)
444
+ )
445
+ # the track continues on the new output - PLAY is left (player paused) and entered again
446
+ machine.addtransition(States.PLAY, Transitions.OUTPUT_CHANGED, States.PLAY, self._rewind_output)
447
+ for state in States:
448
+ if state != States.END:
449
+ machine.addtransition(state, Transitions.END, States.END)
450
+ machine.addtimeoutevent(States.DELAY, Transitions.NEXT, self._pause_between_tracks)
451
+
452
+ @property
453
+ def machine(self) -> object:
454
+ """The tantamount machine (diagram export, tests)."""
455
+ return self._machine
456
+
457
+ def create_player(self) -> PlayerInterface:
458
+ player = self._player_factory(self._logger, self.album, self._end_of_track, self._album_finished)
459
+ player.set_output_lost_hook(self.cmd_output_lost)
460
+ player.set_track_failed_hook(self._track_failed)
461
+ return player
462
+
463
+ def _track_failed(self, track: int, error: str) -> None:
464
+ """The decoder found the file of a track damaged: noted for the library, which collects the broken
465
+ files."""
466
+ name = os.path.relpath(self.album.get_tracks()[track], str(self.album.full_album_folder))
467
+ self.note("broken", track=track, file=name.replace(os.sep, "/"), error=error[-MAX_ERROR_LENGTH:])
468
+
469
+ def save_position(self, track_id: int, track_time: float, finished: bool) -> None:
470
+ """Store the position. A card that cannot be written - full, or read-only after a fault - stops nothing: the
471
+ album plays on, and the error is logged once."""
472
+ try:
473
+ self.activity.update(track_id, track_time, finished)
474
+ except OSError as error:
475
+ if not self._saving_failed:
476
+ self._saving_failed = True
477
+ self._logger.error("the position cannot be saved: %s - the album plays on", error)
478
+ return
479
+ if self._saving_failed:
480
+ self._saving_failed = False
481
+ self._logger.info("the position is saved again")
482
+
483
+ def report(self, status: PlaybackStatus) -> None:
484
+ """Publish the playback status (for the headset) if it changed."""
485
+ if status != self._reported:
486
+ self._reported = status
487
+ self._pubsub_client.publish(PLAYBACK, status)
488
+
489
+ def note(self, event: str, **fields: object) -> None:
490
+ """Something for the journal of the box about this album."""
491
+ self._pubsub_client.publish(JOURNAL, JournalEvent.of(event, album=str(self.album.target_id), **fields))
492
+
493
+ def _control(self, action: str) -> None:
494
+ """A command changed the playback - by a button (of the box or the headset) or by a user of the web page."""
495
+ who = actor()
496
+ self.note("control", action=action, by="button" if who == SOFTWARE else who)
497
+
498
+ def _paused(self, reason: PauseReason, next_track: bool, rewind: bool = True) -> None:
499
+ """Transition action into PAUSED: remember why; ``next_track``: paused between two tracks; ``rewind``: the
500
+ end of the playback was not heard (not while waiting for a file - nothing played)."""
501
+ self.pause.paused(reason)
502
+ player = self.player
503
+ if player is None:
504
+ return
505
+ if next_track:
506
+ player.to_next()
507
+ elif reason is PauseReason.OUTPUT and rewind:
508
+ self._rewind_output()
509
+
510
+ def _rewind_output(self) -> None:
511
+ """Transition action: back :data:`OUTPUT_REWIND` seconds - the end of the playback has not been heard."""
512
+ player = self.player
513
+ if player is not None:
514
+ player.set_track(player.current_track_id(), max(0.0, player.current_track_time() - OUTPUT_REWIND))
515
+
516
+ def want_track(self, track_id: int) -> None:
517
+ """Ask for the file of the track ``track_id`` first."""
518
+ if self._files is not None:
519
+ self._files.need(str(self.album.target_id), track_id)
520
+
521
+ def _file_arrived(self, album: str) -> None:
522
+ """A file of an album arrived (thread of the download) - only WAIT reacts."""
523
+ if album == str(self.album.target_id):
524
+ self._machine.asyncoperate(Transitions.ARRIVED)
525
+
526
+ def _end_of_track(self) -> None:
527
+ """Called by the player when a track has been played to its end."""
528
+ self._machine.asyncoperate(Transitions.NEXT if self._pause_between_tracks == 0 else Transitions.DELAY)
529
+
530
+ def _album_finished(self) -> None:
531
+ """Called by the player when the last track has been played to its end."""
532
+ self._machine.asyncoperate(Transitions.FINISHED)
533
+
534
+ def cmd_next_handler(self) -> None:
535
+ self._control("next")
536
+ self._machine.asyncoperate(Transitions.NEXT)
537
+
538
+ def cmd_prev_handler(self) -> None:
539
+ self._control("prev")
540
+ self._machine.asyncoperate(Transitions.PREV)
541
+
542
+ def cmd_restart_album(self) -> None:
543
+ self._control("restart")
544
+ self._machine.asyncoperate(Transitions.FIRST)
545
+
546
+ def cmd_pause(self) -> None:
547
+ self._control("pause")
548
+ self._machine.asyncoperate(Transitions.PAUSE)
549
+
550
+ def cmd_play(self) -> None:
551
+ self._control("play")
552
+ self._machine.asyncoperate(Transitions.PLAY)
553
+
554
+ def cmd_output_lost(self, then: Callable[[], None] | None = None) -> None:
555
+ """The headset has gone: pause - unless the album is paused or finished already. ``then`` is called in the
556
+ worker once that has been handled."""
557
+ self._machine.asyncoperate(Transitions.OUTPUT_LOST)
558
+ if then is not None:
559
+ self._machine.call_soon(then)
560
+
561
+ def cmd_output_changed(self) -> None:
562
+ """The output has changed (headset connected): a playing track continues on it, a pause caused by the loss
563
+ of the headset ends."""
564
+ self._machine.asyncoperate(Transitions.OUTPUT_CHANGED)
565
+
566
+ def get_state(self) -> States:
567
+ return self._machine.get_active_state().id # type: ignore[no-any-return]
568
+
569
+ def wait_until_idle(self) -> None:
570
+ """Block until all queued events have been processed (tests)."""
571
+ self._machine.wait_until_idle()
572
+
573
+ def get_info(self) -> dict[str, Any]:
574
+ player = self.player
575
+ info: dict[str, Any] = {
576
+ "target_id": self.album.target_id,
577
+ "target_type": "album",
578
+ "album_folder": self.album.album_folder,
579
+ "playlist": self.album.playlist,
580
+ "state": self.get_state().value,
581
+ "tracks": self.album.get_tracks(),
582
+ "track": {"track_id": None, "filename": None, "pos": None, "length": None},
583
+ }
584
+ if player is not None:
585
+ info["track"] = {
586
+ "track_id": player.current_track_id(),
587
+ "filename": player.current_track_filename(),
588
+ "pos": player.current_track_time(),
589
+ "length": player.current_track_length(),
590
+ }
591
+ return info
592
+
593
+ def dump_to_dot(self, filename: str | Path) -> None:
594
+ write_dot_file(self._machine, filename)
595
+
596
+ def _button_handler(self, message: object) -> None:
597
+ """Handler for :data:`~tinyjukebox.messages.BUTTON_PRESSED` - reacts on next/prev/play/pause, ignores the other
598
+ buttons."""
599
+ self._both_buttons.pressed(message)
600
+ command = {
601
+ Button.NEXT: self.cmd_next_handler,
602
+ Button.PREV: self.cmd_prev_handler,
603
+ Button.PLAY: self.cmd_play,
604
+ Button.PAUSE: self.cmd_pause,
605
+ }.get(message) # type: ignore[call-overload] # any payload - unknown ones are ignored
606
+ if command is not None:
607
+ self._logger.info("_button_handler - received: %s", message)
608
+ command()
609
+
610
+ def _released_handler(self, message: object) -> None:
611
+ """Handler for :data:`~tinyjukebox.messages.BUTTON_RELEASED`."""
612
+ self._both_buttons.released(message)
613
+
614
+ def _held_handler(self, message: object) -> None:
615
+ """Handler for :data:`~tinyjukebox.messages.BUTTON_HELD` - 'prev' held alone: the album from the beginning."""
616
+ if message != Button.PREV:
617
+ return
618
+ if self._both_buttons.next_is_pressed():
619
+ self._logger.info("_held_handler - prev held together with next: no restart")
620
+ return
621
+ self._logger.info("_held_handler - prev held: restarting the album")
622
+ self.cmd_restart_album()
623
+
624
+ def start(self) -> None:
625
+ """Load the album and start playing. :raises Exception: if the album cannot be loaded (machine stopped)."""
626
+ try:
627
+ self._machine.start()
628
+ except Exception:
629
+ # e.g. album cannot be loaded in INIT - stop the machine, otherwise its worker thread is left running
630
+ self._machine.stop()
631
+ raise
632
+ self.started = True
633
+ self._pubsub_client.subscribe(BUTTON_PRESSED, self._button_handler)
634
+ self._pubsub_client.subscribe(BUTTON_HELD, self._held_handler)
635
+ self._pubsub_client.subscribe(BUTTON_RELEASED, self._released_handler)
636
+ if self._files is not None:
637
+ self._files.subscribe(self._file_arrived)
638
+
639
+ def stop(self) -> None:
640
+ if self._files is not None:
641
+ self._files.unsubscribe(self._file_arrived)
642
+ self._pubsub_client.unsubscribe(BUTTON_PRESSED, self._button_handler, ignore_not_found=True)
643
+ self._pubsub_client.unsubscribe(BUTTON_HELD, self._held_handler, ignore_not_found=True)
644
+ self._pubsub_client.unsubscribe(BUTTON_RELEASED, self._released_handler, ignore_not_found=True)
645
+ try:
646
+ self._machine.operate(Transitions.END)
647
+ except Exception as error:
648
+ # what ended in an exception is logged; the machine and its threads end all the same, and so does the sound
649
+ self._logger.exception("the album does not end cleanly")
650
+ unexpected.report(error, "end of an album")
651
+ if self.player is not None:
652
+ with contextlib.suppress(Exception):
653
+ self.player.stop_player()
654
+ finally:
655
+ self._machine.stop()