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
|
@@ -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
|
+
)
|