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,198 @@
|
|
|
1
|
+
"""One running Soloist: the process, its commands (``soloist ctl``) and its events (``soloist ctl trace``).
|
|
2
|
+
|
|
3
|
+
Soloist is started with its WebSocket interface on a free port of 127.0.0.1; ``soloist ctl`` finds it through the files
|
|
4
|
+
``ws.addr`` and ``ws.port`` in its data directory. Every event is one line ``<unix_epoch_ms> <json>``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from typing import IO, Any
|
|
14
|
+
|
|
15
|
+
from tinyjukebox.core.logging import get_child
|
|
16
|
+
|
|
17
|
+
__all__ = ["CACHE_MB", "CTL_TIMEOUT", "Soloist", "SoloistError", "parse_event"]
|
|
18
|
+
|
|
19
|
+
CACHE_MB = 100
|
|
20
|
+
"""--cache-size: the smallest value Soloist takes. It does not keep to it (open report no. 8): the box empties the
|
|
21
|
+
cache whenever it ends Soloist."""
|
|
22
|
+
CTL_TIMEOUT = 10.0
|
|
23
|
+
|
|
24
|
+
EventHook = Callable[[dict[str, Any]], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SoloistError(Exception):
|
|
28
|
+
"""A command of ``soloist ctl`` failed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_event(line: str) -> dict[str, Any] | None:
|
|
32
|
+
"""The event of a line of ``soloist ctl trace`` - None for anything else."""
|
|
33
|
+
_, _, text = line.strip().partition(" ")
|
|
34
|
+
try:
|
|
35
|
+
event = json.loads(text)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None
|
|
38
|
+
return event if isinstance(event, dict) and isinstance(event.get("type"), str) else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Soloist:
|
|
42
|
+
"""Soloist of the box, started by :meth:`start` and ended by :meth:`stop`. ``on_event`` is called with every
|
|
43
|
+
event (from a thread of its own), ``on_exit`` with the exit code when Soloist ends without being stopped. Once it
|
|
44
|
+
is stopped, no event is passed on and no command is run any more: the next Soloist - or ``soloist --pair`` of
|
|
45
|
+
"Connect" - may use the data directory already."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
program: str,
|
|
50
|
+
key: str,
|
|
51
|
+
name: str,
|
|
52
|
+
data_dir: str,
|
|
53
|
+
cache_dir: str,
|
|
54
|
+
logger: logging.Logger,
|
|
55
|
+
env: Mapping[str, str] | None = None,
|
|
56
|
+
device: str | None = None,
|
|
57
|
+
popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen,
|
|
58
|
+
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._program = program
|
|
61
|
+
self._key = key
|
|
62
|
+
self._name = name
|
|
63
|
+
self._data_dir = data_dir
|
|
64
|
+
self._cache_dir = cache_dir
|
|
65
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
66
|
+
self._env = dict(env) if env is not None else None
|
|
67
|
+
self._device = device
|
|
68
|
+
self._popen = popen
|
|
69
|
+
self._run = run
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
72
|
+
self._trace: subprocess.Popen[bytes] | None = None
|
|
73
|
+
self._threads: list[threading.Thread] = []
|
|
74
|
+
self._stopped = threading.Event()
|
|
75
|
+
self.on_event: EventHook = lambda event: None
|
|
76
|
+
self.on_exit: Callable[[int], None] = lambda code: None
|
|
77
|
+
|
|
78
|
+
def command(self) -> list[str]:
|
|
79
|
+
command = [self._program, "-n", self._name, "-k", self._key, "-D", self._data_dir, "-C", self._cache_dir,
|
|
80
|
+
"-z", str(CACHE_MB), "-i", "100", "-w", "127.0.0.1:0"] # fmt: skip
|
|
81
|
+
if self._device is not None:
|
|
82
|
+
command += ["-d", self._device]
|
|
83
|
+
return command
|
|
84
|
+
|
|
85
|
+
def start(self) -> None:
|
|
86
|
+
"""Start Soloist and follow its events. :raises OSError: if it cannot be started."""
|
|
87
|
+
self._stopped.clear()
|
|
88
|
+
process = self._popen(self.command(), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=self._env)
|
|
89
|
+
with self._lock:
|
|
90
|
+
self._process = process
|
|
91
|
+
self._logger.info("Soloist started (pid %s)", process.pid)
|
|
92
|
+
for target, name in ((self._follow, "Soloist.trace"), (self._watch, "Soloist.watch")):
|
|
93
|
+
thread = threading.Thread(target=target, args=(process,), name=name, daemon=True)
|
|
94
|
+
self._threads.append(thread)
|
|
95
|
+
thread.start()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def running(self) -> bool:
|
|
99
|
+
process = self._process
|
|
100
|
+
return process is not None and process.poll() is None
|
|
101
|
+
|
|
102
|
+
def _watch(self, process: subprocess.Popen[bytes]) -> None:
|
|
103
|
+
"""Wait for the end of Soloist; what it said last goes to the log."""
|
|
104
|
+
said: list[bytes] = []
|
|
105
|
+
if process.stderr is not None:
|
|
106
|
+
with contextlib.suppress(OSError, ValueError), process.stderr:
|
|
107
|
+
for line in process.stderr:
|
|
108
|
+
said = [*said[-9:], line]
|
|
109
|
+
code = process.wait()
|
|
110
|
+
if self._stopped.is_set():
|
|
111
|
+
return
|
|
112
|
+
self._logger.warning(
|
|
113
|
+
"Soloist ended (%s): %s", code, b"".join(said).decode("utf-8", errors="replace").strip()[-500:]
|
|
114
|
+
)
|
|
115
|
+
self.on_exit(code)
|
|
116
|
+
|
|
117
|
+
def _follow(self, process: subprocess.Popen[bytes]) -> None:
|
|
118
|
+
"""``soloist ctl trace`` - started again while Soloist runs (it needs a moment until its port is known)."""
|
|
119
|
+
while not self._stopped.is_set() and process.poll() is None:
|
|
120
|
+
try:
|
|
121
|
+
trace = self._popen(
|
|
122
|
+
self._ctl("trace"), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=self._env
|
|
123
|
+
)
|
|
124
|
+
except OSError as e:
|
|
125
|
+
self._logger.error("cannot follow the events of Soloist: %s", e)
|
|
126
|
+
return
|
|
127
|
+
with self._lock:
|
|
128
|
+
self._trace = trace
|
|
129
|
+
self._read(trace.stdout)
|
|
130
|
+
if trace.stdout is not None:
|
|
131
|
+
trace.stdout.close()
|
|
132
|
+
trace.wait()
|
|
133
|
+
if not self._stopped.wait(0.5):
|
|
134
|
+
self._logger.debug("soloist ctl trace ended (%s) - following again", trace.returncode)
|
|
135
|
+
|
|
136
|
+
def _read(self, stream: IO[bytes] | None) -> None:
|
|
137
|
+
if stream is None:
|
|
138
|
+
return
|
|
139
|
+
with contextlib.suppress(OSError, ValueError):
|
|
140
|
+
for raw in stream:
|
|
141
|
+
if self._stopped.is_set():
|
|
142
|
+
return # what was still in the pipe comes from a Soloist that the box has ended
|
|
143
|
+
event = parse_event(raw.decode("utf-8", errors="replace"))
|
|
144
|
+
if event is not None:
|
|
145
|
+
try:
|
|
146
|
+
self.on_event(event)
|
|
147
|
+
except Exception:
|
|
148
|
+
self._logger.exception("handling the event %s failed", event.get("type"))
|
|
149
|
+
|
|
150
|
+
def _ctl(self, *args: str) -> list[str]:
|
|
151
|
+
return [self._program, "ctl", "-D", self._data_dir, *args]
|
|
152
|
+
|
|
153
|
+
def ctl(self, *args: str) -> str:
|
|
154
|
+
"""A command of ``soloist ctl``; returns what it printed. :raises SoloistError: if it fails, or if Soloist
|
|
155
|
+
has been stopped."""
|
|
156
|
+
if self._stopped.is_set():
|
|
157
|
+
raise SoloistError(f"soloist ctl {' '.join(args)}: Soloist has been stopped")
|
|
158
|
+
try:
|
|
159
|
+
done = self._run(self._ctl(*args), capture_output=True, text=True, timeout=CTL_TIMEOUT, env=self._env)
|
|
160
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
161
|
+
raise SoloistError(f"soloist ctl {' '.join(args)}: {e}") from e
|
|
162
|
+
if done.returncode != 0:
|
|
163
|
+
raise SoloistError(f"soloist ctl {' '.join(args)}: {(done.stderr or done.stdout).strip()[-300:]}")
|
|
164
|
+
return done.stdout
|
|
165
|
+
|
|
166
|
+
def json(self, *args: str) -> dict[str, Any]:
|
|
167
|
+
"""A command of ``soloist ctl`` that prints json (``now --json``, ``queue N --json``)."""
|
|
168
|
+
text = self.ctl(*args)
|
|
169
|
+
try:
|
|
170
|
+
value = json.loads(text)
|
|
171
|
+
except ValueError as e:
|
|
172
|
+
raise SoloistError(f"soloist ctl {' '.join(args)}: no json") from e
|
|
173
|
+
return value if isinstance(value, dict) else {}
|
|
174
|
+
|
|
175
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
176
|
+
self._stopped.set()
|
|
177
|
+
with self._lock:
|
|
178
|
+
processes: Sequence[subprocess.Popen[bytes] | None] = (self._trace, self._process)
|
|
179
|
+
self._trace = self._process = None
|
|
180
|
+
for process in processes:
|
|
181
|
+
if process is not None and process.poll() is None:
|
|
182
|
+
process.terminate()
|
|
183
|
+
for process in processes:
|
|
184
|
+
if process is None:
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
process.wait(timeout)
|
|
188
|
+
except subprocess.TimeoutExpired:
|
|
189
|
+
process.kill()
|
|
190
|
+
try:
|
|
191
|
+
process.wait(timeout)
|
|
192
|
+
except subprocess.TimeoutExpired:
|
|
193
|
+
self._logger.error("Soloist does not end (pid %s) - left behind", process.pid)
|
|
194
|
+
for thread in self._threads:
|
|
195
|
+
if thread is not threading.current_thread():
|
|
196
|
+
thread.join(timeout)
|
|
197
|
+
self._threads = []
|
|
198
|
+
self._logger.info("Soloist stopped")
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The sound of Soloist on the box: a PipeWire of its own whose only output is a named pipe, and the reader of that pipe
|
|
2
|
+
that hands the sound to ``aplay`` (docs/developer/architecture.md, section 4.1).
|
|
3
|
+
|
|
4
|
+
Soloist plays only into PipeWire or PulseAudio; the box plays with ``aplay``, to the speaker or through BlueALSA to the
|
|
5
|
+
headset. PipeWire and WirePlumber are started with a setup of their own in the folder ``spotify``: no sound card, no
|
|
6
|
+
Bluetooth, one sink that writes 16 bit stereo, 44.1 kHz into the pipe. The box reads the pipe itself. That way it sees
|
|
7
|
+
whether sound comes (a playback that stands still while Soloist says "playing", open report no. 5), it starts another
|
|
8
|
+
``aplay`` on another output without touching Soloist, and it drops the sound while it skips to a saved track.
|
|
9
|
+
|
|
10
|
+
A process is ended with SIGTERM first and SIGKILL after a while, and every wait is bounded: an ``aplay`` that hangs in
|
|
11
|
+
its driver never empties its pipe, and whoever ends it must not wait for that.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import errno
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import select
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
from typing import IO, Any
|
|
25
|
+
|
|
26
|
+
from tinyjukebox.albumplayer.simpleplayer import AudioOutput
|
|
27
|
+
from tinyjukebox.core.logging import get_child
|
|
28
|
+
|
|
29
|
+
__all__ = ["NODE", "PipeReader", "PipeWire", "SoundServer", "is_silent"]
|
|
30
|
+
|
|
31
|
+
NODE = "tinyjukebox"
|
|
32
|
+
"""Name of the sink of PipeWire into which Soloist plays (``--pipewire-device``)."""
|
|
33
|
+
CHUNK = 4096
|
|
34
|
+
FORMAT = "S16LE"
|
|
35
|
+
END_TIMEOUT = 2.0
|
|
36
|
+
"""Seconds a process has to end after SIGTERM, and again after SIGKILL."""
|
|
37
|
+
|
|
38
|
+
PIPEWIRE_CONF = """# written by tinyjukebox: the only output of this PipeWire is the pipe that the box reads
|
|
39
|
+
context.modules = [
|
|
40
|
+
{{ name = libpipewire-module-pipe-tunnel
|
|
41
|
+
args = {{
|
|
42
|
+
tunnel.mode = sink
|
|
43
|
+
pipe.filename = "{pipe}"
|
|
44
|
+
audio.format = {format}
|
|
45
|
+
audio.rate = 44100
|
|
46
|
+
audio.channels = 2
|
|
47
|
+
audio.position = [ FL FR ]
|
|
48
|
+
stream.props = {{
|
|
49
|
+
node.name = "{node}"
|
|
50
|
+
node.description = "Tiny Jukebox"
|
|
51
|
+
priority.session = 2000
|
|
52
|
+
}}
|
|
53
|
+
}}
|
|
54
|
+
}}
|
|
55
|
+
]
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
WIREPLUMBER_CONF = """# written by tinyjukebox: no sound card and no Bluetooth - those belong to the box and to BlueALSA
|
|
59
|
+
wireplumber.profiles = {
|
|
60
|
+
main = {
|
|
61
|
+
monitor.alsa = disabled
|
|
62
|
+
monitor.alsa-midi = disabled
|
|
63
|
+
monitor.bluez = disabled
|
|
64
|
+
monitor.bluez.midi = disabled
|
|
65
|
+
monitor.v4l2 = disabled
|
|
66
|
+
monitor.libcamera = disabled
|
|
67
|
+
support.reserve-device = disabled
|
|
68
|
+
support.logind = disabled
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_silent(chunk: bytes) -> bool:
|
|
75
|
+
"""Digital silence: nothing but zeros."""
|
|
76
|
+
return not chunk.strip(b"\x00")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SoundServer:
|
|
80
|
+
"""What Soloist plays into - ``env`` for Soloist, ``device`` its sink, ``reader`` the pipe."""
|
|
81
|
+
|
|
82
|
+
env: Mapping[str, str] | None = None
|
|
83
|
+
device: str | None = None
|
|
84
|
+
reader: "PipeReader"
|
|
85
|
+
|
|
86
|
+
def start(self) -> None: ...
|
|
87
|
+
|
|
88
|
+
def stop(self) -> None: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PipeReader:
|
|
92
|
+
"""Reads the pipe and hands the sound to ``aplay`` on the current output. ``muted``: the sound is dropped.
|
|
93
|
+
``on_output_lost``: ``aplay`` failed on the headset (it has gone)."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
pipe: str,
|
|
98
|
+
output: AudioOutput,
|
|
99
|
+
logger: logging.Logger,
|
|
100
|
+
popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen,
|
|
101
|
+
clock: Callable[[], float] = time.monotonic,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._pipe = pipe
|
|
104
|
+
self._output = output
|
|
105
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
106
|
+
self._popen = popen
|
|
107
|
+
self._clock = clock
|
|
108
|
+
self._lock = threading.Lock()
|
|
109
|
+
self._aplay: subprocess.Popen[bytes] | None = None
|
|
110
|
+
self._headset = False
|
|
111
|
+
self._stopped = threading.Event()
|
|
112
|
+
self._thread: threading.Thread | None = None
|
|
113
|
+
self._fds: tuple[int, int] | None = None
|
|
114
|
+
self.muted = True
|
|
115
|
+
self.last_sound = clock()
|
|
116
|
+
"""When the last chunk that was not silent arrived (while not muted)."""
|
|
117
|
+
self.on_output_lost: Callable[[], None] = lambda: None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def pipe(self) -> str:
|
|
121
|
+
return self._pipe
|
|
122
|
+
|
|
123
|
+
def open(self) -> None:
|
|
124
|
+
"""Create the pipe and open it - before PipeWire starts, which writes into it."""
|
|
125
|
+
with contextlib.suppress(FileNotFoundError):
|
|
126
|
+
os.remove(self._pipe)
|
|
127
|
+
os.mkfifo(self._pipe, 0o600)
|
|
128
|
+
read = os.open(self._pipe, os.O_RDONLY | os.O_NONBLOCK)
|
|
129
|
+
# a writer of our own: without one, every read returns at once while PipeWire has not opened the pipe
|
|
130
|
+
write = os.open(self._pipe, os.O_WRONLY | os.O_NONBLOCK)
|
|
131
|
+
self._fds = (read, write)
|
|
132
|
+
self._stopped.clear()
|
|
133
|
+
self._thread = threading.Thread(target=self._run, name="Spotify.pipe", daemon=True)
|
|
134
|
+
self._thread.start()
|
|
135
|
+
|
|
136
|
+
def _run(self) -> None:
|
|
137
|
+
assert self._fds is not None # noqa: S101 - set by open()
|
|
138
|
+
read = self._fds[0]
|
|
139
|
+
while not self._stopped.is_set():
|
|
140
|
+
ready, _, _ = select.select([read], [], [], 0.5)
|
|
141
|
+
if not ready:
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
chunk = os.read(read, CHUNK)
|
|
145
|
+
except BlockingIOError:
|
|
146
|
+
continue
|
|
147
|
+
except OSError:
|
|
148
|
+
break
|
|
149
|
+
if not chunk:
|
|
150
|
+
time.sleep(0.05)
|
|
151
|
+
continue
|
|
152
|
+
if self.muted:
|
|
153
|
+
continue
|
|
154
|
+
if not is_silent(chunk):
|
|
155
|
+
self.last_sound = self._clock()
|
|
156
|
+
self._play(chunk)
|
|
157
|
+
|
|
158
|
+
def _play(self, chunk: bytes) -> None:
|
|
159
|
+
with self._lock:
|
|
160
|
+
aplay = self._aplay
|
|
161
|
+
if aplay is None or aplay.poll() is not None:
|
|
162
|
+
if self.muted: # silenced meanwhile: no aplay of its own any more
|
|
163
|
+
return
|
|
164
|
+
command, self._headset = self._output.current()
|
|
165
|
+
try:
|
|
166
|
+
# unbuffered: ending aplay never flushes into a pipe that a hung aplay does not empty
|
|
167
|
+
aplay = self._popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
|
168
|
+
stderr=subprocess.DEVNULL, bufsize=0) # fmt: skip
|
|
169
|
+
except OSError as e:
|
|
170
|
+
self._logger.error("cannot start %s: %s", command[0], e)
|
|
171
|
+
return
|
|
172
|
+
self._aplay = aplay
|
|
173
|
+
try:
|
|
174
|
+
assert aplay.stdin is not None # noqa: S101 - stdin=PIPE
|
|
175
|
+
_write_all(aplay.stdin, chunk)
|
|
176
|
+
except (BrokenPipeError, ValueError, OSError):
|
|
177
|
+
with self._lock:
|
|
178
|
+
if self._aplay is not aplay:
|
|
179
|
+
return # ended by the box: another output, or silence
|
|
180
|
+
headset, self._aplay = self._headset, None
|
|
181
|
+
if headset:
|
|
182
|
+
# never on to the speaker by itself: the box must not become loud unexpectedly
|
|
183
|
+
self.muted = True
|
|
184
|
+
self._logger.warning("the headset output failed - pause")
|
|
185
|
+
self.on_output_lost()
|
|
186
|
+
|
|
187
|
+
def restart_output(self) -> None:
|
|
188
|
+
"""End ``aplay``: the next sound starts another one, on the output that is current then. Waits at most
|
|
189
|
+
twice :data:`END_TIMEOUT`."""
|
|
190
|
+
with self._lock:
|
|
191
|
+
aplay, self._aplay = self._aplay, None
|
|
192
|
+
if not _end(aplay):
|
|
193
|
+
self._logger.error("aplay does not end (pid %s) - left behind", getattr(aplay, "pid", None))
|
|
194
|
+
|
|
195
|
+
def silence(self) -> None:
|
|
196
|
+
"""Drop the sound from now on, and end ``aplay``: what is in its buffer is not heard."""
|
|
197
|
+
self.muted = True
|
|
198
|
+
self.restart_output()
|
|
199
|
+
|
|
200
|
+
def sound(self) -> None:
|
|
201
|
+
"""Hand the sound to ``aplay`` again."""
|
|
202
|
+
self.last_sound = self._clock()
|
|
203
|
+
self.muted = False
|
|
204
|
+
|
|
205
|
+
def close(self) -> None:
|
|
206
|
+
"""End the reader and its ``aplay`` - aplay first: the reader may wait for it to take the sound."""
|
|
207
|
+
self._stopped.set()
|
|
208
|
+
self.silence()
|
|
209
|
+
thread, self._thread = self._thread, None
|
|
210
|
+
if thread is not None and thread is not threading.current_thread():
|
|
211
|
+
thread.join(2)
|
|
212
|
+
fds, self._fds = self._fds, None
|
|
213
|
+
for fd in fds or ():
|
|
214
|
+
with contextlib.suppress(OSError):
|
|
215
|
+
os.close(fd)
|
|
216
|
+
with contextlib.suppress(FileNotFoundError):
|
|
217
|
+
os.remove(self._pipe)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _write_all(stream: IO[bytes], chunk: bytes) -> None:
|
|
221
|
+
"""All of ``chunk`` into an unbuffered pipe: a write interrupted by a signal takes only a part of it."""
|
|
222
|
+
written: int | None = stream.write(chunk)
|
|
223
|
+
while written is not None and 0 < written < len(chunk):
|
|
224
|
+
chunk = chunk[written:]
|
|
225
|
+
written = stream.write(chunk)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _end(process: subprocess.Popen[Any] | None, timeout: float | None = None) -> bool:
|
|
229
|
+
"""End ``process``: SIGTERM, SIGKILL after ``timeout`` (:data:`END_TIMEOUT`) - every wait is bounded. Its input is
|
|
230
|
+
closed only then: closed first, it would be flushed into a pipe that a hung ``aplay`` never empties. Returns False
|
|
231
|
+
if it does not end (it hangs in a driver) - it is left behind."""
|
|
232
|
+
if process is None:
|
|
233
|
+
return True
|
|
234
|
+
timeout = END_TIMEOUT if timeout is None else timeout
|
|
235
|
+
ended = True
|
|
236
|
+
if process.poll() is None:
|
|
237
|
+
process.terminate()
|
|
238
|
+
try:
|
|
239
|
+
process.wait(timeout)
|
|
240
|
+
except subprocess.TimeoutExpired:
|
|
241
|
+
process.kill()
|
|
242
|
+
try:
|
|
243
|
+
process.wait(timeout)
|
|
244
|
+
except subprocess.TimeoutExpired:
|
|
245
|
+
ended = False
|
|
246
|
+
if process.stdin is not None:
|
|
247
|
+
with contextlib.suppress(OSError, ValueError):
|
|
248
|
+
process.stdin.close()
|
|
249
|
+
return ended
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class PipeWire(SoundServer):
|
|
253
|
+
"""PipeWire and WirePlumber of the box, in ``folder`` (``spotify/sound``): config, runtime directory, pipe."""
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
folder: str,
|
|
258
|
+
output: AudioOutput,
|
|
259
|
+
logger: logging.Logger,
|
|
260
|
+
popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen,
|
|
261
|
+
) -> None:
|
|
262
|
+
self._folder = folder
|
|
263
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
264
|
+
self._popen = popen
|
|
265
|
+
self._processes: list[subprocess.Popen[bytes]] = []
|
|
266
|
+
runtime = os.path.join(folder, "run")
|
|
267
|
+
self._env: dict[str, str] = {
|
|
268
|
+
**os.environ,
|
|
269
|
+
"XDG_RUNTIME_DIR": runtime,
|
|
270
|
+
"PIPEWIRE_RUNTIME_DIR": runtime,
|
|
271
|
+
"XDG_CONFIG_HOME": os.path.join(folder, "config"),
|
|
272
|
+
"XDG_STATE_HOME": os.path.join(folder, "state"),
|
|
273
|
+
}
|
|
274
|
+
self._env.pop("DBUS_SESSION_BUS_ADDRESS", None)
|
|
275
|
+
self._env.pop("PULSE_SERVER", None)
|
|
276
|
+
self.env = self._env
|
|
277
|
+
self.device = NODE
|
|
278
|
+
self.reader = PipeReader(os.path.join(folder, "sound.pipe"), output, logger, popen)
|
|
279
|
+
|
|
280
|
+
def _write_config(self) -> None:
|
|
281
|
+
for name, content in (
|
|
282
|
+
(os.path.join("pipewire", "pipewire.conf.d", "tinyjukebox.conf"),
|
|
283
|
+
PIPEWIRE_CONF.format(pipe=self.reader.pipe, format=FORMAT, node=NODE)),
|
|
284
|
+
(os.path.join("wireplumber", "wireplumber.conf.d", "tinyjukebox.conf"), WIREPLUMBER_CONF),
|
|
285
|
+
): # fmt: skip
|
|
286
|
+
path = os.path.join(self._env["XDG_CONFIG_HOME"], name)
|
|
287
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
288
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
289
|
+
f.write(content)
|
|
290
|
+
|
|
291
|
+
def start(self) -> None:
|
|
292
|
+
""":raises OSError: if PipeWire or WirePlumber cannot be started (e.g. not installed)."""
|
|
293
|
+
for program in ("pipewire", "wireplumber"):
|
|
294
|
+
if shutil.which(program) is None:
|
|
295
|
+
raise OSError(errno.ENOENT, f"{program} is not installed")
|
|
296
|
+
shutil.rmtree(self._env["XDG_RUNTIME_DIR"], ignore_errors=True)
|
|
297
|
+
os.makedirs(self._env["XDG_RUNTIME_DIR"], mode=0o700, exist_ok=True)
|
|
298
|
+
self._write_config()
|
|
299
|
+
self.reader.open()
|
|
300
|
+
try:
|
|
301
|
+
for program in ("pipewire", "wireplumber"):
|
|
302
|
+
self._processes.append(
|
|
303
|
+
self._popen([program], env=self._env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
304
|
+
)
|
|
305
|
+
time.sleep(0.3) # WirePlumber connects to PipeWire
|
|
306
|
+
except OSError:
|
|
307
|
+
self.stop()
|
|
308
|
+
raise
|
|
309
|
+
self._logger.info("PipeWire for Spotify started")
|
|
310
|
+
|
|
311
|
+
def stop(self) -> None:
|
|
312
|
+
self.reader.close()
|
|
313
|
+
for process in reversed(self._processes):
|
|
314
|
+
if not _end(process):
|
|
315
|
+
self._logger.error("%s does not end (pid %s) - left behind", process.args, process.pid)
|
|
316
|
+
self._processes = []
|
|
File without changes
|
tinyjukebox/stm/base.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Common base of the state machines: a robust tantamount machine, the state base class and diagram export."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tantamount.fsm2dot import GetDotNotation
|
|
11
|
+
from tantamount.machine import Machine
|
|
12
|
+
from tantamount.simplestate import SimpleState
|
|
13
|
+
|
|
14
|
+
from tinyjukebox.core import unexpected
|
|
15
|
+
from tinyjukebox.core.logging import get_child
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"RESUME_WINDOW",
|
|
19
|
+
"OutputPause",
|
|
20
|
+
"PauseReason",
|
|
21
|
+
"PeriodicTask",
|
|
22
|
+
"SafeMachine",
|
|
23
|
+
"State",
|
|
24
|
+
"create_machine",
|
|
25
|
+
"mermaid_notation",
|
|
26
|
+
"write_dot_file",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
RESUME_WINDOW = 600.0
|
|
30
|
+
"""Seconds within which a headset that comes back continues a playback that its disconnection has paused."""
|
|
31
|
+
_CALL = object() # in the queue of the worker in place of an event: a function to call (SafeMachine.call_soon)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SafeMachine(Machine): # type: ignore[misc] # tantamount has no type information
|
|
35
|
+
"""tantamount machine that ignores undefined events and whose async worker survives exceptions of states.
|
|
36
|
+
|
|
37
|
+
tantamount 0.3.2 has two problems that are worked around here:
|
|
38
|
+
|
|
39
|
+
* The constructor stores ``ignore_undefined_events`` under the wrong attribute name. Without the flag an
|
|
40
|
+
unexpected event (e.g. a button press while an album is finished) raises a ``KeyError`` in the worker thread.
|
|
41
|
+
* An exception in ``on_entry``/``on_exit`` of a state reached via ``asyncoperate()`` terminates the worker
|
|
42
|
+
thread - afterwards the machine silently ignores all events and ``stop()`` blocks forever.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, logger: logging.Logger) -> None:
|
|
46
|
+
super().__init__(ignore_undefined_events=True)
|
|
47
|
+
self._ignore_undefined_events = True
|
|
48
|
+
self._logger = logger
|
|
49
|
+
|
|
50
|
+
def call_soon(self, function: Callable[[], object]) -> None:
|
|
51
|
+
"""Call ``function`` in the worker, after the events queued before it - e.g. what has to happen once a queued
|
|
52
|
+
event has been handled. A machine that is stopped before calls it no more."""
|
|
53
|
+
self._async_queue.put((_CALL, function, None))
|
|
54
|
+
|
|
55
|
+
def _async_worker(self) -> None:
|
|
56
|
+
while True:
|
|
57
|
+
params = self._async_queue.get()
|
|
58
|
+
if params is None or self._stopping:
|
|
59
|
+
self._async_queue.task_done()
|
|
60
|
+
break
|
|
61
|
+
eventid, source_stateid, source_activation_counter = params
|
|
62
|
+
try:
|
|
63
|
+
if eventid is _CALL:
|
|
64
|
+
source_stateid()
|
|
65
|
+
else:
|
|
66
|
+
self.operate(eventid, source_stateid, source_activation_counter)
|
|
67
|
+
except Exception as error:
|
|
68
|
+
self._logger.exception("_async_worker - processing event '%s' failed", eventid)
|
|
69
|
+
unexpected.report(error, f"state machine, event {eventid}")
|
|
70
|
+
self._async_queue.task_done()
|
|
71
|
+
|
|
72
|
+
def wait_until_idle(self) -> None:
|
|
73
|
+
"""Block until all events queued with ``asyncoperate`` have been processed (tests)."""
|
|
74
|
+
self._async_queue.join()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_machine(logger: logging.Logger) -> SafeMachine:
|
|
78
|
+
"""A new :class:`SafeMachine` (a tantamount machine can be started only once)."""
|
|
79
|
+
return SafeMachine(logger)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class State(SimpleState): # type: ignore[misc] # tantamount has no type information
|
|
83
|
+
"""Base class of all states.
|
|
84
|
+
|
|
85
|
+
tantamount calls ``on_entry``/``on_exit`` with the machine's operate lock held - states are never entered or
|
|
86
|
+
left concurrently. ``_on_entry`` may return a transition that is processed immediately.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, state_id: Enum, logger: logging.Logger) -> None:
|
|
90
|
+
super().__init__(state_id, None)
|
|
91
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
92
|
+
|
|
93
|
+
def on_entry(self) -> Enum | None:
|
|
94
|
+
self._logger.info("on_entry")
|
|
95
|
+
return self._on_entry()
|
|
96
|
+
|
|
97
|
+
def on_exit(self) -> None:
|
|
98
|
+
self._logger.info("on_exit")
|
|
99
|
+
self._on_exit()
|
|
100
|
+
|
|
101
|
+
def _on_entry(self) -> Enum | None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def _on_exit(self) -> None:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def write_dot_file(machine: Machine, filename: str | Path) -> None:
|
|
109
|
+
"""Write a Graphviz representation of the machine (``dot -Tpng file.dot > file.png``)."""
|
|
110
|
+
Path(filename).write_text(GetDotNotation(machine).getdotnotation(), encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def mermaid_notation(machine: Machine) -> str:
|
|
114
|
+
"""Mermaid ``stateDiagram-v2`` of the machine - generates the diagrams in docs/developer/architecture.md."""
|
|
115
|
+
lines = ["stateDiagram-v2", f" [*] --> {machine._start_state.id.name}"]
|
|
116
|
+
for source, transitions in machine._transitions.items():
|
|
117
|
+
for event, transition in transitions.items():
|
|
118
|
+
label = event.name
|
|
119
|
+
timeout = machine._timeoutevents.get(source)
|
|
120
|
+
if timeout is not None and timeout[0] == event:
|
|
121
|
+
label += " (timeout)"
|
|
122
|
+
lines.append(f" {source.name} --> {transition.targetstateid.name}: {label}")
|
|
123
|
+
return "\n".join(lines)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PeriodicTask:
|
|
127
|
+
"""Calls ``action`` every ``interval`` seconds in its own thread until :meth:`stop` is called."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, interval: float, action: Callable[[], object], name: str, logger: logging.Logger) -> None:
|
|
130
|
+
self._interval = interval
|
|
131
|
+
self._action = action
|
|
132
|
+
self._logger = logger
|
|
133
|
+
self._stop = threading.Event()
|
|
134
|
+
self._thread = threading.Thread(target=self._run, name=name, daemon=True)
|
|
135
|
+
|
|
136
|
+
def _run(self) -> None:
|
|
137
|
+
while not self._stop.wait(self._interval):
|
|
138
|
+
try:
|
|
139
|
+
self._action()
|
|
140
|
+
except Exception as error:
|
|
141
|
+
self._logger.exception("periodic task '%s' failed", self._thread.name)
|
|
142
|
+
unexpected.report(error, f"periodic task {self._thread.name}")
|
|
143
|
+
|
|
144
|
+
def start(self) -> None:
|
|
145
|
+
self._thread.start()
|
|
146
|
+
|
|
147
|
+
def stop(self) -> None:
|
|
148
|
+
"""Stop and wait for a running action to finish (unless called by the action itself)."""
|
|
149
|
+
self._stop.set()
|
|
150
|
+
if self._thread.is_alive() and self._thread is not threading.current_thread():
|
|
151
|
+
self._thread.join()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PauseReason(Enum):
|
|
155
|
+
USER = "user" # play/pause button (headset, web page, command line)
|
|
156
|
+
OUTPUT = "output" # the headset has gone (out of range, switched off, empty battery)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class OutputPause:
|
|
160
|
+
"""Why and since when a playback is paused - only a pause caused by the loss of the headset is continued when the
|
|
161
|
+
headset comes back, and only within :data:`RESUME_WINDOW` seconds."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
|
|
164
|
+
self._clock = clock
|
|
165
|
+
self.reason: PauseReason | None = None
|
|
166
|
+
self.since = 0.0
|
|
167
|
+
|
|
168
|
+
def paused(self, reason: PauseReason) -> None:
|
|
169
|
+
self.reason = reason
|
|
170
|
+
self.since = self._clock()
|
|
171
|
+
|
|
172
|
+
def resumes_on_output(self) -> bool:
|
|
173
|
+
"""True if the headset that has come back continues the playback."""
|
|
174
|
+
return self.reason is PauseReason.OUTPUT and self._clock() - self.since <= RESUME_WINDOW
|