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