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