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,647 @@
1
+ """The key, the player and the login of Spotify on this box (docs/developer/architecture.md, section 4.1).
2
+
3
+ Everything is kept in one folder of the box, ``spotify`` next to ``json`` and ``sync``. It is never synchronized and
4
+ is not part of a backup::
5
+
6
+ spotify/key the Soloist API key (mode 600)
7
+ spotify/settings.json {"speaker": bool, "connected": time or null}
8
+ spotify/state.json the build: when it was made, its Last-Modified; the last question to Spotify
9
+ spotify/bin/soloist the player, fetched from Spotify; soloist.previous: the build before
10
+ spotify/data the data directory of Soloist: the login (--data-dir)
11
+ spotify/cache its cache (--cache-dir)
12
+
13
+ Spotify does not allow passing Soloist on: the box fetches it itself, from Spotify, when the key is entered. Every
14
+ build stops working 90 days after it was built. The box keeps the build it has while that has more than
15
+ :data:`RENEW_DAYS` days left: once a day it looks at its age, and only then it asks Spotify whether the archive has
16
+ changed (``If-Modified-Since``). A build that has expired (exit code 10) and "Update now" ask at once.
17
+
18
+ A download is checked against its ``ETag`` when that is the MD5 of the archive; otherwise it is trusted because it
19
+ comes from Spotify's address over HTTPS, and the new build replaces the old one only if ``soloist --version`` runs. It
20
+ is unpacked in pieces and refused if the program unpacks to more than :data:`MAX_PROGRAM` bytes, everything else to more
21
+ than :data:`MAX_REST` bytes, or if more than :data:`MAX_FILES` files come before the program.
22
+ """
23
+
24
+ import contextlib
25
+ import ctypes.util
26
+ import datetime
27
+ import functools
28
+ import gzip
29
+ import hashlib
30
+ import io
31
+ import json
32
+ import logging
33
+ import os
34
+ import platform
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ import tarfile
39
+ import threading
40
+ import urllib.error
41
+ import urllib.request
42
+ import zlib
43
+ from collections.abc import Callable
44
+ from typing import IO, Any, cast
45
+
46
+ from tinyjukebox.core.logging import get_child
47
+ from tinyjukebox.errors import BadRequest, Conflict
48
+
49
+ __all__ = [
50
+ "ARCHITECTURES",
51
+ "BUILDS",
52
+ "CONNECT_SECONDS",
53
+ "EXPIRED",
54
+ "LIFETIME_DAYS",
55
+ "MAX_FILES",
56
+ "MAX_PROGRAM",
57
+ "MAX_REST",
58
+ "RENEW_DAYS",
59
+ "Busy",
60
+ "SpotifyAccount",
61
+ "Unavailable",
62
+ "build_time",
63
+ ]
64
+
65
+ BUILDS = "https://soloist-builds.spotifycdn.com/soloist_release_{}.tar.gz"
66
+ ARCHITECTURES = {"aarch64": "arm64", "arm64": "arm64", "armv7l": "arm32", "x86_64": "x86_64", "amd64": "x86_64"}
67
+ """``uname -m`` -> the name of the build of Soloist."""
68
+ LIFETIME_DAYS = 90
69
+ RENEW_DAYS = 30
70
+ """The box looks for a new build once its build has fewer days left than this."""
71
+ EXPIRED = 10
72
+ """Exit code of Soloist: the build has expired."""
73
+ CONNECT_SECONDS = 300.0
74
+ """How long "Connect" waits for the box to be chosen in the Spotify app."""
75
+ CHECK_EVERY = 24 * 3600.0
76
+ MAX_ARCHIVE = 128 * 2**20
77
+ MAX_PROGRAM = 4 * MAX_ARCHIVE
78
+ """Bytes the program may unpack to at most - a decompression bomb would fill the card."""
79
+ MAX_REST = 16 * 2**20
80
+ """Bytes of everything else in the archive: the other files and the headers, which tarfile keeps in memory."""
81
+ MAX_FILES = 100
82
+ """Files the archive may hold before the program (it holds a few)."""
83
+ CHUNK = 2**20
84
+ MAX_KEY_LENGTH = 400
85
+ AVAHI = ("avahi-daemon.socket", "avahi-daemon.service")
86
+ _BUILD = re.compile(r"\bbuild\s+(\d{9,11})\b")
87
+ _DATE = re.compile(r"\b(20\d{2})(\d{2})(\d{2})\b")
88
+
89
+ Run = Callable[..., subprocess.CompletedProcess[str]]
90
+ Opener = Callable[..., Any]
91
+
92
+
93
+ class Unavailable(Exception): # noqa: N818
94
+ """Spotify cannot play on this box - ``reason`` is a word for the page: ``no_key``, ``no_player``,
95
+ ``not_connected``, ``expired``, ``cannot_run``, ``missing``."""
96
+
97
+ def __init__(self, reason: str, message: str) -> None:
98
+ super().__init__(message)
99
+ self.reason = reason
100
+
101
+
102
+ class Busy(Exception): # noqa: N818
103
+ """Soloist must not run just now: "Connect" runs ``soloist --pair`` with the same data directory, or the box is
104
+ ending. A playback waits and tries again - it is no failure."""
105
+
106
+
107
+ class _Unpacked:
108
+ """The unpacked archive as tarfile reads it: at most ``program`` bytes while the program is copied
109
+ (:attr:`copying`), at most ``rest`` bytes of everything else. :raises ValueError: beyond."""
110
+
111
+ def __init__(self, stream: io.BufferedIOBase, program: int, rest: int) -> None:
112
+ self._stream = stream
113
+ self._left = {True: program, False: rest}
114
+ self.copying = False
115
+
116
+ def read(self, size: int = -1) -> bytes:
117
+ left = self._left[self.copying]
118
+ data = self._stream.read(left + 1 if size < 0 else min(size, left + 1))
119
+ self._left[self.copying] = left - len(data)
120
+ if len(data) > left:
121
+ raise ValueError("the program is too large." if self.copying else "the archive holds too much.")
122
+ return data
123
+
124
+
125
+ def _now() -> datetime.datetime:
126
+ return datetime.datetime.now(datetime.UTC)
127
+
128
+
129
+ def _iso(moment: datetime.datetime | None) -> str | None:
130
+ return moment.isoformat(timespec="seconds") if moment is not None else None
131
+
132
+
133
+ def _parse(text: object) -> datetime.datetime | None:
134
+ if not isinstance(text, str):
135
+ return None
136
+ try:
137
+ return datetime.datetime.fromisoformat(text)
138
+ except ValueError:
139
+ return None
140
+
141
+
142
+ def build_time(version: str) -> datetime.datetime | None:
143
+ """When the build was made, from the output of ``soloist --version`` (e.g. ``1.3.8.105 build 1790942580
144
+ (20261002) (g5c3a2053ac), linux/aarch64``) - ``None`` if it does not say."""
145
+ match = _BUILD.search(version)
146
+ if match:
147
+ return datetime.datetime.fromtimestamp(int(match.group(1)), datetime.UTC)
148
+ match = _DATE.search(version)
149
+ if match:
150
+ with contextlib.suppress(ValueError):
151
+ year, month, day = (int(part) for part in match.groups())
152
+ return datetime.datetime(year, month, day, tzinfo=datetime.UTC)
153
+ return None
154
+
155
+
156
+ @functools.cache
157
+ def _library(name: str) -> str | None:
158
+ """``ctypes.util.find_library`` runs ``ldconfig`` - once per run is enough."""
159
+ return ctypes.util.find_library(name)
160
+
161
+
162
+ def _write(path: str, data: bytes | IO[bytes], mode: int = 0o600) -> None:
163
+ """``data`` - bytes or a stream, copied in pieces - into ``path``: complete or not at all."""
164
+ temp = path + ".tmp"
165
+ try:
166
+ with open(os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode), "wb") as f:
167
+ if isinstance(data, bytes):
168
+ f.write(data)
169
+ else:
170
+ while chunk := data.read(CHUNK):
171
+ f.write(chunk)
172
+ f.flush()
173
+ os.fsync(f.fileno())
174
+ except BaseException:
175
+ with contextlib.suppress(OSError):
176
+ os.remove(temp)
177
+ raise
178
+ os.replace(temp, path)
179
+
180
+
181
+ class SpotifyAccount:
182
+ """Key, player and login of this box, kept in ``folder``.
183
+
184
+ ``device_name()``: the name the box has in the Spotify app. ``manage_avahi``: stop ``avahi-daemon`` while the box
185
+ waits to be chosen in the Spotify app, so that Soloist gets the port for its announcement (a box with hardware).
186
+ ``run`` and ``opener`` are ``subprocess.run`` and ``urllib.request.urlopen`` (tests replace them).
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ folder: str,
192
+ logger: logging.Logger,
193
+ device_name: Callable[[], str],
194
+ manage_avahi: bool = False,
195
+ machine: str | None = None,
196
+ run: Run = subprocess.run,
197
+ opener: Opener = urllib.request.urlopen,
198
+ popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen,
199
+ clock: Callable[[], datetime.datetime] = _now,
200
+ connect_seconds: float = CONNECT_SECONDS,
201
+ ) -> None:
202
+ self.folder = folder
203
+ self._logger = get_child(logger, type(self).__name__)
204
+ self._device_name = device_name
205
+ self._manage_avahi = manage_avahi
206
+ self._architecture = ARCHITECTURES.get((machine or platform.machine()).lower())
207
+ self._run = run
208
+ self._opener = opener
209
+ self._popen = popen
210
+ self._clock = clock
211
+ self._connect_seconds = connect_seconds
212
+ self._lock = threading.RLock()
213
+ self._fetching: threading.Thread | None = None
214
+ self._connecting: threading.Thread | None = None
215
+ self._pairing: subprocess.Popen[bytes] | None = None
216
+ self._stopped = threading.Event()
217
+ self._checker: threading.Thread | None = None
218
+ self.before_connect: Callable[[], None] = lambda: None
219
+ """Called before "Connect" and "Disconnect": Soloist must not run with the same data directory until
220
+ :attr:`after_connect` is called."""
221
+ self.after_connect: Callable[[], None] = lambda: None
222
+ """Called when "Connect" or "Disconnect" has ended (with any result): Soloist may run again."""
223
+ self.on_speaker: Callable[[bool], None] = lambda enabled: None
224
+ """Called when the switch of the speaker changed."""
225
+ self.on_connected: Callable[[], None] = lambda: None
226
+ """Called when the box was connected or disconnected."""
227
+
228
+ # --- files -------------------------------------------------------------------------------------------------------
229
+
230
+ def _path(self, *parts: str) -> str:
231
+ return os.path.join(self.folder, *parts)
232
+
233
+ @property
234
+ def program(self) -> str:
235
+ return self._path("bin", "soloist")
236
+
237
+ @property
238
+ def data_dir(self) -> str:
239
+ return self._path("data")
240
+
241
+ @property
242
+ def cache_dir(self) -> str:
243
+ return self._path("cache")
244
+
245
+ def _read_json(self, name: str) -> dict[str, Any]:
246
+ try:
247
+ with open(self._path(name), encoding="utf-8") as f:
248
+ data = json.load(f)
249
+ except (OSError, ValueError):
250
+ return {}
251
+ return data if isinstance(data, dict) else {}
252
+
253
+ def _update_json(self, name: str, **changes: Any) -> dict[str, Any]:
254
+ with self._lock:
255
+ data = {**self._read_json(name), **changes}
256
+ os.makedirs(self.folder, exist_ok=True)
257
+ _write(self._path(name), json.dumps(data, indent=2).encode(), 0o644)
258
+ return data
259
+
260
+ # --- key ---------------------------------------------------------------------------------------------------------
261
+
262
+ @property
263
+ def key(self) -> str | None:
264
+ try:
265
+ with open(self._path("key"), encoding="utf-8") as f:
266
+ return f.read().strip() or None
267
+ except OSError:
268
+ return None
269
+
270
+ def set_key(self, key: object) -> None:
271
+ """Store the Soloist API key; the box fetches the player then if it has none."""
272
+ if not isinstance(key, str) or not key.strip() or len(key.strip()) > MAX_KEY_LENGTH:
273
+ raise BadRequest("the key must not be empty.", code="spotify.key")
274
+ key = key.strip()
275
+ if any(c.isspace() or not c.isprintable() for c in key):
276
+ raise BadRequest("the key must not contain blanks.", code="spotify.key")
277
+ os.makedirs(self.folder, exist_ok=True)
278
+ _write(self._path("key"), key.encode())
279
+ self._logger.info("the Soloist API key was entered")
280
+ if not os.path.exists(self.program):
281
+ self._fetch_in_background(force=True)
282
+
283
+ # --- settings ----------------------------------------------------------------------------------------------------
284
+
285
+ @property
286
+ def speaker(self) -> bool:
287
+ return bool(self._read_json("settings.json").get("speaker"))
288
+
289
+ def set_speaker(self, enabled: object) -> None:
290
+ if not isinstance(enabled, bool):
291
+ raise BadRequest("enabled must be true or false.")
292
+ self._update_json("settings.json", speaker=enabled)
293
+ self._logger.info("Spotify speaker while no tag is on the box: %s", "on" if enabled else "off")
294
+ self.on_speaker(enabled)
295
+
296
+ @property
297
+ def connected(self) -> bool:
298
+ return _parse(self._read_json("settings.json").get("connected")) is not None
299
+
300
+ def lost(self) -> None:
301
+ """Soloist says that it has no login (any more): the box is not connected."""
302
+ if self.connected:
303
+ self._logger.warning("Spotify: the login of the box is gone - connect again")
304
+ self._update_json("settings.json", connected=None)
305
+ self.on_connected()
306
+
307
+ def info(self) -> dict[str, Any]:
308
+ settings = self._read_json("settings.json")
309
+ return {
310
+ "key": self.key is not None,
311
+ "connected": _parse(settings.get("connected")) is not None,
312
+ "connected_since": settings.get("connected"),
313
+ "connecting": self._connecting is not None,
314
+ "connect_result": settings.get("connect-result"),
315
+ "speaker": bool(settings.get("speaker")),
316
+ }
317
+
318
+ # --- connect -----------------------------------------------------------------------------------------------------
319
+
320
+ def connect(self) -> None:
321
+ """Wait up to :data:`CONNECT_SECONDS` to be chosen in the Spotify app (``soloist --pair``) - in the
322
+ background. The app connects the box to its account."""
323
+ key = self.key
324
+ if key is None:
325
+ raise Conflict("enter the key of Spotify first.", code="spotify.no_key")
326
+ self.usable()
327
+ with self._lock:
328
+ if self._connecting is not None:
329
+ raise Conflict("the box is waiting to be connected already.", code="spotify.connecting")
330
+ self._connecting = threading.Thread(target=self._connect, args=(key,), name="Spotify.connect", daemon=True)
331
+ self._update_json("settings.json", **{"connect-result": None})
332
+ self._connecting.start()
333
+
334
+ def _avahi(self, action: str) -> None:
335
+ if not self._manage_avahi:
336
+ return
337
+ try:
338
+ self._run(["sudo", "-n", "systemctl", action, *AVAHI], capture_output=True, text=True, timeout=30)
339
+ except (OSError, subprocess.SubprocessError) as e:
340
+ self._logger.warning("cannot %s avahi-daemon: %s", action, e)
341
+
342
+ def _connect(self, key: str) -> None:
343
+ """``soloist --pair`` - no other Soloist runs meanwhile (:attr:`before_connect`, :attr:`after_connect`)."""
344
+ result = "failed"
345
+ try:
346
+ self.before_connect()
347
+ self._avahi("stop")
348
+ command = [self.program, "--pair", "-n", self._device_name(), "-k", key, "-D", self.data_dir, "-C",
349
+ self.cache_dir] # fmt: skip
350
+ os.makedirs(self.data_dir, exist_ok=True)
351
+ self._logger.info("Spotify: waiting %.0f s to be chosen in the Spotify app", self._connect_seconds)
352
+ process = self._popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
353
+ self._pairing = process
354
+ try:
355
+ _, error = process.communicate(timeout=self._connect_seconds)
356
+ except subprocess.TimeoutExpired:
357
+ self._end_pairing(process)
358
+ result = "timeout"
359
+ else:
360
+ if process.returncode == 0:
361
+ result = "connected"
362
+ elif self._stopped.is_set():
363
+ result = "stopped"
364
+ else:
365
+ self._logger.warning(
366
+ "Spotify: connecting failed (%s): %s", process.returncode, error.decode(errors="replace")[-300:]
367
+ )
368
+ if process.returncode == EXPIRED:
369
+ self._fetch_in_background(force=True)
370
+ except OSError as e:
371
+ self._logger.error("Spotify: cannot start the player to connect: %s", e)
372
+ finally:
373
+ try:
374
+ self._pairing = None
375
+ self._avahi("start")
376
+ changes: dict[str, Any] = {"connect-result": result}
377
+ if result == "connected":
378
+ changes["connected"] = _iso(self._clock())
379
+ self._logger.info("Spotify: the box is connected to an account")
380
+ self._update_json("settings.json", **changes)
381
+ finally:
382
+ with self._lock:
383
+ self._connecting = None
384
+ self.after_connect()
385
+ if result == "connected":
386
+ self.on_connected()
387
+
388
+ def _end_pairing(self, process: subprocess.Popen[bytes]) -> None:
389
+ """Nobody chose the box in time: end ``soloist --pair`` - every wait is bounded."""
390
+ for end in (process.terminate, process.kill):
391
+ end()
392
+ try:
393
+ process.communicate(timeout=5)
394
+ except subprocess.TimeoutExpired:
395
+ continue
396
+ return
397
+ self._logger.error("Spotify: soloist --pair does not end (pid %s)", process.pid)
398
+
399
+ def disconnect(self) -> None:
400
+ """Remove the login from the box."""
401
+ with self._lock:
402
+ if self._connecting is not None:
403
+ raise Conflict("the box is waiting to be connected.", code="spotify.connecting")
404
+ self.before_connect()
405
+ try:
406
+ shutil.rmtree(self.data_dir, ignore_errors=True)
407
+ self._update_json("settings.json", connected=None, **{"connect-result": None})
408
+ finally:
409
+ self.after_connect()
410
+ self._logger.info("Spotify: the box is disconnected from its account")
411
+ self.on_connected()
412
+
413
+ # --- the player --------------------------------------------------------------------------------------------------
414
+
415
+ def missing(self) -> list[str]:
416
+ """Packages of the system that Soloist and the sound need and that are not there."""
417
+ result = []
418
+ if _library("atomic") is None:
419
+ result.append("libatomic1")
420
+ for program, package in (("pipewire", "pipewire"), ("wireplumber", "wireplumber")):
421
+ if shutil.which(program) is None:
422
+ result.append(package)
423
+ return result
424
+
425
+ def _version(self, program: str) -> tuple[str | None, str | None]:
426
+ """(output of ``--version``, error)"""
427
+ try:
428
+ done = self._run([program, "--version"], capture_output=True, text=True, timeout=20)
429
+ except (OSError, subprocess.SubprocessError) as e:
430
+ return None, str(e)
431
+ if done.returncode == 0:
432
+ return done.stdout.strip() or done.stderr.strip(), None
433
+ if done.returncode == EXPIRED:
434
+ return None, "expired"
435
+ said = (done.stderr or done.stdout or "").strip()
436
+ if "libatomic" in said:
437
+ return None, "libatomic1 is missing"
438
+ return None, said[-300:] or f"exit code {done.returncode}"
439
+
440
+ def _days_left(self, state: dict[str, Any]) -> float | None:
441
+ built = _parse(state.get("built"))
442
+ if built is None:
443
+ return None
444
+ return LIFETIME_DAYS - (self._clock() - built).total_seconds() / 86400
445
+
446
+ def player_info(self) -> dict[str, Any]:
447
+ """What the card "Player" shows."""
448
+ state = self._read_json("state.json")
449
+ built = _parse(state.get("built"))
450
+ days_left = self._days_left(state)
451
+ if self._fetching is not None:
452
+ status = "fetching"
453
+ elif not os.path.exists(self.program):
454
+ status = "none"
455
+ elif state.get("error"):
456
+ status = "cannot_run"
457
+ elif days_left is not None and days_left <= 0:
458
+ status = "expired"
459
+ else:
460
+ status = "ready"
461
+ return {
462
+ "state": status,
463
+ "error": state.get("error"),
464
+ "version": state.get("version"),
465
+ "built": _iso(built),
466
+ "last_day": _iso(built + datetime.timedelta(days=LIFETIME_DAYS)) if built else None,
467
+ "renew_from": _iso(built + datetime.timedelta(days=LIFETIME_DAYS - RENEW_DAYS)) if built else None,
468
+ "asked": state.get("asked"),
469
+ "result": state.get("result"),
470
+ "missing": self.missing(),
471
+ }
472
+
473
+ def usable(self) -> str:
474
+ """The program of Soloist, if it can be used. :raises Unavailable: otherwise."""
475
+ if self.key is None:
476
+ raise Unavailable("no_key", "the key of Spotify has not been entered.")
477
+ if not os.path.exists(self.program):
478
+ raise Unavailable("no_player", "the player of Spotify has not been fetched yet.")
479
+ state = self._read_json("state.json")
480
+ if state.get("error"):
481
+ raise Unavailable("cannot_run", f"the player of Spotify does not start: {state['error']}")
482
+ days_left = self._days_left(state)
483
+ if days_left is not None and days_left <= 0:
484
+ raise Unavailable("expired", "the player of Spotify has expired.")
485
+ return self.program
486
+
487
+ def update_now(self) -> None:
488
+ """Ask Spotify at once whether there is a new build, whatever the age of the one the box has."""
489
+ if self.key is None:
490
+ raise Conflict("enter the key of Spotify first.", code="spotify.no_key")
491
+ if not self._fetch_in_background(force=True):
492
+ raise Conflict("the player is being fetched already.", code="spotify.fetching")
493
+
494
+ def expired(self) -> None:
495
+ """Soloist ended with exit code 10: its build has expired - a new one is fetched."""
496
+ self._logger.warning("Spotify: the build of the player has expired - fetching a new one")
497
+ self._update_json("state.json", error=None, built=_iso(self._clock() - datetime.timedelta(days=LIFETIME_DAYS)))
498
+ self._fetch_in_background(force=True)
499
+
500
+ def wait_until_fetched(self, timeout: float | None = None) -> None:
501
+ thread = self._fetching
502
+ if thread is not None:
503
+ thread.join(timeout)
504
+
505
+ def wait_until_connected(self, timeout: float | None = None) -> None:
506
+ thread = self._connecting
507
+ if thread is not None:
508
+ thread.join(timeout)
509
+
510
+ def _fetch_in_background(self, force: bool) -> bool:
511
+ with self._lock:
512
+ if self._fetching is not None:
513
+ return False
514
+ self._fetching = threading.Thread(target=self._fetch, args=(force,), name="Spotify.fetch", daemon=True)
515
+ self._fetching.start()
516
+ return True
517
+
518
+ def check(self) -> None:
519
+ """The daily look: with a key, and no build or one with less than :data:`RENEW_DAYS` days left, ask
520
+ Spotify."""
521
+ if self.key is None:
522
+ return
523
+ days_left = self._days_left(self._read_json("state.json"))
524
+ if not os.path.exists(self.program) or days_left is None or days_left < RENEW_DAYS:
525
+ self._fetch_in_background(force=False)
526
+
527
+ def _fetch(self, force: bool) -> None:
528
+ try:
529
+ result, error = self._ask(force)
530
+ except Exception as e: # a failed fetch must not end the thread silently
531
+ self._logger.exception("Spotify: fetching the player failed")
532
+ result, error = "failed", str(e)
533
+ changes: dict[str, Any] = {"asked": _iso(self._clock()), "result": result}
534
+ if error is not None:
535
+ changes["fetch-error"] = error
536
+ self._update_json("state.json", **changes)
537
+ with self._lock:
538
+ self._fetching = None
539
+
540
+ def _ask(self, force: bool) -> tuple[str, str | None]: # noqa: PLR0911 - one return per outcome
541
+ """(result, error) of one question to Spotify: ``unchanged``, ``new``, ``failed``, ``broken`` (the new build
542
+ does not start; the box keeps the one it has)."""
543
+ if self._architecture is None:
544
+ return "failed", f"there is no build of Soloist for the processor '{platform.machine()}'."
545
+ state = self._read_json("state.json")
546
+ headers = {}
547
+ if os.path.exists(self.program) and state.get("last-modified") and not state.get("error"):
548
+ headers["If-Modified-Since"] = state["last-modified"]
549
+ request = urllib.request.Request(BUILDS.format(self._architecture), headers=headers) # noqa: S310 - https
550
+ try:
551
+ with self._opener(request, timeout=60) as response:
552
+ data = response.read(MAX_ARCHIVE + 1)
553
+ last_modified = response.headers.get("Last-Modified")
554
+ etag = (response.headers.get("ETag") or "").strip('"')
555
+ except urllib.error.HTTPError as e:
556
+ if e.code == 304:
557
+ self._logger.info("Spotify: the box has the current build of the player")
558
+ return "unchanged", None
559
+ return "failed", f"HTTP {e.code}"
560
+ except OSError as e:
561
+ self._logger.info("Spotify: cannot ask for a new build of the player: %s", e)
562
+ return "failed", str(e)
563
+ if len(data) > MAX_ARCHIVE:
564
+ return "failed", "the archive is too large."
565
+ if re.fullmatch(r"[0-9a-f]{32}", etag) and hashlib.md5(data).hexdigest() != etag: # noqa: S324 - a checksum
566
+ return "failed", "the archive does not match its checksum."
567
+ try:
568
+ program = self._unpack(data)
569
+ except (OSError, EOFError, zlib.error, tarfile.TarError, ValueError) as e:
570
+ self._logger.error("Spotify: cannot unpack the new build of the player: %s", e)
571
+ return "failed", f"cannot unpack the archive: {e}"
572
+ version, error = self._version(program)
573
+ if version is None:
574
+ os.remove(program)
575
+ self._logger.error("Spotify: the new build of the player does not start: %s", error)
576
+ if not os.path.exists(self.program):
577
+ self._update_json("state.json", error=error)
578
+ return "broken", error
579
+ if os.path.exists(self.program):
580
+ os.replace(self.program, self.program + ".previous")
581
+ os.replace(program, self.program)
582
+ built = build_time(version) or self._clock()
583
+ self._update_json(
584
+ "state.json", version=version, built=_iso(built), error=None, **{"last-modified": last_modified}
585
+ )
586
+ self._logger.info("Spotify: new build of the player: %s", version)
587
+ return "new", None
588
+
589
+ def _unpack(self, data: bytes) -> str:
590
+ """The program ``soloist`` of the archive, as ``bin/soloist.new`` - unpacked and written in pieces.
591
+ :raises ValueError: if it holds none, or more than :data:`MAX_PROGRAM`, :data:`MAX_REST`, :data:`MAX_FILES`."""
592
+ os.makedirs(self._path("bin"), exist_ok=True)
593
+ target = self.program + ".new"
594
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as unpacked:
595
+ stream = _Unpacked(unpacked, MAX_PROGRAM, MAX_REST)
596
+ # read as a stream: tarfile keeps the headers, never the content of a file
597
+ with tarfile.open(fileobj=cast(IO[bytes], stream), mode="r|") as archive:
598
+ for count, member in enumerate(archive):
599
+ if count >= MAX_FILES:
600
+ raise ValueError("the archive holds too many files.")
601
+ if os.path.basename(member.name) == "soloist" and member.isfile():
602
+ source = archive.extractfile(member)
603
+ if source is None:
604
+ break
605
+ stream.copying = True
606
+ _write(target, source, 0o700)
607
+ return target
608
+ raise ValueError("the archive holds no program 'soloist'.")
609
+
610
+ def back_to_previous(self) -> bool:
611
+ """The build that was fetched last does not run: use the one before. Returns whether there was one."""
612
+ previous = self.program + ".previous"
613
+ if not os.path.exists(previous):
614
+ return False
615
+ version, _ = self._version(previous)
616
+ if version is None:
617
+ return False
618
+ os.replace(previous, self.program)
619
+ built = build_time(version) or self._clock()
620
+ self._update_json("state.json", version=version, built=_iso(built), error=None)
621
+ self._logger.warning("Spotify: back to the build before: %s", version)
622
+ return True
623
+
624
+ # --- life cycle --------------------------------------------------------------------------------------------------
625
+
626
+ def start(self, first_check: float = 60.0) -> None:
627
+ """The daily look at the age of the build - the first one ``first_check`` seconds after the start."""
628
+ self._stopped.clear()
629
+ self._checker = threading.Thread(target=self._check_daily, args=(first_check,), name="Spotify.check")
630
+ self._checker.daemon = True
631
+ self._checker.start()
632
+
633
+ def _check_daily(self, first: float) -> None:
634
+ wait = first
635
+ while not self._stopped.wait(wait):
636
+ self.check()
637
+ wait = CHECK_EVERY
638
+
639
+ def stop(self) -> None:
640
+ self._stopped.set()
641
+ pairing = self._pairing
642
+ if pairing is not None and pairing.poll() is None:
643
+ pairing.terminate()
644
+ for thread in (self._checker, self._connecting, self._fetching):
645
+ if thread is not None and thread is not threading.current_thread():
646
+ thread.join(10)
647
+ self._checker = None