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,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
@@ -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