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,419 @@
1
+ """Player implementation with external processes: ffmpeg decodes, aplay (alsa-utils) plays."""
2
+
3
+ import collections
4
+ import logging
5
+ import re
6
+ import subprocess
7
+ import threading
8
+ from collections.abc import Callable
9
+ from typing import IO
10
+
11
+ from tinyjukebox.albumplayer.aalbumplayer import AAlbumPlayer, ATrack, TrackEndHook
12
+ from tinyjukebox.albumplayer.clock import SYSTEM_CLOCK, Clock
13
+ from tinyjukebox.albumplayer.player_interface import Hook
14
+ from tinyjukebox.data.target import TargetAlbum
15
+
16
+ __all__ = [
17
+ "STREAM_TIMEOUT",
18
+ "AudioOutput",
19
+ "AudioPipeline",
20
+ "SimplePlayer",
21
+ "SimpleTrack",
22
+ "bluealsa_device",
23
+ "decoder_command",
24
+ "output_command",
25
+ ]
26
+
27
+
28
+ STREAM_TIMEOUT = 15.0
29
+ """Seconds without data from a stream until ffmpeg gives up (the radio player then connects again)."""
30
+ CD_BYTES_PER_SECOND = 44100 * 2 * 2
31
+ """What ffmpeg writes to aplay per second of audio: 16 bit stereo pcm at 44.1 kHz."""
32
+
33
+
34
+ def decoder_command(source: str, start_time: float = 0, stream: bool = False) -> list[str]:
35
+ """ffmpeg decodes the source to raw 16 bit stereo pcm (44.1 kHz) on stdout.
36
+
37
+ ``stream``: ``source`` is the url of an internet radio stream - no start time; ffmpeg reconnects after short
38
+ interruptions and gives up after :data:`STREAM_TIMEOUT` seconds without data.
39
+ """
40
+ if stream:
41
+ position = [
42
+ "-reconnect",
43
+ "1",
44
+ "-reconnect_streamed",
45
+ "1",
46
+ "-reconnect_delay_max",
47
+ "10",
48
+ "-rw_timeout",
49
+ str(round(STREAM_TIMEOUT * 1_000_000)), # µs
50
+ ]
51
+ else:
52
+ position = ["-ss", str(start_time)]
53
+ return [
54
+ "ffmpeg",
55
+ "-nostdin",
56
+ "-hide_banner",
57
+ "-loglevel",
58
+ "error",
59
+ *position,
60
+ "-i",
61
+ source,
62
+ "-vn",
63
+ "-f",
64
+ "s16le",
65
+ "-ac",
66
+ "2",
67
+ "-ar",
68
+ "44100",
69
+ "-",
70
+ ]
71
+
72
+
73
+ def output_command(null_output: bool = False, buffer_time: float | None = None, device: str | None = None) -> list[str]:
74
+ """aplay plays raw pcm from stdin on ``device`` (``None``: the default device - the speaker) in cd format (16 bit
75
+ little endian, 44100 Hz, stereo).
76
+
77
+ ffmpeg's own alsa output ('-f alsa') can hang in ALSA and ignore SIGTERM - piping into aplay avoids that.
78
+ ``null_output`` (``--no-audio``): ffmpeg consumes the audio in real time and discards it - no sound card needed.
79
+ ``buffer_time``: alsa buffer of aplay in ms (``None``: aplay's default, usually 500 ms) - the sound starts once the
80
+ buffer is filled; a smaller buffer starts faster but tolerates shorter stalls of the decoder.
81
+ """
82
+ if null_output:
83
+ return [
84
+ "ffmpeg",
85
+ "-nostdin",
86
+ "-loglevel",
87
+ "error",
88
+ "-f",
89
+ "s16le",
90
+ "-ar",
91
+ "44100",
92
+ "-ac",
93
+ "2",
94
+ "-i",
95
+ "-",
96
+ "-af",
97
+ "arealtime", # not '-re': since ffmpeg 6.1 it reads the first 0.5 s at once (readrate_initial_burst)
98
+ "-f",
99
+ "null",
100
+ "-",
101
+ ]
102
+ command = ["aplay", "-q", "-t", "raw", "-f", "cd"]
103
+ if device is not None:
104
+ command += ["-D", device]
105
+ if buffer_time is not None:
106
+ command.append(f"--buffer-time={round(buffer_time * 1000)}") # µs
107
+ return command
108
+
109
+
110
+ def bluealsa_device(address: str, volume: float | None = None) -> str:
111
+ """The alsa device of BlueALSA for the headset with the bluetooth ``address`` - always stereo (A2DP).
112
+
113
+ ``volume`` (%): set (and unmuted) when the device is opened, i.e. before the first sound - BlueALSA remembers
114
+ the volume a headset had the last time, and a volume set via the mixer may arrive a moment after the playback
115
+ has started."""
116
+ device = f"bluealsa:DEV={address},PROFILE=a2dp"
117
+ return device if volume is None else f"{device},VOL={round(volume)}+"
118
+
119
+
120
+ class AudioOutput:
121
+ """Where the players play: the speaker (default alsa device) or the bluetooth headset - looked up whenever a
122
+ playback starts, so that a switch applies to the next playback.
123
+
124
+ ``headset``: a failing output means the headset has gone (the players pause instead of skipping the track).
125
+ """
126
+
127
+ def __init__(self, null_output: bool = False, buffer_time: float | None = None) -> None:
128
+ self._null_output = null_output
129
+ self._buffer_time = buffer_time
130
+ self._lock = threading.Lock()
131
+ self._device: Callable[[], str] | None = None
132
+ self._headset = False
133
+
134
+ def use_speaker(self) -> None:
135
+ with self._lock:
136
+ self._device, self._headset = None, False
137
+
138
+ def use_headset(self, device: Callable[[], str] | None) -> None:
139
+ """``device``: returns the alsa device of the headset - asked for every playback, it may carry the current
140
+ volume (``None``: the default device - the virtual headset)."""
141
+ with self._lock:
142
+ self._device, self._headset = device, True
143
+
144
+ @property
145
+ def headset(self) -> bool:
146
+ return self._headset
147
+
148
+ @property
149
+ def buffer_time(self) -> float | None:
150
+ """Milliseconds of aplay's buffer (``None``: aplay's default)."""
151
+ return self._buffer_time
152
+
153
+ def current(self) -> tuple[list[str], bool]:
154
+ """(output command, headset)"""
155
+ with self._lock:
156
+ device, headset = self._device, self._headset
157
+ return output_command(self._null_output, self._buffer_time, device() if device is not None else None), headset
158
+
159
+
160
+ DECODER_ERROR_LINES = 10 # the last lines of the decoder's error output are kept for the log
161
+
162
+
163
+ PROGRESS = re.compile(rb"[a-z_0-9.]+= *\S*")
164
+ """A line of ``ffmpeg -progress``: how far the decoder is - no message about the source."""
165
+ UNDERRUN = re.compile(rb"!!! \([^0-9]*([0-9]+(?:[.,][0-9]+)?)")
166
+ """What aplay says when its buffer ran empty: the sound broke off for at least this long. In the language of the box -
167
+ ``underrun!!! (at least 12.345 ms long)``, ``Unterlauf!!! (mindestens 12,345 ms)`` - so only its form is matched."""
168
+
169
+
170
+ def _read_tail(stream: IO[bytes], tail: collections.deque[bytes], watch: Callable[[bytes], bool] | None = None) -> None:
171
+ """Keep the last lines of ``stream``; ``watch(line)`` sees every line first - True: it is no message to keep."""
172
+ with stream:
173
+ for line in stream:
174
+ if watch is None or not watch(line):
175
+ tail.append(line)
176
+
177
+
178
+ class AudioPipeline:
179
+ """``ffmpeg | aplay`` - started by the constructor, stopped by :meth:`stop`; :meth:`wait` blocks until the
180
+ playback has ended (``stream``: see :func:`decoder_command`)."""
181
+
182
+ def __init__(
183
+ self,
184
+ source: str,
185
+ start_time: float = 0,
186
+ output: list[str] | None = None,
187
+ stream: bool = False,
188
+ on_output: Callable[[], None] | None = None,
189
+ on_underrun: Callable[[float], None] | None = None,
190
+ on_heard: Callable[[], None] | None = None,
191
+ heard_after: float = 0,
192
+ ) -> None:
193
+ """``on_output()``: the decoder has produced its first audio; ``on_underrun(ms)``: the sound broke off;
194
+ ``on_heard()``: the decoder has produced ``heard_after`` seconds of audio - aplay's buffer is full, and aplay
195
+ plays once it is full. All from a thread of the pipeline, for the radio (docs/developer/library.md, section
196
+ 4.22)."""
197
+ command = decoder_command(source, start_time, stream)
198
+ progress = on_output is not None or on_heard is not None
199
+ if progress: # ffmpeg says four times a second how much it has decoded
200
+ command[1:1] = ["-progress", "pipe:2", "-stats_period", "0.25"]
201
+ self._on_output = on_output
202
+ self._on_heard = on_heard
203
+ self._heard_bytes = max(1, round(heard_after * CD_BYTES_PER_SECOND))
204
+ self._on_underrun = on_underrun
205
+ self._decoder = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
206
+ assert self._decoder.stdout is not None # noqa: S101 - stdout=PIPE
207
+ assert self._decoder.stderr is not None # noqa: S101 - stderr=PIPE
208
+ try:
209
+ self._output = subprocess.Popen(
210
+ output or output_command(),
211
+ stdin=self._decoder.stdout,
212
+ stdout=subprocess.DEVNULL,
213
+ stderr=subprocess.PIPE,
214
+ )
215
+ except OSError:
216
+ # e.g. aplay not installed - do not leave the decoder running
217
+ self._decoder.kill()
218
+ self._decoder.communicate()
219
+ raise
220
+ # close our copy - the decoder gets SIGPIPE if the output process exits
221
+ self._decoder.stdout.close()
222
+ # ffmpeg logs every broken frame of a damaged file: its error output is read all the time - a full pipe would
223
+ # block the decoder and thus the playback forever. The thread ends when the decoder has ended.
224
+ self._decoder_errors: collections.deque[bytes] = collections.deque(maxlen=DECODER_ERROR_LINES)
225
+ self._decoder_reader = threading.Thread(
226
+ target=_read_tail,
227
+ args=(self._decoder.stderr, self._decoder_errors, self._progress if progress else None),
228
+ name="AudioPipeline.decoder_errors",
229
+ daemon=True,
230
+ )
231
+ self._decoder_reader.start()
232
+ # the stderr pipe of the output is read and closed by wait() - or by stop() if nobody waits. To hear of every
233
+ # underrun at once, a thread of its own reads it and closes it at its end
234
+ self._pipes_lock = threading.Lock()
235
+ self._pipes_owned_by_wait = False
236
+ self._pipes_closed = False
237
+ self._output_errors: collections.deque[bytes] = collections.deque(maxlen=DECODER_ERROR_LINES)
238
+ self._output_reader: threading.Thread | None = None
239
+ if on_underrun is not None:
240
+ assert self._output.stderr is not None # noqa: S101 - stderr=PIPE
241
+ self._pipes_owned_by_wait = True
242
+ self._output_reader = threading.Thread(
243
+ target=_read_tail,
244
+ args=(self._output.stderr, self._output_errors, self._underrun),
245
+ name="AudioPipeline.output_errors",
246
+ daemon=True,
247
+ )
248
+ self._output_reader.start()
249
+
250
+ def _progress(self, line: bytes) -> bool:
251
+ if not PROGRESS.fullmatch(line.strip()):
252
+ return False
253
+ key, _, value = line.strip().partition(b"=")
254
+ if key != b"total_size" or not value.isdigit():
255
+ return True
256
+ size = int(value)
257
+ if size > 0 and self._on_output is not None:
258
+ on_output, self._on_output = self._on_output, None # the first audio only
259
+ on_output()
260
+ if size >= self._heard_bytes and self._on_heard is not None:
261
+ on_heard, self._on_heard = self._on_heard, None
262
+ on_heard()
263
+ return True
264
+
265
+ def _underrun(self, line: bytes) -> bool:
266
+ found = UNDERRUN.search(line)
267
+ if found is None:
268
+ return False
269
+ if self._on_underrun is not None:
270
+ self._on_underrun(float(found.group(1).replace(b",", b".")))
271
+ return True
272
+
273
+ @property
274
+ def output_failed(self) -> bool:
275
+ """True if the output process has ended with an error (after :meth:`wait`)."""
276
+ return self._output.returncode not in (0, None)
277
+
278
+ @property
279
+ def decoder_errors(self) -> str:
280
+ """What the decoder said about a source that it could not decode as it is - its last lines; empty for a
281
+ source without a fault (after :meth:`wait`).
282
+
283
+ ffmpeg plays on through the broken parts of a damaged file and ends without an error: what it logs is the only
284
+ sign, and it logs nothing for a sound file. If the output failed, the decoder ended because nobody took what
285
+ it decoded - that says nothing about the source."""
286
+ if self._output.returncode != 0 or self._decoder.returncode is None:
287
+ return ""
288
+ said = b"".join(self._decoder_errors).decode("utf-8", errors="replace").strip()
289
+ if not said and self._decoder.returncode != 0:
290
+ said = f"the decoder ended with status {self._decoder.returncode}"
291
+ return said
292
+
293
+ def wait(self) -> tuple[bool, str]:
294
+ """Wait for the end of the playback - returns (success, error messages)."""
295
+ if self._output_reader is not None:
296
+ self._output.wait()
297
+ self._output_reader.join()
298
+ output_err = b"".join(self._output_errors)
299
+ else:
300
+ with self._pipes_lock:
301
+ if self._pipes_closed:
302
+ return False, "stopped"
303
+ self._pipes_owned_by_wait = True
304
+ _, output_err = self._output.communicate()
305
+ self._decoder.wait()
306
+ self._decoder_reader.join()
307
+ decoder_err = b"".join(self._decoder_errors)
308
+ errors = [e.decode("utf-8", errors="replace").strip() for e in (decoder_err, output_err) if e]
309
+ success = self._decoder.returncode == 0 and self._output.returncode == 0
310
+ return success, "; ".join(e for e in errors if e)
311
+
312
+ def stop(self, timeout: float = 2.0) -> None:
313
+ """Terminate both processes; a process that ignores SIGTERM for ``timeout`` seconds is killed."""
314
+ # stop the output first -> sound stops immediately
315
+ for process in (self._output, self._decoder):
316
+ if process.poll() is None:
317
+ process.terminate()
318
+ for process in (self._output, self._decoder):
319
+ try:
320
+ process.wait(timeout=timeout)
321
+ except subprocess.TimeoutExpired:
322
+ process.kill()
323
+ process.wait()
324
+ with self._pipes_lock:
325
+ if not self._pipes_owned_by_wait and not self._pipes_closed:
326
+ self._pipes_closed = True
327
+ if self._output.stderr is not None:
328
+ self._output.stderr.close()
329
+
330
+
331
+ class SimpleTrack(ATrack):
332
+ """Plays one mp3 file with an :class:`AudioPipeline`; a monitor thread waits for the end of the playback."""
333
+
334
+ def __init__(
335
+ self,
336
+ filename: str | None,
337
+ logger: logging.Logger,
338
+ end_of_track_hook: TrackEndHook,
339
+ start_time: float = 0,
340
+ clock: Clock = SYSTEM_CLOCK,
341
+ null_output: bool = False,
342
+ on_play: Callable[[], None] | None = None,
343
+ buffer_time: float | None = None,
344
+ output: AudioOutput | None = None,
345
+ ) -> None:
346
+ """``output``: where to play (default: the speaker - ``null_output``/``buffer_time`` apply only then)."""
347
+ super().__init__(filename, logger, end_of_track_hook, start_time, clock)
348
+ self._output = output or AudioOutput(null_output, buffer_time)
349
+ self._pipeline: AudioPipeline | None = None
350
+ self._pipeline_lock = threading.Lock() # the monitor thread and play/stop exchange the pipeline under it
351
+ self._on_play = on_play
352
+
353
+ def _play(self, play_id: int) -> None:
354
+ command, headset = self._output.current()
355
+ pipeline = AudioPipeline(self.filename, self._start_time, command)
356
+ with self._pipeline_lock:
357
+ self._pipeline = pipeline
358
+ if self._on_play is not None:
359
+ self._on_play()
360
+ threading.Thread(
361
+ target=self._monitor_playback, args=(pipeline, play_id, headset), name="SimpleTrack.monitor", daemon=True
362
+ ).start()
363
+
364
+ def _monitor_playback(self, pipeline: AudioPipeline, play_id: int, headset: bool) -> None:
365
+ success, errors = pipeline.wait()
366
+ with self._pipeline_lock:
367
+ if pipeline is not self._pipeline:
368
+ return # stopped via stop() or replaced by a new pipeline
369
+ self._pipeline = None
370
+ if not success and headset and pipeline.output_failed:
371
+ # the headset has gone - the track has not been played to its end
372
+ self._logger.error("headset output failed for %s: %s", self.filename, errors)
373
+ self._output_lost(play_id)
374
+ return
375
+ if not success:
376
+ self._logger.error("playback failed for %s: %s", self.filename, errors)
377
+ damage = pipeline.decoder_errors
378
+ if damage:
379
+ if success:
380
+ self._logger.warning("%s is damaged: %s", self.filename, damage)
381
+ if self.failed_hook is not None:
382
+ self.failed_hook(damage)
383
+ self._end_of_track(play_id)
384
+
385
+ def _stop(self) -> None:
386
+ with self._pipeline_lock:
387
+ pipeline, self._pipeline = self._pipeline, None
388
+ if pipeline is not None:
389
+ pipeline.stop()
390
+
391
+
392
+ class SimplePlayer(AAlbumPlayer):
393
+ """Album player with ffmpeg and aplay (``null_output``: no sound card, see :func:`output_command`).
394
+
395
+ ``on_play`` is called whenever ffmpeg has been started for a track.
396
+ """
397
+
398
+ def __init__(
399
+ self,
400
+ logger: logging.Logger,
401
+ album: TargetAlbum,
402
+ end_of_track_hook: Hook,
403
+ end_of_album_hook: Hook,
404
+ null_output: bool = False,
405
+ clock: Clock = SYSTEM_CLOCK,
406
+ on_play: Callable[[], None] | None = None,
407
+ buffer_time: float | None = None,
408
+ output: AudioOutput | None = None,
409
+ ) -> None:
410
+ """``output``: where to play (default: the speaker - ``null_output``/``buffer_time`` apply only then)."""
411
+ self._output = output or AudioOutput(null_output, buffer_time)
412
+ self._clock = clock
413
+ self._on_play = on_play
414
+ super().__init__(logger, album, end_of_track_hook, end_of_album_hook)
415
+
416
+ def _create_track(self, filename: str, end_of_track_hook: TrackEndHook) -> SimpleTrack:
417
+ return SimpleTrack(
418
+ filename, self._logger, end_of_track_hook, clock=self._clock, on_play=self._on_play, output=self._output
419
+ )