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,7 @@
1
+ """Tiny Jukebox - RFID-controlled audio player for albums (Raspberry Pi)."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = version("tinyjukebox")
@@ -0,0 +1 @@
1
+ """Album players: :class:`SimplePlayer` (ffmpeg | aplay) and :class:`DummyPlayer` (no audio, timer based)."""
@@ -0,0 +1,402 @@
1
+ """Common implementation of tracks and album players."""
2
+
3
+ import functools
4
+ import logging
5
+ import os
6
+ import threading
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Callable
9
+
10
+ from tinyjukebox.albumplayer.clock import SYSTEM_CLOCK, Clock
11
+ from tinyjukebox.albumplayer.player_interface import Hook, PlayerInterface, TrackFailedHook
12
+ from tinyjukebox.core.logging import get_child
13
+ from tinyjukebox.data.target import TargetAlbum
14
+ from tinyjukebox.metadata import mp3_info
15
+
16
+ __all__ = ["AAlbumPlayer", "ATrack", "TrackEndHook"]
17
+
18
+ TrackEndHook = Callable[[int], object]
19
+ """Called with the play id of the playback that reached the end of the track."""
20
+
21
+
22
+ class ATrack(ABC):
23
+ """One mp3 file: length, position and the play/stop life cycle.
24
+
25
+ Every :meth:`play` gets a new *play id*. Implementations report the end of a playback with
26
+ :meth:`_end_of_track` and its play id; ends of playbacks that have been stopped or restarted meanwhile are
27
+ ignored. This avoids a race between a track ending by itself and a stop/next at the same moment.
28
+
29
+ Stopping keeps the position - the next :meth:`play` resumes there.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ filename: str | None,
35
+ logger: logging.Logger,
36
+ end_of_track_hook: TrackEndHook,
37
+ start_time: float = 0,
38
+ clock: Clock = SYSTEM_CLOCK,
39
+ ) -> None:
40
+ self._logger = get_child(logger, str(filename))
41
+ if not filename or not os.path.isfile(filename): # no glob - '[...]' in file names is no pattern
42
+ message = f"unknown filename '{filename}'"
43
+ self._logger.error(message)
44
+ raise ValueError(message)
45
+ self.filename = filename
46
+ self._length, self._frequency = mp3_info(filename)
47
+ self._end_of_track_hook = end_of_track_hook
48
+ self.output_lost_hook: TrackEndHook | None = None # called with the play id if the headset output failed
49
+ self.failed_hook: Callable[[str], object] | None = (
50
+ None # called with what the decoder said about a damaged file
51
+ )
52
+ self._clock = clock
53
+ self._lock = threading.Lock()
54
+ self._is_playing = False
55
+ self._end_of_track_reached = False
56
+ self._play_id = 0
57
+ self._play_start: float | None = None
58
+ self._start_time = 0.0
59
+ self.set_start_time(start_time)
60
+ self._logger.debug("__init__ - length: %s, frequency: %s", self._length, self._frequency)
61
+
62
+ @property
63
+ def play_id(self) -> int:
64
+ """Id of the latest playback."""
65
+ return self._play_id
66
+
67
+ def set_start_time(self, start_time: float) -> None:
68
+ new_time = min(self.track_length(), max(0, start_time))
69
+ if start_time != new_time:
70
+ self._logger.warning("set_start_time - corrected out of bounds start time %s to %s.", start_time, new_time)
71
+ self._start_time = new_time
72
+
73
+ def time_played(self) -> float:
74
+ start = self._play_start
75
+ if start is None:
76
+ return self._start_time
77
+ return self._clock.now() - start + self._start_time
78
+
79
+ def track_length(self) -> float:
80
+ return self._length
81
+
82
+ def track_frequency(self) -> int:
83
+ return self._frequency
84
+
85
+ def progress(self) -> float:
86
+ return min(1.0, self.time_played() / self.track_length()) if self.track_length() > 0 else 1.0
87
+
88
+ def time_left(self) -> float:
89
+ return max(0.0, self.track_length() - self.time_played())
90
+
91
+ def is_playing(self) -> bool:
92
+ return self._is_playing
93
+
94
+ def end_of_track_reached(self) -> bool:
95
+ return self._end_of_track_reached
96
+
97
+ def play(self) -> None:
98
+ with self._lock:
99
+ if self._is_playing:
100
+ return
101
+ self._play_id += 1
102
+ play_id = self._play_id
103
+ self._is_playing = True
104
+ self._end_of_track_reached = False
105
+ self._play_start = self._clock.now()
106
+ self._logger.info("play (%s from %.1fs)", play_id, self._start_time)
107
+ try:
108
+ self._play(play_id)
109
+ except BaseException:
110
+ with self._lock:
111
+ self._is_playing = False
112
+ raise
113
+
114
+ def stop(self) -> None:
115
+ with self._lock:
116
+ if not self._is_playing:
117
+ return
118
+ position = self.time_played()
119
+ self._is_playing = False
120
+ self._play_start = None
121
+ self._start_time = min(position, self.track_length())
122
+ self._logger.info("stop at %.1fs", self._start_time)
123
+ self._stop()
124
+
125
+ def _end_of_track(self, play_id: int) -> None:
126
+ """To be called by implementations when the playback ``play_id`` has reached the end of the file."""
127
+ with self._lock:
128
+ if not self._is_playing or play_id != self._play_id:
129
+ self._logger.debug("_end_of_track - playback %s has been stopped already - ignored", play_id)
130
+ return
131
+ self._is_playing = False
132
+ self._end_of_track_reached = True
133
+ self._play_start = None
134
+ self._start_time = self.track_length()
135
+ self._logger.info("_end_of_track")
136
+ self._end_of_track_hook(play_id)
137
+
138
+ def _output_lost(self, play_id: int) -> None:
139
+ """To be called by implementations if the output of the playback ``play_id`` failed (headset gone) - the
140
+ track stops at its position, like :meth:`stop`."""
141
+ with self._lock:
142
+ if not self._is_playing or play_id != self._play_id:
143
+ return
144
+ position = self.time_played()
145
+ self._is_playing = False
146
+ self._play_start = None
147
+ self._start_time = min(position, self.track_length())
148
+ self._logger.warning("_output_lost at %.1fs", self._start_time)
149
+ if self.output_lost_hook is not None:
150
+ self.output_lost_hook(play_id)
151
+
152
+ @abstractmethod
153
+ def _play(self, play_id: int) -> None:
154
+ """Start the playback from ``_start_time``."""
155
+
156
+ @abstractmethod
157
+ def _stop(self) -> None:
158
+ """Stop the playback immediately."""
159
+
160
+
161
+ class _PendingTrack(ATrack):
162
+ """Stands for a track whose file has not arrived yet: it has no length and plays nothing. The album player
163
+ replaces it as soon as the file is there (:meth:`AAlbumPlayer.current_track_ready`)."""
164
+
165
+ def __init__(self, filename: str, logger: logging.Logger) -> None:
166
+ self._logger = get_child(logger, filename)
167
+ self.filename = filename
168
+ self._length, self._frequency = 0.0, 0
169
+ self._end_of_track_hook = lambda play_id: None
170
+ self.output_lost_hook = None
171
+ self.failed_hook = None
172
+ self._clock = SYSTEM_CLOCK
173
+ self._lock = threading.Lock()
174
+ self._is_playing = False
175
+ self._end_of_track_reached = False
176
+ self._play_id = 0
177
+ self._play_start = None
178
+ self._start_time = 0.0
179
+
180
+ def set_start_time(self, start_time: float) -> None:
181
+ self._start_time = max(0.0, start_time) # kept for the real track
182
+
183
+ def time_played(self) -> float:
184
+ return self._start_time
185
+
186
+ def _play(self, play_id: int) -> None:
187
+ pass
188
+
189
+ def _stop(self) -> None:
190
+ pass
191
+
192
+
193
+ class AAlbumPlayer(PlayerInterface):
194
+ """Album player on top of :class:`ATrack` implementations.
195
+
196
+ ``_rlock`` guards the current track and the playback state; it is held while tracks are started and stopped
197
+ (short - no waiting for the end of a track).
198
+ """
199
+
200
+ def __init__(
201
+ self, logger: logging.Logger, album: TargetAlbum, end_of_track_hook: Hook, end_of_album_hook: Hook
202
+ ) -> None:
203
+ self._logger = get_child(logger, type(self).__name__)
204
+ self._album = album
205
+ self._logger.info("__init__ for %s", album.to_text())
206
+ self._end_of_track_hook = end_of_track_hook
207
+ self._end_of_album_hook = end_of_album_hook
208
+ self._output_lost_hook: Hook | None = None
209
+ self._track_failed_hook: TrackFailedHook | None = None
210
+ self._rlock = threading.RLock()
211
+ self._playback_active = False
212
+ self._album_end_reached = False
213
+ self._track_end_reached = False
214
+ self._tracks: list[ATrack] = [
215
+ self._new_track(index, filename) for index, filename in enumerate(album.get_tracks())
216
+ ]
217
+ if not self._tracks:
218
+ message = "no tracks provided for album - an album must contain at least one track."
219
+ self._logger.error(message)
220
+ raise ValueError(message)
221
+ self._album_length_raw = sum(track.track_length() for track in self._tracks)
222
+ self._current_track_id = 0
223
+ self._current_track = self._tracks[0]
224
+
225
+ @abstractmethod
226
+ def _create_track(self, filename: str, end_of_track_hook: TrackEndHook) -> ATrack: ...
227
+
228
+ def _new_track(self, index: int, filename: str) -> ATrack:
229
+ if self._album.incomplete and not os.path.isfile(filename):
230
+ return _PendingTrack(filename, self._logger) # the album is being fetched from the library
231
+ track = self._create_track(filename, functools.partial(self._on_track_end, index))
232
+ track.output_lost_hook = functools.partial(self._on_output_lost, index)
233
+ track.failed_hook = functools.partial(self._on_track_failed, index)
234
+ return track
235
+
236
+ def current_track_ready(self) -> bool:
237
+ with self._rlock:
238
+ pending = self._current_track
239
+ if isinstance(pending, _PendingTrack) and os.path.isfile(pending.filename):
240
+ track = self._new_track(self._current_track_id, pending.filename)
241
+ track.set_start_time(pending.time_played())
242
+ self._tracks[self._current_track_id] = self._current_track = track
243
+ self._album_length_raw = sum(track.track_length() for track in self._tracks)
244
+ return not isinstance(self._current_track, _PendingTrack)
245
+
246
+ def set_output_lost_hook(self, hook: Hook) -> None:
247
+ self._output_lost_hook = hook
248
+
249
+ def set_track_failed_hook(self, hook: TrackFailedHook) -> None:
250
+ self._track_failed_hook = hook
251
+
252
+ def start_player(self) -> None:
253
+ self._logger.info("start_player")
254
+
255
+ def stop_player(self) -> None:
256
+ self._logger.info("stop_player")
257
+ with self._rlock:
258
+ self._playback_active = False
259
+ self._current_track.stop()
260
+
261
+ # --- playback ----------------------------------------------------------------------------------------------
262
+
263
+ def play(self) -> None:
264
+ """:raises OSError: if the track cannot be started (ffmpeg or aplay) - the player is not playing then."""
265
+ with self._rlock:
266
+ if not self._playback_active:
267
+ self._logger.info("play")
268
+ self._playback_active = True
269
+ try:
270
+ self._current_track.play()
271
+ except BaseException:
272
+ self._playback_active = False
273
+ raise
274
+
275
+ def pause(self) -> None:
276
+ with self._rlock:
277
+ if self._playback_active:
278
+ self._logger.info("pause")
279
+ self._playback_active = False
280
+ self._current_track.stop()
281
+
282
+ def is_playing(self) -> bool:
283
+ return self._playback_active
284
+
285
+ def set_track(self, track_id: int, track_start_time: float = 0) -> None:
286
+ with self._rlock:
287
+ track_id = min(max(track_id, 0), len(self._tracks) - 1)
288
+ self._logger.info("set_track %s at %s", track_id, track_start_time)
289
+ self._track_end_reached = False
290
+ self._album_end_reached = False
291
+ self._current_track.stop()
292
+ self._current_track_id = track_id
293
+ self._current_track = self._tracks[track_id]
294
+ self._current_track.set_start_time(track_start_time)
295
+ if self._playback_active:
296
+ self._current_track.play()
297
+
298
+ def to_first(self) -> None:
299
+ self.set_track(0)
300
+
301
+ def to_next(self) -> bool:
302
+ with self._rlock:
303
+ if self._current_track_id >= len(self._tracks) - 1:
304
+ self._end_of_album()
305
+ return False
306
+ self.set_track(self._current_track_id + 1)
307
+ return True
308
+
309
+ def to_prev(self) -> None:
310
+ with self._rlock:
311
+ self.set_track(self._current_track_id - 1)
312
+
313
+ def rewind_track(self) -> None:
314
+ with self._rlock:
315
+ self.set_track(self._current_track_id, 0)
316
+
317
+ def _on_track_end(self, index: int, play_id: int) -> None:
318
+ """End-of-track hook of the tracks (player thread of the track)."""
319
+ with self._rlock:
320
+ track = self._tracks[index]
321
+ if index != self._current_track_id or play_id != track.play_id or not self._playback_active:
322
+ self._logger.info("_on_track_end - end of an outdated playback of track %s - ignored", index)
323
+ return
324
+ self._track_end_reached = True
325
+ if index == len(self._tracks) - 1:
326
+ self._end_of_album()
327
+ else:
328
+ self._end_of_track_hook()
329
+
330
+ def _on_output_lost(self, index: int, play_id: int) -> None:
331
+ """Output-lost hook of the tracks: the playback stops (the track keeps its position)."""
332
+ with self._rlock:
333
+ track = self._tracks[index]
334
+ if index != self._current_track_id or play_id != track.play_id or not self._playback_active:
335
+ return
336
+ self._playback_active = False
337
+ if self._output_lost_hook is not None:
338
+ self._output_lost_hook()
339
+
340
+ def _on_track_failed(self, index: int, error: str) -> None:
341
+ """Failed hook of the tracks: the file of the track is damaged or cannot be decoded at all."""
342
+ if self._track_failed_hook is not None:
343
+ self._track_failed_hook(index, error)
344
+
345
+ def _end_of_album(self) -> None:
346
+ with self._rlock:
347
+ self._logger.info("_end_of_album")
348
+ self._current_track.stop()
349
+ self._album_end_reached = True
350
+ self._track_end_reached = True
351
+ self._end_of_album_hook()
352
+
353
+ # --- information -------------------------------------------------------------------------------------------
354
+
355
+ def album_end_reached(self) -> bool:
356
+ return self._album_end_reached
357
+
358
+ def current_track_end_reached(self) -> bool:
359
+ return self._track_end_reached
360
+
361
+ def current_track_id(self) -> int:
362
+ return self._current_track_id
363
+
364
+ def current_track_filename(self) -> str:
365
+ return self._current_track.filename
366
+
367
+ def current_track_length(self) -> float:
368
+ return self._current_track.track_length()
369
+
370
+ def current_track_time_left(self) -> float:
371
+ return self._current_track.time_left()
372
+
373
+ def current_track_time_played(self) -> float:
374
+ return self._current_track.time_played()
375
+
376
+ def current_track_time(self) -> float:
377
+ return self.current_track_time_played()
378
+
379
+ def current_track_progress(self) -> float:
380
+ return self._current_track.progress()
381
+
382
+ def album_track_count(self) -> int:
383
+ return len(self._tracks)
384
+
385
+ def album_length(self, pause_between_tracks: float = 0) -> float:
386
+ return self._album_length_raw + pause_between_tracks * (len(self._tracks) - 1)
387
+
388
+ def album_time_played(self, pause_between_tracks: float = 0) -> float:
389
+ with self._rlock:
390
+ index = self._current_track_id
391
+ played = sum(track.track_length() for track in self._tracks[:index])
392
+ return played + index * pause_between_tracks + self._current_track.time_played()
393
+
394
+ def album_time_left(self, pause_between_tracks: float = 0) -> float:
395
+ return self.album_length(pause_between_tracks) - self.album_time_played(pause_between_tracks)
396
+
397
+ def album_progress(self, pause_between_tracks: float = 0) -> float:
398
+ length = self.album_length(pause_between_tracks)
399
+ return self.album_time_played(pause_between_tracks) / length if length > 0 else 0
400
+
401
+ def get_track(self, track_id: int) -> ATrack:
402
+ return self._tracks[track_id]
@@ -0,0 +1,39 @@
1
+ """Time source of the players - replaced by a manually advanced clock in tests."""
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Callable
6
+ from typing import Protocol
7
+
8
+ __all__ = ["SYSTEM_CLOCK", "Clock", "SystemClock", "TimerHandle"]
9
+
10
+
11
+ class TimerHandle(Protocol):
12
+ def cancel(self) -> None: ...
13
+
14
+
15
+ class Clock(Protocol):
16
+ def now(self) -> float:
17
+ """Monotonic time in seconds."""
18
+ ...
19
+
20
+ def call_later(self, delay: float, callback: Callable[[], object]) -> TimerHandle:
21
+ """Call ``callback`` (in another thread) after ``delay`` seconds unless the handle is cancelled before."""
22
+ ...
23
+
24
+
25
+ class SystemClock:
26
+ """Real time: ``time.monotonic`` and ``threading.Timer`` (daemon threads)."""
27
+
28
+ def now(self) -> float:
29
+ return time.monotonic()
30
+
31
+ def call_later(self, delay: float, callback: Callable[[], object]) -> TimerHandle:
32
+ timer = threading.Timer(max(0.0, delay), callback)
33
+ timer.name = "tinyjukebox.Timer"
34
+ timer.daemon = True
35
+ timer.start()
36
+ return timer
37
+
38
+
39
+ SYSTEM_CLOCK = SystemClock()
@@ -0,0 +1,49 @@
1
+ """Player without audio: a track 'plays' for its length (timer) - for tests with a manually advanced clock."""
2
+
3
+ import logging
4
+
5
+ from tinyjukebox.albumplayer.aalbumplayer import AAlbumPlayer, ATrack, TrackEndHook
6
+ from tinyjukebox.albumplayer.clock import SYSTEM_CLOCK, Clock, TimerHandle
7
+ from tinyjukebox.albumplayer.player_interface import Hook
8
+ from tinyjukebox.data.target import TargetAlbum
9
+
10
+ __all__ = ["DummyPlayer", "DummyTrack"]
11
+
12
+
13
+ class DummyTrack(ATrack):
14
+ """Ends ``time_left()`` seconds after :meth:`play` (according to ``clock``)."""
15
+
16
+ def __init__(
17
+ self,
18
+ filename: str | None,
19
+ logger: logging.Logger,
20
+ end_of_track_hook: TrackEndHook,
21
+ start_time: float = 0,
22
+ clock: Clock = SYSTEM_CLOCK,
23
+ ) -> None:
24
+ super().__init__(filename, logger, end_of_track_hook, start_time, clock)
25
+ self._timer: TimerHandle | None = None
26
+
27
+ def _play(self, play_id: int) -> None:
28
+ self._timer = self._clock.call_later(self.time_left(), lambda: self._end_of_track(play_id))
29
+
30
+ def _stop(self) -> None:
31
+ timer, self._timer = self._timer, None
32
+ if timer is not None:
33
+ timer.cancel()
34
+
35
+
36
+ class DummyPlayer(AAlbumPlayer):
37
+ def __init__(
38
+ self,
39
+ logger: logging.Logger,
40
+ album: TargetAlbum,
41
+ end_of_track_hook: Hook,
42
+ end_of_album_hook: Hook,
43
+ clock: Clock = SYSTEM_CLOCK,
44
+ ) -> None:
45
+ self._clock = clock
46
+ super().__init__(logger, album, end_of_track_hook, end_of_album_hook)
47
+
48
+ def _create_track(self, filename: str, end_of_track_hook: TrackEndHook) -> DummyTrack:
49
+ return DummyTrack(filename, self._logger, end_of_track_hook, clock=self._clock)
@@ -0,0 +1,97 @@
1
+ """Interface of the album players as used by the play-album state machine."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from typing import TypeAlias
6
+
7
+ __all__ = ["Hook", "PlayerInterface", "TrackFailedHook"]
8
+
9
+ Hook: TypeAlias = Callable[[], object]
10
+ TrackFailedHook: TypeAlias = Callable[[int, str], object]
11
+ """Called with the id of a track whose file is damaged or cannot be decoded at all, and what the decoder said."""
12
+
13
+
14
+ class PlayerInterface(ABC):
15
+ """Plays the tracks of one album. Positions are in seconds, track ids are 0-based.
16
+
17
+ The player calls the end-of-track hook when a track has been played to its end (and the end-of-album hook
18
+ instead for the last track). Hooks are called from a player thread - they must not block.
19
+ """
20
+
21
+ def set_output_lost_hook(self, hook: Hook) -> None: # noqa: B027 - optional, players without headset ignore it
22
+ """``hook`` is called (instead of the end-of-track hook) if the headset output fails while a track plays -
23
+ the track stops at its position."""
24
+
25
+ def set_track_failed_hook(self, hook: TrackFailedHook) -> None: # noqa: B027 - optional, as above
26
+ """``hook`` is called if the decoder found the file of a track damaged - when the track has ended, before
27
+ the end-of-track hook; the player goes on as after every track."""
28
+
29
+ def current_track_ready(self) -> bool:
30
+ """False while the file of the current track has not arrived yet - an album that is fetched from the
31
+ library plays as soon as the track that is due is there."""
32
+ return True
33
+
34
+ @abstractmethod
35
+ def start_player(self) -> None:
36
+ """Acquire resources (before the first :meth:`play`)."""
37
+
38
+ @abstractmethod
39
+ def stop_player(self) -> None:
40
+ """Stop playback and release all resources."""
41
+
42
+ @abstractmethod
43
+ def play(self) -> None:
44
+ """Start or resume playback of the current track."""
45
+
46
+ @abstractmethod
47
+ def pause(self) -> None:
48
+ """Pause playback - the position is kept."""
49
+
50
+ @abstractmethod
51
+ def is_playing(self) -> bool: ...
52
+
53
+ @abstractmethod
54
+ def set_track(self, track_id: int, track_start_time: float = 0) -> None:
55
+ """Select a track (ids and times out of range are corrected); continues playing if the player plays."""
56
+
57
+ @abstractmethod
58
+ def to_first(self) -> None: ...
59
+
60
+ @abstractmethod
61
+ def to_next(self) -> bool:
62
+ """Next track - False (and end of album) if the current track is the last one."""
63
+
64
+ @abstractmethod
65
+ def to_prev(self) -> None: ...
66
+
67
+ @abstractmethod
68
+ def rewind_track(self) -> None:
69
+ """Back to the start of the current track."""
70
+
71
+ @abstractmethod
72
+ def album_end_reached(self) -> bool: ...
73
+
74
+ @abstractmethod
75
+ def current_track_end_reached(self) -> bool: ...
76
+
77
+ @abstractmethod
78
+ def current_track_id(self) -> int: ...
79
+
80
+ @abstractmethod
81
+ def current_track_filename(self) -> str: ...
82
+
83
+ @abstractmethod
84
+ def current_track_time(self) -> float:
85
+ """Position within the current track."""
86
+
87
+ @abstractmethod
88
+ def current_track_length(self) -> float: ...
89
+
90
+ @abstractmethod
91
+ def current_track_time_left(self) -> float: ...
92
+
93
+ @abstractmethod
94
+ def album_track_count(self) -> int: ...
95
+
96
+ @abstractmethod
97
+ def album_length(self, pause_between_tracks: float = 0) -> float: ...