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,1115 @@
|
|
|
1
|
+
"""The library: catalog, albums, covers of albums and stations, id table, journal and the archive of box versions; a web
|
|
2
|
+
page for managing them, and the interface for the boxes (docs/developer/library.md, section 3).
|
|
3
|
+
|
|
4
|
+
tinyjukebox-library -c library.yaml
|
|
5
|
+
|
|
6
|
+
A server and a box are different things, so this is a program of its own. It uses what both need: the data of
|
|
7
|
+
:mod:`tinyjukebox.data`, the operations of :class:`~tinyjukebox.control.CatalogControl`, the web server with its page
|
|
8
|
+
and its users. Drivers, state machines and player are left out.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import queue
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import segno
|
|
28
|
+
|
|
29
|
+
from tinyjukebox import __version__
|
|
30
|
+
from tinyjukebox.backup import Backup, Folder
|
|
31
|
+
from tinyjukebox.backups import Backups
|
|
32
|
+
from tinyjukebox.control import CatalogControl
|
|
33
|
+
from tinyjukebox.core.config import read_config, validate_config
|
|
34
|
+
from tinyjukebox.core.logging import add_stdout_handler, create_logger
|
|
35
|
+
from tinyjukebox.data.activities import Activities
|
|
36
|
+
from tinyjukebox.data.tags import Tags
|
|
37
|
+
from tinyjukebox.data.targets import Targets
|
|
38
|
+
from tinyjukebox.data_folder import first_admin, library_config
|
|
39
|
+
from tinyjukebox.errors import (
|
|
40
|
+
ApiError,
|
|
41
|
+
BadRequest,
|
|
42
|
+
Forbidden,
|
|
43
|
+
Gone,
|
|
44
|
+
NotFound,
|
|
45
|
+
ServiceUnavailable,
|
|
46
|
+
Unauthorized,
|
|
47
|
+
Unprocessable,
|
|
48
|
+
)
|
|
49
|
+
from tinyjukebox.images import Images
|
|
50
|
+
from tinyjukebox.library.diagnostics import DAYS, FILTERS, PER_BOX, Diagnostics
|
|
51
|
+
from tinyjukebox.library.releases import Releases, app_name, installer_name
|
|
52
|
+
from tinyjukebox.library.schema import get_schema
|
|
53
|
+
from tinyjukebox.library.statistics import statistics
|
|
54
|
+
from tinyjukebox.library.store import APP, BOX, CATALOG, Invalid, Store, Unknown, knows_album_covers
|
|
55
|
+
from tinyjukebox.restore import Place, Restore, missing_albums
|
|
56
|
+
from tinyjukebox.sync import INTERFACE, actor, handshake
|
|
57
|
+
from tinyjukebox.sync.client import KEEP_ALIVE, Unreachable
|
|
58
|
+
from tinyjukebox.sync.util import public_key_pin, read_json, version_key, write_json
|
|
59
|
+
from tinyjukebox.web.auth import has_role
|
|
60
|
+
from tinyjukebox.web.logbuffer import LogBuffer
|
|
61
|
+
from tinyjukebox.web.server import GUARDED, NO_CONTENT, Request, Response, RestApi, Route, Writable
|
|
62
|
+
|
|
63
|
+
__all__ = ["BOX_ID", "Hub", "Library", "LibraryControl", "main"]
|
|
64
|
+
|
|
65
|
+
BOX_ID = re.compile(r"[a-z][a-z0-9]{1,15}")
|
|
66
|
+
READER_UID = re.compile(r"[0-9A-F]{2}(:[0-9A-F]{2}){3}((:[0-9A-F]{2}){3}){0,2}")
|
|
67
|
+
"""uid of a tag as ``rfid-reader`` reports it: 4, 7 or 10 bytes."""
|
|
68
|
+
HANDSHAKE_SECONDS = 30
|
|
69
|
+
MAX_HANDSHAKES = 1000 # first steps of handshakes kept at once - who sends more forgets the oldest of them
|
|
70
|
+
SPOTIFY_CLIENT = re.compile(r"[0-9a-f]{32}") # the client id of an app at Spotify
|
|
71
|
+
PAIRING_SECONDS = 600 # how long the one-time password for a phone is valid (docs/developer/app.md)
|
|
72
|
+
MAX_CHANGES_BODY = 4 * 2**20
|
|
73
|
+
FILLING = "first filling" # who sends the files of the first filling, for the log
|
|
74
|
+
# what never changes, whatever the version of the interface (section 4.10)
|
|
75
|
+
UNVERSIONED = re.compile(r"/api/sync/(boxes|interface|startup|versions(/[^/]+(/signature)?)?)")
|
|
76
|
+
V = f"/api/sync/v{INTERFACE}"
|
|
77
|
+
# what the app on a phone is refused (docs/developer/app.md): sending files of albums and covers, the versions of the
|
|
78
|
+
# boxes
|
|
79
|
+
NOT_FOR_APPS = re.compile(rf"PUT {V}/(albums/[^/]+/files|images)/.+|GET /api/sync/versions/.+")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Hub:
|
|
83
|
+
"""Passes events on to the boxes that keep a connection open."""
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
self._queues: list[queue.Queue[str]] = []
|
|
88
|
+
|
|
89
|
+
def publish(self, event: str) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
for subscriber in self._queues:
|
|
92
|
+
subscriber.put(event)
|
|
93
|
+
|
|
94
|
+
def subscribe(self) -> Iterator[str | None]:
|
|
95
|
+
"""The events for one box; None when nothing happened for a while."""
|
|
96
|
+
subscriber: queue.Queue[str] = queue.Queue()
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._queues.append(subscriber)
|
|
99
|
+
try:
|
|
100
|
+
# the first event tells a box that it will hear of every change from now on; what changed before, it asks
|
|
101
|
+
yield "connected"
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
yield subscriber.get(timeout=KEEP_ALIVE)
|
|
105
|
+
except queue.Empty:
|
|
106
|
+
yield None
|
|
107
|
+
finally:
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._queues.remove(subscriber)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LibraryControl(CatalogControl):
|
|
113
|
+
"""What the page of the library does with the catalog. There is no tag on a library and nothing plays."""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
config: Mapping[str, Any],
|
|
118
|
+
tags: Tags,
|
|
119
|
+
targets: Targets,
|
|
120
|
+
activities: Activities,
|
|
121
|
+
logger: logging.Logger,
|
|
122
|
+
images: Images,
|
|
123
|
+
store: Store,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__(config, tags, targets, activities, logger, images)
|
|
126
|
+
self._store = store
|
|
127
|
+
|
|
128
|
+
def _album_changed(self, album_id: str) -> None:
|
|
129
|
+
self._store.album_changed(album_id, actor())
|
|
130
|
+
|
|
131
|
+
def backup(self, albums: bool = False) -> Backup:
|
|
132
|
+
"""The data folder (:meth:`Store.backup_data`), the covers of albums and stations and, with ``albums``, the
|
|
133
|
+
albums."""
|
|
134
|
+
folders = [Folder("images", self._store.images_folder)]
|
|
135
|
+
if albums:
|
|
136
|
+
folders.append(Folder("albums", self._store.albums_folder))
|
|
137
|
+
return Backup("library", albums, self._store.backup_data(), folders)
|
|
138
|
+
|
|
139
|
+
def _described(self, album_id: str) -> dict[str, Any]:
|
|
140
|
+
album: dict[str, Any] = (self._store.entry("target", album_id) or {}).get("album", {})
|
|
141
|
+
return album
|
|
142
|
+
|
|
143
|
+
def list_albums(self, unmapped_only: bool = False) -> list[dict[str, Any]]:
|
|
144
|
+
"""With ``broken``: the number of files of the album that the library or a box found broken."""
|
|
145
|
+
broken = self._store.broken()
|
|
146
|
+
return [dict(album, broken=len(broken.get(album["id"], {}))) for album in super().list_albums(unmapped_only)]
|
|
147
|
+
|
|
148
|
+
def get_album(self, album_id: object) -> dict[str, Any]:
|
|
149
|
+
"""With ``broken``: the files of the album that the library or a box found broken, in the order of their
|
|
150
|
+
names - ``library`` is what the library found itself (None if nothing), ``boxes`` what boxes reported."""
|
|
151
|
+
result = super().get_album(album_id)
|
|
152
|
+
names = {box["id"]: self._store.box_name(box) for box in self._store.boxes()}
|
|
153
|
+
result["broken"] = [
|
|
154
|
+
{
|
|
155
|
+
"file": name,
|
|
156
|
+
"library": found.get("library"),
|
|
157
|
+
"boxes": [
|
|
158
|
+
{"box": box, "name": names.get(box, box), **report}
|
|
159
|
+
for box, report in sorted(found["boxes"].items())
|
|
160
|
+
],
|
|
161
|
+
}
|
|
162
|
+
for name, found in sorted(self._store.broken(result["id"]).get(result["id"], {}).items())
|
|
163
|
+
]
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def forget_broken_reports(self, album_id: str) -> dict[str, Any]:
|
|
167
|
+
"""Drop what boxes reported about the files of the album; returns the album."""
|
|
168
|
+
self._album(album_id)
|
|
169
|
+
self._store.forget_reports(album_id)
|
|
170
|
+
return self.get_album(album_id)
|
|
171
|
+
|
|
172
|
+
def scan_tag(self, uid: str) -> tuple[dict[str, Any], bool]:
|
|
173
|
+
"""A phone read this tag (docs/developer/library.md, section 5.1): an unknown one is added, not linked - as a
|
|
174
|
+
box adds the tag of an unknown tag. Returns the tag and whether it was added. Nothing is kept about the
|
|
175
|
+
scan itself."""
|
|
176
|
+
if not READER_UID.fullmatch(uid):
|
|
177
|
+
raise BadRequest(
|
|
178
|
+
"invalid uid - 4, 7 or 10 bytes as the reader reports them, e.g. 04:1A:2B:3C:4D:5E:6F.",
|
|
179
|
+
code="scan.invalid_uid",
|
|
180
|
+
)
|
|
181
|
+
with self._lock:
|
|
182
|
+
added = uid not in self._tags.tags
|
|
183
|
+
if added:
|
|
184
|
+
self._tags.add_tag(uid)
|
|
185
|
+
self._logger.info("new tag '%s' read by a phone - adding it to tags as unmapped tag.", uid)
|
|
186
|
+
return self._tag_dict(self._tags.get_tag(uid)), added
|
|
187
|
+
|
|
188
|
+
def status(self) -> dict[str, Any]:
|
|
189
|
+
boxes = [box for box in self._store.boxes() if not box.get("removed")]
|
|
190
|
+
return {
|
|
191
|
+
"state": "library",
|
|
192
|
+
"tag": None,
|
|
193
|
+
"album": None,
|
|
194
|
+
"radio": None,
|
|
195
|
+
"spotify": None,
|
|
196
|
+
"library": {"version": __version__, "interface": INTERFACE, "boxes": len(boxes)},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Library:
|
|
201
|
+
def __init__(self, config: Mapping[str, Any], logger: logging.Logger) -> None:
|
|
202
|
+
"""``config``: the section ``library`` of the config file."""
|
|
203
|
+
self.logger = logger
|
|
204
|
+
self._config = config
|
|
205
|
+
self.data = os.path.expanduser(config["data-folder"])
|
|
206
|
+
albums = os.path.expanduser(config["albums-folder"])
|
|
207
|
+
images_folder = os.path.expanduser(config["images-folder"])
|
|
208
|
+
json_folder = os.path.expanduser(config.get("json-folder") or os.path.join(self.data, "json"))
|
|
209
|
+
# a backup that is put back (tinyjukebox.restore): a swap that a power cut interrupted is finished first
|
|
210
|
+
self.restore = self._create_restore(albums, images_folder)
|
|
211
|
+
self.restore.finish()
|
|
212
|
+
os.makedirs(json_folder, exist_ok=True)
|
|
213
|
+
self.hub = Hub()
|
|
214
|
+
self.store = Store(self.data, albums, images_folder, logger, self.hub.publish)
|
|
215
|
+
kept = config.get("diagnostics") or {} # what the boxes deliver of their logs (section 4.22)
|
|
216
|
+
self.diagnostics = Diagnostics(
|
|
217
|
+
os.path.join(self.data, "diagnostics.sqlite"), int(kept.get("days", DAYS)),
|
|
218
|
+
int(kept.get("records-per-box", PER_BOX)),
|
|
219
|
+
) # fmt: skip
|
|
220
|
+
self.settings: dict[str, Any] = read_json(os.path.join(self.data, "settings.json"), {})
|
|
221
|
+
self._take_bootstrap(config.get("bootstrap-password"))
|
|
222
|
+
|
|
223
|
+
# what the library works with is generated from the catalog, as on a box (section 4.2)
|
|
224
|
+
files = {kind: os.path.join(json_folder, name) for kind, name in CATALOG.items()}
|
|
225
|
+
for kind in ("tag", "target"):
|
|
226
|
+
write_json(files[kind], self.store.working(kind))
|
|
227
|
+
self.tags = Tags(files["tag"], logger)
|
|
228
|
+
self.targets = Targets(files["target"], albums, logger)
|
|
229
|
+
write_json(os.path.join(json_folder, "activity.json"), {}) # positions belong to the boxes
|
|
230
|
+
self.activities = Activities(os.path.join(json_folder, "activity.json"), self.tags, logger)
|
|
231
|
+
self.images = Images(images_folder)
|
|
232
|
+
self.targets.id_source = self.store.new_target_id
|
|
233
|
+
self.tags.listen(lambda key, value: self.store.set("tag", key, value, actor()))
|
|
234
|
+
self.targets.listen(lambda key, value: self.store.set("target", key, value, actor()))
|
|
235
|
+
self.images.listen(self.store.scan_images)
|
|
236
|
+
# a change that a box made is put into the working data by a thread of its own: the one that tells of it
|
|
237
|
+
# may hold a lock of the working data
|
|
238
|
+
self._changed = threading.Event()
|
|
239
|
+
self._stopping = threading.Event()
|
|
240
|
+
self._worker: threading.Thread | None = None
|
|
241
|
+
self.store.changed = self._changed.set
|
|
242
|
+
|
|
243
|
+
self.control = LibraryControl(
|
|
244
|
+
{"playlist-filename": config.get("playlist-filename", "playlist.m3u")},
|
|
245
|
+
self.tags,
|
|
246
|
+
self.targets,
|
|
247
|
+
self.activities,
|
|
248
|
+
logger,
|
|
249
|
+
self.images,
|
|
250
|
+
self.store,
|
|
251
|
+
)
|
|
252
|
+
self.releases = Releases(
|
|
253
|
+
self.store, self._releases_config(), str(config.get("platform", "")), self.data, logger
|
|
254
|
+
)
|
|
255
|
+
# beside the albums by default: on the NAS that is the shared folder, where the backup of the NAS finds them
|
|
256
|
+
backups = config.get("backups-folder") or os.path.join(os.path.dirname(os.path.abspath(albums)), "backups")
|
|
257
|
+
self.backups = Backups(
|
|
258
|
+
os.path.expanduser(backups), os.path.join(self.data, "backups.json"), self.control.backup, logger
|
|
259
|
+
)
|
|
260
|
+
self.control.backups = self.backups # the page sets the schedules: /api/library/backups (RestApi)
|
|
261
|
+
self.control.restore = self.restore
|
|
262
|
+
self._handshakes: dict[str, tuple[str, str, float]] = {}
|
|
263
|
+
self._pairing: tuple[str, float] | None = None # the one-time password for a phone, and until when it is valid
|
|
264
|
+
self._lock = threading.Lock()
|
|
265
|
+
self._log_buffer = LogBuffer()
|
|
266
|
+
logger.addHandler(self._log_buffer)
|
|
267
|
+
self.api = RestApi(
|
|
268
|
+
config.get("rest-api", {}),
|
|
269
|
+
self.control,
|
|
270
|
+
self.targets,
|
|
271
|
+
logger,
|
|
272
|
+
self._log_buffer,
|
|
273
|
+
routes=[*self._sync_routes(), *self._page_routes()],
|
|
274
|
+
guards={"/api/sync/": self._guard},
|
|
275
|
+
mode="library",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _create_restore(self, albums: str, images_folder: str) -> Restore:
|
|
279
|
+
"""The data folder, the images and the albums. What a restore does not replace stays: the settings with the
|
|
280
|
+
bootstrap password and the tokens, and the schedules and folders of the backups. Its state is kept beside
|
|
281
|
+
the data folder."""
|
|
282
|
+
places = [
|
|
283
|
+
Place("data", self.data, keep=("settings.json", "backups.json")),
|
|
284
|
+
Place("images", images_folder),
|
|
285
|
+
Place("albums", albums),
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
def missing() -> list[dict[str, str]]:
|
|
289
|
+
catalog = read_json(os.path.join(self.data, "catalog", CATALOG["target"]), {}).get("entries", {})
|
|
290
|
+
return missing_albums({key: entry["value"] for key, entry in catalog.items() if entry["value"]}, albums)
|
|
291
|
+
|
|
292
|
+
state = os.path.dirname(os.path.abspath(self.data))
|
|
293
|
+
return Restore("library", places, state, self.logger, missing=missing)
|
|
294
|
+
|
|
295
|
+
def _take_bootstrap(self, configured: str | None) -> None:
|
|
296
|
+
"""The bootstrap password of the config counts at the first start, and again whenever the config names another
|
|
297
|
+
one than at the start before - the dialog of a new installation over the data of the old one, a changed secret
|
|
298
|
+
of Docker. One set on the page stays until then (docs/developer/library.md, section 4.14)."""
|
|
299
|
+
if not configured:
|
|
300
|
+
return
|
|
301
|
+
mark = handshake.hash_secret(configured) # what the library remembers of the password of the config
|
|
302
|
+
known = self.settings.get("bootstrap-config")
|
|
303
|
+
if known == mark:
|
|
304
|
+
return
|
|
305
|
+
# a library of before 0.45 remembers no password of the config: the one it has may have been set on the page
|
|
306
|
+
if not self.settings.get("bootstrap") or known is not None:
|
|
307
|
+
if self.settings.get("bootstrap"):
|
|
308
|
+
self.logger.info("the config names another bootstrap password than before - it replaces the one set")
|
|
309
|
+
self.settings["bootstrap"] = configured
|
|
310
|
+
self.settings["bootstrap-config"] = mark
|
|
311
|
+
self._save_settings()
|
|
312
|
+
|
|
313
|
+
def _save_settings(self) -> None:
|
|
314
|
+
filename = os.path.join(self.data, "settings.json")
|
|
315
|
+
write_json(filename, self.settings)
|
|
316
|
+
os.chmod(filename, 0o600)
|
|
317
|
+
|
|
318
|
+
def _releases_config(self) -> dict[str, Any]:
|
|
319
|
+
"""What is set on the page comes before the config: the token before the one of the token file, and the
|
|
320
|
+
number of versions the archive keeps."""
|
|
321
|
+
config = dict(self._config.get("releases", {}))
|
|
322
|
+
if self.settings.get("keep-versions"):
|
|
323
|
+
config["keep-versions"] = self.settings["keep-versions"]
|
|
324
|
+
token_file = config.pop("token-file", None)
|
|
325
|
+
self._access = "none" # where the token comes from: "page", "file", "missing" or "none" (asks without)
|
|
326
|
+
if self.settings.get("releases-token"):
|
|
327
|
+
config["token"] = self.settings["releases-token"]
|
|
328
|
+
self._access = "page"
|
|
329
|
+
elif token_file:
|
|
330
|
+
try:
|
|
331
|
+
with open(os.path.expanduser(token_file), encoding="utf-8") as infile:
|
|
332
|
+
config["token"] = infile.read().strip()
|
|
333
|
+
self._access = "file"
|
|
334
|
+
except FileNotFoundError:
|
|
335
|
+
# without its token the library does not ask the git server
|
|
336
|
+
self.logger.warning(
|
|
337
|
+
"the token for the releases is missing (not set on the page, no file %s): "
|
|
338
|
+
"no new versions are fetched",
|
|
339
|
+
token_file,
|
|
340
|
+
)
|
|
341
|
+
config["token"] = None
|
|
342
|
+
self._access = "missing"
|
|
343
|
+
return config
|
|
344
|
+
|
|
345
|
+
# --- start and stop ----------------------------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def _own_slot(self) -> None:
|
|
348
|
+
"""The package brings the slot of its own version (section 6.13)."""
|
|
349
|
+
folder = os.path.expanduser(self._config.get("slots-folder") or "")
|
|
350
|
+
if not folder or not os.path.isdir(folder):
|
|
351
|
+
return
|
|
352
|
+
for name in sorted(os.listdir(folder)):
|
|
353
|
+
if not name.endswith(".json"):
|
|
354
|
+
continue
|
|
355
|
+
description = read_json(os.path.join(folder, name))
|
|
356
|
+
# also a version whose files are gone, e.g. after the data was restored from a backup
|
|
357
|
+
if not self.store.versions().get(description["version"], {}).get("files"):
|
|
358
|
+
base = os.path.join(folder, name.removesuffix(".json"))
|
|
359
|
+
files = {"description": f"{base}.json", "archive": f"{base}.tar.gz", "signature": f"{base}.tar.gz.sig"}
|
|
360
|
+
installer = os.path.join(folder, installer_name(description["version"]))
|
|
361
|
+
if os.path.isfile(installer): # the installer of a box of the same version (section 3.2)
|
|
362
|
+
files["installer"] = installer
|
|
363
|
+
app = os.path.join(folder, app_name(description["version"]))
|
|
364
|
+
if os.path.isfile(app): # and the app for phones
|
|
365
|
+
files["app"] = app
|
|
366
|
+
self.store.add_version(description, files, "package")
|
|
367
|
+
|
|
368
|
+
def start(self) -> None:
|
|
369
|
+
self._stopping.clear()
|
|
370
|
+
self._own_slot()
|
|
371
|
+
self._worker = threading.Thread(target=self._apply_changes, name="Library.working", daemon=True)
|
|
372
|
+
self._worker.start()
|
|
373
|
+
self.api.start()
|
|
374
|
+
self.releases.prune() # the limit may be new: set in the config, or the default of this version
|
|
375
|
+
self.releases.start()
|
|
376
|
+
self.backups.start()
|
|
377
|
+
self.restore.clean() # the data of before a restore, now that the restored data runs
|
|
378
|
+
self.logger.info("library %s serves the interface version %d", __version__, INTERFACE)
|
|
379
|
+
|
|
380
|
+
def stop(self) -> None:
|
|
381
|
+
self._stopping.set()
|
|
382
|
+
self._changed.set()
|
|
383
|
+
self.backups.stop()
|
|
384
|
+
self.api.stop()
|
|
385
|
+
if self._worker is not None:
|
|
386
|
+
self._worker.join()
|
|
387
|
+
self._worker = None
|
|
388
|
+
self.logger.removeHandler(self._log_buffer)
|
|
389
|
+
self.store.close()
|
|
390
|
+
self.diagnostics.close()
|
|
391
|
+
|
|
392
|
+
def run(self) -> None:
|
|
393
|
+
self.start()
|
|
394
|
+
try:
|
|
395
|
+
while True:
|
|
396
|
+
time.sleep(3600)
|
|
397
|
+
except KeyboardInterrupt:
|
|
398
|
+
self.logger.info("stopping")
|
|
399
|
+
finally:
|
|
400
|
+
self.stop()
|
|
401
|
+
|
|
402
|
+
def _apply_changes(self) -> None:
|
|
403
|
+
"""Put what boxes changed in the catalog into the working data of the library."""
|
|
404
|
+
while True:
|
|
405
|
+
self._changed.wait()
|
|
406
|
+
if self._stopping.is_set():
|
|
407
|
+
return
|
|
408
|
+
self._changed.clear()
|
|
409
|
+
try:
|
|
410
|
+
self.targets.sync(lambda: self.store.working("target"))
|
|
411
|
+
self.tags.sync(lambda: self.store.working("tag"))
|
|
412
|
+
except Exception:
|
|
413
|
+
self.logger.exception("the working data of the library cannot be brought up to date")
|
|
414
|
+
|
|
415
|
+
def settle(self, timeout: float = 5.0) -> None:
|
|
416
|
+
"""Wait until the working data has what the boxes changed (tests)."""
|
|
417
|
+
deadline = time.monotonic() + timeout
|
|
418
|
+
while self._changed.is_set() and time.monotonic() < deadline:
|
|
419
|
+
time.sleep(0.01)
|
|
420
|
+
|
|
421
|
+
# --- who may ask (section 4.14) ----------------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
def _guard(self, request: Request) -> None:
|
|
424
|
+
"""No part of the interface for the boxes answers without the key of a box."""
|
|
425
|
+
kind, _, credentials = request.headers.get("Authorization", "").partition(" ")
|
|
426
|
+
box_id, _, secret = credentials.partition(":")
|
|
427
|
+
if self.restore.confirmed: # a backup is put back: the library starts again in a moment
|
|
428
|
+
raise ServiceUnavailable("a backup is being put back.")
|
|
429
|
+
box = self.store.authenticate(box_id, secret) if kind == "Box" else None
|
|
430
|
+
if box is None:
|
|
431
|
+
raise Unauthorized("")
|
|
432
|
+
try:
|
|
433
|
+
speaks = int(request.headers.get("X-Box-Interface", "0") or 0)
|
|
434
|
+
except ValueError:
|
|
435
|
+
speaks = 0
|
|
436
|
+
self.store.contact(
|
|
437
|
+
box,
|
|
438
|
+
request.headers.get("X-Box-Version", ""),
|
|
439
|
+
speaks,
|
|
440
|
+
request.client_address,
|
|
441
|
+
_port(request.headers.get("X-Box-Page", "")),
|
|
442
|
+
)
|
|
443
|
+
request.context["box"] = box
|
|
444
|
+
if request.params.get("box", box_id) != box_id:
|
|
445
|
+
raise Forbidden("the key of a box is valid for its own data only.")
|
|
446
|
+
if box["kind"] == APP and NOT_FOR_APPS.fullmatch(f"{request.method} {request.path}"):
|
|
447
|
+
raise Forbidden("a phone may not do this.")
|
|
448
|
+
if not UNVERSIONED.fullmatch(request.path) and not request.path.startswith(f"{V}/"):
|
|
449
|
+
# a box that speaks another version gets nothing but what updates it (section 4.15)
|
|
450
|
+
raise Gone("this version of the interface is not served.", serves=[INTERFACE])
|
|
451
|
+
|
|
452
|
+
def _handshake(self, request: Request) -> Response:
|
|
453
|
+
"""Two steps. Nobody without the bootstrap password or the one-time password of a phone gets more than a random
|
|
454
|
+
number. The password that fits decides what is registered: the bootstrap password a box, the one-time password
|
|
455
|
+
the app on a phone (docs/developer/app.md)."""
|
|
456
|
+
body = request.json()
|
|
457
|
+
if not BOX_ID.fullmatch(str(body.get("box", ""))):
|
|
458
|
+
raise Unauthorized("")
|
|
459
|
+
box_id, now = str(body["box"]), time.monotonic()
|
|
460
|
+
with self._lock:
|
|
461
|
+
if "proof" not in body:
|
|
462
|
+
if not isinstance(body.get("nonce"), str) or not 16 <= len(body["nonce"]) <= 256:
|
|
463
|
+
raise Unauthorized("")
|
|
464
|
+
nonce = handshake.new_nonce()
|
|
465
|
+
# the first step needs no password: what was never finished is forgotten, and there is a limit
|
|
466
|
+
for stale in [key for key, (_, _, until) in self._handshakes.items() if until < now]:
|
|
467
|
+
del self._handshakes[stale]
|
|
468
|
+
while len(self._handshakes) >= MAX_HANDSHAKES:
|
|
469
|
+
del self._handshakes[next(iter(self._handshakes))]
|
|
470
|
+
self._handshakes[box_id] = (body["nonce"], nonce, now + HANDSHAKE_SECONDS)
|
|
471
|
+
return Response(data={"nonce": nonce})
|
|
472
|
+
nonce_box, nonce_library, expires = self._handshakes.pop(box_id, ("", "", 0.0))
|
|
473
|
+
with open(self.api.certificate_file, encoding="utf-8") as infile:
|
|
474
|
+
certificate = infile.read()
|
|
475
|
+
|
|
476
|
+
def proven(password: str) -> bool:
|
|
477
|
+
expected = handshake.proof(password, "box", box_id, nonce_box, nonce_library, certificate)
|
|
478
|
+
return bool(password) and now <= expires and handshake.same(str(body["proof"]), expected)
|
|
479
|
+
|
|
480
|
+
password, kind = str(self.settings.get("bootstrap") or ""), BOX
|
|
481
|
+
if not proven(password):
|
|
482
|
+
with self._lock:
|
|
483
|
+
pairing, kind = self._pairing, APP
|
|
484
|
+
password = pairing[0] if pairing is not None and now <= pairing[1] else ""
|
|
485
|
+
if not proven(password):
|
|
486
|
+
self.logger.warning("handshake of box %s rejected", box_id)
|
|
487
|
+
raise Unauthorized("")
|
|
488
|
+
self._pairing = None # used up
|
|
489
|
+
secret = handshake.new_secret()
|
|
490
|
+
settings = {"spotify-client": self.settings["spotify-client"]} if self.settings.get("spotify-client") else {}
|
|
491
|
+
try:
|
|
492
|
+
self.store.register(
|
|
493
|
+
box_id, str(body.get("name") or box_id), secret, kind, settings if kind == APP else None
|
|
494
|
+
)
|
|
495
|
+
except Invalid as error:
|
|
496
|
+
self.logger.warning("handshake of %s rejected: %s", box_id, error)
|
|
497
|
+
raise Unauthorized("") from None
|
|
498
|
+
proof = handshake.proof(password, "library", box_id, nonce_box, nonce_library, certificate)
|
|
499
|
+
return Response(data={"proof": proof, "key": secret})
|
|
500
|
+
|
|
501
|
+
def _box_diagnostics(self, request: Request) -> Any:
|
|
502
|
+
"""What a box collected of its log (section 4.22)."""
|
|
503
|
+
try:
|
|
504
|
+
return {"received": self.diagnostics.add(request.context["box"]["id"], request.json(MAX_CHANGES_BODY))}
|
|
505
|
+
except (ValueError, TypeError, AttributeError) as error:
|
|
506
|
+
raise Invalid(f"the records are not what a box sends: {error}") from None
|
|
507
|
+
|
|
508
|
+
def _startup(self, request: Request) -> None:
|
|
509
|
+
"""Why a box cannot start: its start script says so, also in recovery mode (section 4.22)."""
|
|
510
|
+
body = request.json()
|
|
511
|
+
body = body if isinstance(body, dict) else {}
|
|
512
|
+
box = request.context["box"]
|
|
513
|
+
try:
|
|
514
|
+
failures = int(body.get("failures") or 0)
|
|
515
|
+
except (ValueError, TypeError):
|
|
516
|
+
raise Invalid("'failures' must be a number") from None
|
|
517
|
+
self.diagnostics.startup(box["id"], {**body, "failures": failures})
|
|
518
|
+
if failures <= 1: # said once, not with every start of a restart loop
|
|
519
|
+
self.logger.warning("box %s cannot start: %s", self.store.box_name(box), str(body.get("error"))[-300:])
|
|
520
|
+
|
|
521
|
+
def _status(self, request: Request) -> Any:
|
|
522
|
+
status = request.json(MAX_CHANGES_BODY)
|
|
523
|
+
history, left, defective = status.get("history", []), status.get("left", []), status.get("defective", [])
|
|
524
|
+
if (
|
|
525
|
+
not isinstance(history, list)
|
|
526
|
+
or not all(isinstance(line, dict) and isinstance(line.get("n"), int) for line in history)
|
|
527
|
+
or not isinstance(left, list)
|
|
528
|
+
or not all(isinstance(item, dict) and isinstance(item.get("version"), str) for item in left)
|
|
529
|
+
or not all(isinstance(item.get("base", 0), int) for item in left)
|
|
530
|
+
or not isinstance(defective, list)
|
|
531
|
+
or not all(isinstance(version, str) for version in defective)
|
|
532
|
+
):
|
|
533
|
+
raise Invalid("the status is not what a box sends")
|
|
534
|
+
answer = self.store.report(request.params["box"], status)
|
|
535
|
+
self.diagnostics.started(request.params["box"]) # its application runs: it started after all
|
|
536
|
+
return answer
|
|
537
|
+
|
|
538
|
+
# --- the interface for the boxes (section 4.13) ------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def _sync_routes(self) -> list[Route]:
|
|
541
|
+
store = self.store
|
|
542
|
+
|
|
543
|
+
def answer(function: Any) -> Any:
|
|
544
|
+
"""A route of the interface: what the store does not have or rejects becomes an error for the box."""
|
|
545
|
+
|
|
546
|
+
def wrapped(request: Request) -> Response:
|
|
547
|
+
try:
|
|
548
|
+
result = function(request)
|
|
549
|
+
except Unknown as error:
|
|
550
|
+
raise NotFound(f"unknown: {error}") from None
|
|
551
|
+
except Invalid as error:
|
|
552
|
+
raise Unprocessable(str(error)) from None
|
|
553
|
+
if isinstance(result, Response):
|
|
554
|
+
return result
|
|
555
|
+
return NO_CONTENT if result is None else Response(data=result)
|
|
556
|
+
|
|
557
|
+
return wrapped
|
|
558
|
+
|
|
559
|
+
def journal(request: Request) -> Any:
|
|
560
|
+
lines = request.json(MAX_CHANGES_BODY).get("lines")
|
|
561
|
+
if not isinstance(lines, list) or not all(
|
|
562
|
+
isinstance(line, dict) and isinstance(line.get("n"), int) and not isinstance(line["n"], bool)
|
|
563
|
+
for line in lines
|
|
564
|
+
):
|
|
565
|
+
raise Invalid("'lines' must be journal lines, each with its number 'n'")
|
|
566
|
+
with store.lock:
|
|
567
|
+
box = store.box(request.params["box"])
|
|
568
|
+
before = box["journal"]
|
|
569
|
+
box["journal"] = store.journal.add(box["id"], before, lines)
|
|
570
|
+
store.save_box(box)
|
|
571
|
+
for line in lines: # the lines that were taken now: a file that the box found damaged is collected
|
|
572
|
+
if line.get("event") == "broken" and before < line["n"] <= box["journal"]:
|
|
573
|
+
store.reported(box["id"], line)
|
|
574
|
+
return {"confirmed": box["journal"]}
|
|
575
|
+
|
|
576
|
+
def own_statistics(request: Request) -> Any:
|
|
577
|
+
"""What the journal of this box says, for the page of the box. The other boxes are not its business."""
|
|
578
|
+
return self.statistics(request.params["box"], others=False)
|
|
579
|
+
|
|
580
|
+
def changes(request: Request) -> Any:
|
|
581
|
+
entries = request.json(MAX_CHANGES_BODY).get("entries")
|
|
582
|
+
if not isinstance(entries, list):
|
|
583
|
+
raise Invalid("'entries' is missing")
|
|
584
|
+
return {"results": store.apply(request.params["box"], entries)}
|
|
585
|
+
|
|
586
|
+
def receive(request: Request) -> Any:
|
|
587
|
+
return store.receive_file(
|
|
588
|
+
request.context["box"]["id"], request.params["id"], request.params["file"], request.save
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def receive_image(request: Request) -> Any:
|
|
592
|
+
store.receive_image(request.context["box"]["id"], request.params["name"], request.save)
|
|
593
|
+
|
|
594
|
+
def catalog_file(request: Request) -> Any:
|
|
595
|
+
if knows_album_covers(request.context["box"].get("software")):
|
|
596
|
+
return Response(file=store.catalog_path(request.params["file"]))
|
|
597
|
+
# a box before 0.32: the catalog as it knows it
|
|
598
|
+
data = store.catalog_file(request.params["file"], album_covers=False)
|
|
599
|
+
return Response(body=data, content_type="application/json")
|
|
600
|
+
|
|
601
|
+
routes = [
|
|
602
|
+
# what never changes, next to the handshake: interface versions, versions of the box software
|
|
603
|
+
(
|
|
604
|
+
"GET",
|
|
605
|
+
"/api/sync/interface",
|
|
606
|
+
# "app": the version of the app that /app.apk hands out, for the settings of the app
|
|
607
|
+
lambda r: {"serves": [INTERFACE], "library": __version__, "app": self.install_offer()["app"]},
|
|
608
|
+
),
|
|
609
|
+
(
|
|
610
|
+
"GET",
|
|
611
|
+
"/api/sync/versions",
|
|
612
|
+
# a phone is offered no version of the box software
|
|
613
|
+
lambda r: {
|
|
614
|
+
"versions": [v for v in self.versions() if v["offered"] and r.context["box"]["kind"] == BOX]
|
|
615
|
+
},
|
|
616
|
+
),
|
|
617
|
+
# ... and why a box cannot start: sent by its start script, also in recovery mode (section 4.22)
|
|
618
|
+
("PUT", "/api/sync/startup", self._startup),
|
|
619
|
+
("GET", "/api/sync/versions/{version}", lambda r: self._version_file(r.params["version"], "archive")),
|
|
620
|
+
(
|
|
621
|
+
"GET",
|
|
622
|
+
"/api/sync/versions/{version}/signature",
|
|
623
|
+
lambda r: self._version_file(r.params["version"], "signature"),
|
|
624
|
+
),
|
|
625
|
+
# the interface of this version
|
|
626
|
+
(
|
|
627
|
+
"GET",
|
|
628
|
+
f"{V}/catalog",
|
|
629
|
+
lambda r: {"files": store.catalog_index(knows_album_covers(r.context["box"].get("software")))},
|
|
630
|
+
),
|
|
631
|
+
("GET", f"{V}/catalog/{{file}}", catalog_file),
|
|
632
|
+
("GET", f"{V}/boxes/{{box}}/state", lambda r: store.box_state(r.params["box"])),
|
|
633
|
+
("POST", f"{V}/boxes/{{box}}/changes", changes),
|
|
634
|
+
(
|
|
635
|
+
"GET",
|
|
636
|
+
f"{V}/boxes/{{box}}/ids/{{type}}/{{id}}",
|
|
637
|
+
lambda r: store.resolve(r.params["box"], r.params["type"], r.params["id"]),
|
|
638
|
+
),
|
|
639
|
+
("POST", f"{V}/boxes/{{box}}/journal", journal),
|
|
640
|
+
("POST", f"{V}/boxes/{{box}}/diagnostics", self._box_diagnostics),
|
|
641
|
+
("GET", f"{V}/boxes/{{box}}/statistics", own_statistics),
|
|
642
|
+
("PUT", f"{V}/boxes/{{box}}/status", self._status),
|
|
643
|
+
("GET", f"{V}/albums/{{id}}/manifest", lambda r: store.manifest(r.params["id"])),
|
|
644
|
+
(
|
|
645
|
+
"GET",
|
|
646
|
+
f"{V}/albums/{{id}}/files/{{file}}",
|
|
647
|
+
lambda r: Response(file=store.album_file(r.params["id"], r.params["file"])),
|
|
648
|
+
),
|
|
649
|
+
("PUT", f"{V}/albums/{{id}}/files/{{file}}", receive),
|
|
650
|
+
("GET", f"{V}/images/{{name}}", lambda r: Response(file=store.image_file(r.params["name"]))),
|
|
651
|
+
("PUT", f"{V}/images/{{name}}", receive_image),
|
|
652
|
+
("GET", f"{V}/events", lambda r: Response(content_type="text/event-stream", stream=self._events)),
|
|
653
|
+
]
|
|
654
|
+
|
|
655
|
+
def install_file(kind: str) -> Response:
|
|
656
|
+
"""Without credentials: only what the archive has - never a question to the git server."""
|
|
657
|
+
version = self.install_offer()["version"]
|
|
658
|
+
if version is None:
|
|
659
|
+
raise NotFound("no version is offered for an installation")
|
|
660
|
+
try:
|
|
661
|
+
return Response(file=self.store.version_file(version, kind))
|
|
662
|
+
except Unknown:
|
|
663
|
+
raise NotFound(f"version {version} has no {kind} here") from None
|
|
664
|
+
|
|
665
|
+
def install_description(request: Request) -> Any:
|
|
666
|
+
version = self.install_offer()["version"]
|
|
667
|
+
if version is None:
|
|
668
|
+
raise NotFound("no version is offered for an installation")
|
|
669
|
+
return self.store.versions()[version]["description"]
|
|
670
|
+
|
|
671
|
+
return [
|
|
672
|
+
# the handshake proves the bootstrap password itself
|
|
673
|
+
Route("POST", "/api/sync/boxes", self._handshake, None),
|
|
674
|
+
# a new box, before it has anything to register with: the files of a release, which are public, and the
|
|
675
|
+
# box verifies the slot by its signature (docs/developer/deployment.md, section 5)
|
|
676
|
+
Route("GET", "/install.sh", lambda r: install_file("installer"), None),
|
|
677
|
+
Route("GET", "/api/sync/install", answer(install_description), None),
|
|
678
|
+
Route("GET", "/api/sync/install/slot", lambda r: install_file("archive"), None),
|
|
679
|
+
Route("GET", "/api/sync/install/signature", lambda r: install_file("signature"), None),
|
|
680
|
+
# the app for phones of the same version, also public: a phone in the home network needs no GitHub
|
|
681
|
+
Route("GET", "/app.apk", lambda r: self._app_file(), None),
|
|
682
|
+
*(Route(method, pattern, answer(handler), GUARDED) for method, pattern, handler in routes),
|
|
683
|
+
]
|
|
684
|
+
|
|
685
|
+
def _events(self, out: Writable) -> None:
|
|
686
|
+
"""Server-sent events for one box until it goes away or the library stops (section 4.9). An event carries
|
|
687
|
+
no content: it only says that something changed."""
|
|
688
|
+
source = self.hub.subscribe()
|
|
689
|
+
try:
|
|
690
|
+
for event in source:
|
|
691
|
+
if self._stopping.is_set():
|
|
692
|
+
break
|
|
693
|
+
out.write(b": keep-alive\n\n" if event is None else f"event: {event}\ndata: {{}}\n\n".encode())
|
|
694
|
+
out.flush()
|
|
695
|
+
except OSError: # the box went away
|
|
696
|
+
pass
|
|
697
|
+
finally:
|
|
698
|
+
source.close() # type: ignore[attr-defined]
|
|
699
|
+
|
|
700
|
+
def _app_file(self) -> Response:
|
|
701
|
+
version = self.install_offer()["app"]
|
|
702
|
+
if version is None:
|
|
703
|
+
raise NotFound("this library has no app to hand out")
|
|
704
|
+
headers = {
|
|
705
|
+
"Content-Type": "application/vnd.android.package-archive",
|
|
706
|
+
"Content-Disposition": f'attachment; filename="{app_name(version)}"',
|
|
707
|
+
}
|
|
708
|
+
return Response(file=self.store.version_file(version, "app"), headers=headers)
|
|
709
|
+
|
|
710
|
+
def _version_file(self, version: str, kind: str) -> Response:
|
|
711
|
+
entry = self.store.versions().get(version)
|
|
712
|
+
if entry is not None and not entry.get("files"):
|
|
713
|
+
try:
|
|
714
|
+
self.releases.restore(version) # removed from the archive: fetched again when needed
|
|
715
|
+
except Unreachable as error:
|
|
716
|
+
raise ServiceUnavailable(f"version {version} cannot be fetched again now: {error}") from None
|
|
717
|
+
return Response(file=self.store.version_file(version, kind))
|
|
718
|
+
|
|
719
|
+
# --- versions and boxes as the page shows them (section 6.13) ----------------------------------------------------
|
|
720
|
+
|
|
721
|
+
def install_offer(self) -> dict[str, Any]:
|
|
722
|
+
"""What the section "Add a box" offers: the newest version in the state ``use`` that the boxes are offered and
|
|
723
|
+
whose installer the library has (``version``), the newest such version at all (``newest``), and the pin of the
|
|
724
|
+
certificate of the library for the line. ``app``: that version, if the library has its app for phones -
|
|
725
|
+
"Add a phone" offers the app of the version that "Add a box" installs, so that app and library fit."""
|
|
726
|
+
usable = [entry for entry in self.versions() if entry["state"] == "use" and entry["offered"]]
|
|
727
|
+
archive = self.store.versions()
|
|
728
|
+
installable = [entry for entry in usable if "installer" in archive[entry["version"]].get("files", {})]
|
|
729
|
+
with open(self.api.certificate_file, encoding="utf-8") as infile:
|
|
730
|
+
pin = public_key_pin(infile.read())
|
|
731
|
+
return {
|
|
732
|
+
"version": installable[-1]["version"] if installable else None,
|
|
733
|
+
"newest": usable[-1]["version"] if usable else None,
|
|
734
|
+
"pin": pin,
|
|
735
|
+
"app": installable[-1]["version"]
|
|
736
|
+
if installable and "app" in archive[installable[-1]["version"]].get("files", {})
|
|
737
|
+
else None,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
def versions(self) -> list[dict[str, Any]]:
|
|
741
|
+
"""The versions in the archive with their states. A version that speaks an interface this library does not
|
|
742
|
+
serve needs a newer library and is not offered to the boxes (section 8)."""
|
|
743
|
+
boxes = self.store.boxes()
|
|
744
|
+
result = []
|
|
745
|
+
for version, entry in sorted(self.store.versions().items(), key=lambda item: version_key(item[0])):
|
|
746
|
+
description = entry["description"]
|
|
747
|
+
fits = description["interface"] == INTERFACE
|
|
748
|
+
result.append({
|
|
749
|
+
"version": version, "state": entry["state"], "rev": entry["rev"], "by": entry["by"],
|
|
750
|
+
"when": entry["when"], "why": entry["why"], "description": description, "offered": fits,
|
|
751
|
+
"note": "" if fits else "needs a newer library", "archived": bool(entry.get("files")),
|
|
752
|
+
"boxes": [box["id"] for box in boxes if box["kind"] == BOX and box["status"].get("version") == version],
|
|
753
|
+
# fetched by the box and tried on its next start (section 6.3)
|
|
754
|
+
"prepared": [
|
|
755
|
+
box["id"] for box in boxes
|
|
756
|
+
if box["status"].get("slots", {}).get("next", {}).get("version") == version
|
|
757
|
+
],
|
|
758
|
+
}) # fmt: skip
|
|
759
|
+
return result
|
|
760
|
+
|
|
761
|
+
def statistics(self, box: str | None = None, others: bool = True) -> dict[str, Any]:
|
|
762
|
+
"""What was played (section 7): on all boxes, or on ``box`` only. ``boxes`` names every box, so that the page
|
|
763
|
+
of the library can offer them - without ``others`` only ``box`` itself, and only its journal is read: what a
|
|
764
|
+
box asks for its own page."""
|
|
765
|
+
store = self.store
|
|
766
|
+
names = {entry["id"]: store.box_name(entry) for entry in store.boxes()}
|
|
767
|
+
gone = frozenset(entry["id"] for entry in store.boxes() if entry.get("removed"))
|
|
768
|
+
|
|
769
|
+
def counted(only: str | None) -> dict[str, Any]:
|
|
770
|
+
return statistics(
|
|
771
|
+
store.journal.lines(only),
|
|
772
|
+
store.journal.records(box=only),
|
|
773
|
+
store.working("tag"),
|
|
774
|
+
store.working("target"),
|
|
775
|
+
names,
|
|
776
|
+
gone,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
if box is None:
|
|
780
|
+
return {**counted(None), "box": None}
|
|
781
|
+
own = counted(store.box(box)["id"])
|
|
782
|
+
if not others:
|
|
783
|
+
return {**own, "boxes": [entry for entry in own["boxes"] if entry["id"] == box], "box": box}
|
|
784
|
+
return {**own, "boxes": counted(None)["boxes"], "box": box}
|
|
785
|
+
|
|
786
|
+
def overview(self) -> dict[str, Any]:
|
|
787
|
+
boxes = []
|
|
788
|
+
startups = self.diagnostics.startups()
|
|
789
|
+
for box in self.store.boxes():
|
|
790
|
+
speaks, name = box.get("interface", 0), self.store.box_name(box)
|
|
791
|
+
fits = ""
|
|
792
|
+
if speaks and speaks != INTERFACE:
|
|
793
|
+
fits = f"box {name} speaks version {speaks}, this library serves {INTERFACE}"
|
|
794
|
+
if speaks < INTERFACE and box["status"].get("mode") in ("manual", "off"):
|
|
795
|
+
fits += "; its update mode is manual, it must be updated by hand"
|
|
796
|
+
boxes.append({
|
|
797
|
+
"id": box["id"], "kind": box["kind"], "name": name, "removed": box.get("removed", False),
|
|
798
|
+
"contact": box.get("contact"), "registered": box.get("registered"), "software": box.get("software"),
|
|
799
|
+
"address": box.get("address", ""), "page": box.get("page"),
|
|
800
|
+
"interface": speaks,
|
|
801
|
+
"fits": fits, "status": box["status"], "positions": len(box["positions"]),
|
|
802
|
+
"settings": {key: entry["value"] for key, entry in box["settings"].items()},
|
|
803
|
+
"waiting": len(box["waiting"]),
|
|
804
|
+
"startup": startups.get(box["id"]), # the box cannot start: its last report (section 4.22)
|
|
805
|
+
}) # fmt: skip
|
|
806
|
+
return {
|
|
807
|
+
"library": __version__, "interface": INTERFACE, "boxes": boxes, "versions": self.versions(),
|
|
808
|
+
"uploads": self.store.uploads(),
|
|
809
|
+
"releases": {**self.releases.state(), "token": self._access},
|
|
810
|
+
"bootstrap": bool(self.settings.get("bootstrap")),
|
|
811
|
+
"install": self.install_offer(),
|
|
812
|
+
"app": {"version": self.install_offer()["app"], "spotify_client": self.settings.get("spotify-client")},
|
|
813
|
+
} # fmt: skip
|
|
814
|
+
|
|
815
|
+
# --- what the page of the library does beyond the catalog --------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
def _page_routes(self) -> list[Route]:
|
|
818
|
+
store = self.store
|
|
819
|
+
|
|
820
|
+
def done(function: Any) -> Any:
|
|
821
|
+
def wrapped(request: Request) -> Response:
|
|
822
|
+
try:
|
|
823
|
+
result = function(request)
|
|
824
|
+
except Unknown as error:
|
|
825
|
+
raise NotFound(f"unknown: {error}") from None
|
|
826
|
+
except (Invalid, KeyError, TypeError) as error: # an Invalid may name the code of its text
|
|
827
|
+
raise BadRequest(str(error), **getattr(error, "details", {})) from None
|
|
828
|
+
return NO_CONTENT if result is None else Response(data=result)
|
|
829
|
+
|
|
830
|
+
return wrapped
|
|
831
|
+
|
|
832
|
+
def set_version(request: Request) -> None:
|
|
833
|
+
body = request.json()
|
|
834
|
+
store.set_version(request.params["version"], body["state"], actor(), str(body.get("why") or "set by hand"))
|
|
835
|
+
|
|
836
|
+
def set_position(request: Request) -> None:
|
|
837
|
+
store.set_position(request.params["box"], request.params["uid"], request.json().get("value"), actor())
|
|
838
|
+
|
|
839
|
+
def bootstrap(request: Request) -> None:
|
|
840
|
+
password = request.json().get("password")
|
|
841
|
+
if not isinstance(password, str) or len(password) < 16:
|
|
842
|
+
raise Invalid("the bootstrap password must have at least 16 characters.", code="bootstrap.too_short")
|
|
843
|
+
self.settings["bootstrap"] = password
|
|
844
|
+
self._save_settings()
|
|
845
|
+
self.logger.info("bootstrap password changed by %s", actor())
|
|
846
|
+
|
|
847
|
+
def token(request: Request) -> dict[str, str]:
|
|
848
|
+
new = secrets.token_urlsafe(32)
|
|
849
|
+
self.settings.setdefault("script-tokens", []).append(handshake.hash_secret(new))
|
|
850
|
+
self._save_settings()
|
|
851
|
+
self.logger.info("token for scripts created by %s", actor())
|
|
852
|
+
return {"token": new}
|
|
853
|
+
|
|
854
|
+
def verify(request: Request) -> dict[str, Any]:
|
|
855
|
+
store.verify_albums()
|
|
856
|
+
store.scan_images()
|
|
857
|
+
return self.overview()
|
|
858
|
+
|
|
859
|
+
def journal(request: Request) -> dict[str, Any]:
|
|
860
|
+
kind, box = request.query.get("kind", [None])[-1], request.query.get("box", [None])[-1]
|
|
861
|
+
return {"records": store.journal.records(kind, box), "lines": store.journal.lines(box)}
|
|
862
|
+
|
|
863
|
+
def scan(request: Request) -> Response:
|
|
864
|
+
tag, added = self.control.scan_tag(request.params["uid"])
|
|
865
|
+
return Response(201 if added else 200, tag)
|
|
866
|
+
|
|
867
|
+
def filters(request: Request) -> dict[str, str]:
|
|
868
|
+
return {key: str(request.query[key][-1]) for key in FILTERS if request.query.get(key, [""])[-1]}
|
|
869
|
+
|
|
870
|
+
def diagnostics(request: Request) -> dict[str, Any]:
|
|
871
|
+
chosen = filters(request)
|
|
872
|
+
try:
|
|
873
|
+
limit = max(1, min(int(request.query.get("limit", ["1000"])[-1]), 5000))
|
|
874
|
+
except ValueError:
|
|
875
|
+
raise BadRequest("'limit' must be a number.") from None
|
|
876
|
+
return {"records": self.diagnostics.query(limit, **chosen),
|
|
877
|
+
"choices": self.diagnostics.choices(chosen.get("box"))} # fmt: skip
|
|
878
|
+
|
|
879
|
+
def export(request: Request) -> Response:
|
|
880
|
+
"""The records that match, as lines of JSON to save - at most 20000."""
|
|
881
|
+
lines = "".join(json.dumps(record) + "\n" for record in self.diagnostics.query(20000, **filters(request)))
|
|
882
|
+
return Response(body=lines.encode(), content_type="application/x-ndjson")
|
|
883
|
+
|
|
884
|
+
def play_statistics(request: Request) -> dict[str, Any]:
|
|
885
|
+
return self.statistics(request.query.get("box", [None])[-1] or None)
|
|
886
|
+
|
|
887
|
+
return [
|
|
888
|
+
Route("GET", "/api/library/overview", done(lambda r: self.overview()), "user"),
|
|
889
|
+
Route("PUT", "/api/library/versions/{version}", done(set_version), "admin"),
|
|
890
|
+
Route("PATCH", "/api/library/boxes/{box}", done(self._set_box), "admin"),
|
|
891
|
+
Route("POST", "/api/library/pairings", done(self._pair), "admin"),
|
|
892
|
+
Route("PUT", "/api/library/spotify-client", done(self._set_spotify_client), "admin"),
|
|
893
|
+
Route("GET", "/api/library/qr", done(self._qr), "admin"),
|
|
894
|
+
Route("DELETE", "/api/library/boxes/{box}", done(lambda r: store.remove_box(r.params["box"])), "admin"),
|
|
895
|
+
Route("PUT", "/api/library/boxes/{box}/positions/{uid}", done(set_position), "admin"),
|
|
896
|
+
Route("GET", "/api/library/boxes/{box}", done(lambda r: store.box_state(r.params["box"])), "admin"),
|
|
897
|
+
# by an admin of the page, or by a script with its token (check_release.sh)
|
|
898
|
+
Route("POST", "/api/library/releases/check", self._check_releases, None),
|
|
899
|
+
Route("PUT", "/api/library/releases/token", done(self._set_releases_token), "admin"),
|
|
900
|
+
Route("PUT", "/api/library/releases/keep", done(self._set_releases_keep), "admin"),
|
|
901
|
+
Route("PUT", "/api/library/bootstrap", done(bootstrap), "admin"),
|
|
902
|
+
Route("POST", "/api/library/tokens", done(token), "admin"),
|
|
903
|
+
Route("POST", "/api/library/verify", done(verify), "admin"),
|
|
904
|
+
Route(
|
|
905
|
+
"DELETE",
|
|
906
|
+
"/api/library/albums/{id}/broken",
|
|
907
|
+
done(lambda r: self.control.forget_broken_reports(r.params["id"])),
|
|
908
|
+
"admin",
|
|
909
|
+
),
|
|
910
|
+
Route("GET", "/api/library/journal", done(journal), "admin"),
|
|
911
|
+
# what the boxes delivered of their logs (section 4.22): for admins, like the log of the library
|
|
912
|
+
Route("GET", "/api/library/diagnostics", done(diagnostics), "admin"),
|
|
913
|
+
Route("GET", "/api/library/diagnostics/export", export, "admin"),
|
|
914
|
+
# what was played is for everybody with a login on the page; it never leaves the library
|
|
915
|
+
Route("GET", "/api/library/statistics", done(play_statistics), "user"),
|
|
916
|
+
# the phone as reader: every user may make a tag known, as everybody may place a tag on a box
|
|
917
|
+
Route("PUT", "/api/tags/{uid}", scan, "user"),
|
|
918
|
+
*self._fill_routes(done),
|
|
919
|
+
]
|
|
920
|
+
|
|
921
|
+
def _fill_routes(self, done: Any) -> list[Route]:
|
|
922
|
+
"""The first filling of the library from the PC (section 3.5, ``tinyjukebox-fill-library``)."""
|
|
923
|
+
store = self.store
|
|
924
|
+
|
|
925
|
+
def fill(request: Request) -> dict[str, Any]:
|
|
926
|
+
"""The first filling (section 3.5): tags and targets under their ids."""
|
|
927
|
+
body = request.json(MAX_CHANGES_BODY)
|
|
928
|
+
kinds = {"target": "targets", "tag": "tags"}
|
|
929
|
+
return {name: store.fill(kind, body.get(name) or {}, actor()) for kind, name in kinds.items()}
|
|
930
|
+
|
|
931
|
+
def describe(request: Request) -> dict[str, Any]:
|
|
932
|
+
manifest = request.json(MAX_CHANGES_BODY).get("manifest") or {}
|
|
933
|
+
return {"lacks": store.describe_album(request.params["id"], manifest, actor())}
|
|
934
|
+
|
|
935
|
+
def album_file(request: Request) -> dict[str, Any]:
|
|
936
|
+
return store.receive_file(FILLING, request.params["id"], request.params["file"], request.save)
|
|
937
|
+
|
|
938
|
+
def image(request: Request) -> None:
|
|
939
|
+
store.receive_image(FILLING, request.params["name"], request.save)
|
|
940
|
+
|
|
941
|
+
return [
|
|
942
|
+
Route("POST", "/api/library/fill", done(fill), "admin"),
|
|
943
|
+
Route("POST", "/api/library/fill/albums/{id}", done(describe), "admin"),
|
|
944
|
+
Route("PUT", "/api/library/fill/albums/{id}/files/{file}", done(album_file), "admin"),
|
|
945
|
+
Route("PUT", "/api/library/fill/images/{name}", done(image), "admin"),
|
|
946
|
+
]
|
|
947
|
+
|
|
948
|
+
def _set_box(self, request: Request) -> None:
|
|
949
|
+
"""Name of a box, taking over an old one, the volume limits of a phone."""
|
|
950
|
+
body = request.json()
|
|
951
|
+
if "name" in body:
|
|
952
|
+
if not isinstance(body["name"], str) or not body["name"].strip():
|
|
953
|
+
raise Invalid("name must not be empty.", code="name.empty")
|
|
954
|
+
self.store.set_setting(request.params["box"], "name", body["name"].strip(), actor())
|
|
955
|
+
if "takeover" in body:
|
|
956
|
+
self.store.take_over(request.params["box"], str(body["takeover"]), actor())
|
|
957
|
+
if "volume" in body:
|
|
958
|
+
self._set_volume(request.params["box"], body["volume"])
|
|
959
|
+
|
|
960
|
+
def _set_volume(self, box_id: str, volume: Any) -> None:
|
|
961
|
+
"""The volume limits of a phone: the most it plays with, of what the phone is set to (docs/developer/app.md)."""
|
|
962
|
+
store = self.store
|
|
963
|
+
with store.lock:
|
|
964
|
+
kind = store.box(box_id)["kind"]
|
|
965
|
+
if kind != APP:
|
|
966
|
+
raise Invalid("only a phone gets its volume limits from the library.", code="boxes.volume_not_a_phone")
|
|
967
|
+
if not isinstance(volume, dict) or not set(volume) <= {"speaker", "headphones"}:
|
|
968
|
+
raise Invalid("volume: the limits of speaker and headphones.", code="boxes.volume_invalid")
|
|
969
|
+
for output, limits in volume.items():
|
|
970
|
+
most = limits.get("max-volume") if isinstance(limits, dict) else None
|
|
971
|
+
if not isinstance(most, int) or isinstance(most, bool) or not 0 <= most <= 100:
|
|
972
|
+
raise Invalid("the most a phone plays with is 0 to 100.", code="boxes.volume_invalid")
|
|
973
|
+
store.set_setting(box_id, f"volume.{output}", {"max-volume": most}, actor())
|
|
974
|
+
|
|
975
|
+
def _set_spotify_client(self, request: Request) -> dict[str, Any]:
|
|
976
|
+
"""The client id of the app at Spotify (docs/developer/app.md, section 3): every phone gets it as its setting
|
|
977
|
+
``spotify-client``, a phone paired later with its pairing. Empty: none - a tag of Spotify plays on a box
|
|
978
|
+
only."""
|
|
979
|
+
client = str(request.json().get("client") or "").strip().lower()
|
|
980
|
+
if client and not SPOTIFY_CLIENT.fullmatch(client):
|
|
981
|
+
raise Invalid("the client id of Spotify has 32 characters, 0-9 and a-f.", code="spotify.client_invalid")
|
|
982
|
+
if client:
|
|
983
|
+
self.settings["spotify-client"] = client
|
|
984
|
+
else:
|
|
985
|
+
self.settings.pop("spotify-client", None)
|
|
986
|
+
self._save_settings()
|
|
987
|
+
for box in self.store.boxes():
|
|
988
|
+
if box["kind"] == APP and not box.get("removed"):
|
|
989
|
+
self.store.set_setting(box["id"], "spotify-client", client or None, actor())
|
|
990
|
+
self.logger.info("the client id of the app at Spotify set by %s", actor())
|
|
991
|
+
return {"client": client or None}
|
|
992
|
+
|
|
993
|
+
@staticmethod
|
|
994
|
+
def _qr(request: Request) -> dict[str, Any]:
|
|
995
|
+
"""A QR code for the camera of a phone, e.g. the link to the app."""
|
|
996
|
+
text = request.query.get("text", [""])[-1]
|
|
997
|
+
if not text.startswith("https://") or len(text) > 500:
|
|
998
|
+
raise Invalid("only an address of the library becomes a QR code.")
|
|
999
|
+
return {"svg": qr_svg(text)}
|
|
1000
|
+
|
|
1001
|
+
def _pair(self, request: Request) -> dict[str, Any]:
|
|
1002
|
+
"""A one-time password for pairing a phone, valid 10 minutes; a new one makes the old one invalid. The QR code
|
|
1003
|
+
holds it with the address of the library - the one the browser uses (docs/developer/app.md)."""
|
|
1004
|
+
url = str(request.json().get("url") or "").rstrip("/")
|
|
1005
|
+
if not url.startswith("https://"):
|
|
1006
|
+
raise Invalid("the address of the library begins with https://.", code="pairing.url")
|
|
1007
|
+
password = handshake.new_secret()
|
|
1008
|
+
with self._lock:
|
|
1009
|
+
self._pairing = (password, time.monotonic() + PAIRING_SECONDS)
|
|
1010
|
+
content = "tinyjukebox:pair?" + urllib.parse.urlencode({"library": url, "password": password})
|
|
1011
|
+
self.logger.info("a phone may be paired for %d minutes, asked by %s", PAIRING_SECONDS // 60, actor())
|
|
1012
|
+
return {"content": content, "svg": qr_svg(content), "seconds": PAIRING_SECONDS}
|
|
1013
|
+
|
|
1014
|
+
def _set_releases_keep(self, request: Request) -> dict[str, Any]:
|
|
1015
|
+
"""How many versions the archive keeps at most; what is too much goes at once."""
|
|
1016
|
+
new = request.json().get("versions")
|
|
1017
|
+
if not isinstance(new, int) or isinstance(new, bool) or new < 1:
|
|
1018
|
+
raise Invalid("the number of versions must be 1 or more.", code="releases.keep_invalid")
|
|
1019
|
+
self.releases.set_keep(new)
|
|
1020
|
+
self.settings["keep-versions"] = new
|
|
1021
|
+
self._save_settings()
|
|
1022
|
+
self.logger.info("the archive keeps %d versions at most, set by %s", new, actor())
|
|
1023
|
+
return {"keep": new}
|
|
1024
|
+
|
|
1025
|
+
def _set_releases_token(self, request: Request) -> dict[str, Any]:
|
|
1026
|
+
"""Take over the token with which the library reads the releases, and look for a release with it."""
|
|
1027
|
+
new = request.json().get("token")
|
|
1028
|
+
if not isinstance(new, str) or not new.strip():
|
|
1029
|
+
raise Invalid("the token must not be empty.", code="releases.token_empty")
|
|
1030
|
+
try:
|
|
1031
|
+
self.releases.set_token(new.strip())
|
|
1032
|
+
except Unreachable as error:
|
|
1033
|
+
raise Invalid(
|
|
1034
|
+
f"the token is not taken over: {error}", code="releases.token_refused", reason=str(error)
|
|
1035
|
+
) from None
|
|
1036
|
+
self.settings["releases-token"] = new.strip()
|
|
1037
|
+
self._save_settings()
|
|
1038
|
+
self._access = "page"
|
|
1039
|
+
self.logger.info("token for the releases set by %s", actor())
|
|
1040
|
+
return self.releases.check(by_hand=True)
|
|
1041
|
+
|
|
1042
|
+
def _check_releases(self, request: Request) -> Response:
|
|
1043
|
+
"""Look for a release now. Also shortens the interval of the checks that follow (section 8)."""
|
|
1044
|
+
role = self.api.users.get_role(request.session.username) if request.session is not None else None
|
|
1045
|
+
if role is not None and has_role(role, "admin"):
|
|
1046
|
+
by = f"web:{request.session.username}" if request.session is not None else "web"
|
|
1047
|
+
elif request.token and handshake.hash_secret(request.token) in self.settings.get("script-tokens", []):
|
|
1048
|
+
by = "script"
|
|
1049
|
+
else:
|
|
1050
|
+
raise Unauthorized("login required.")
|
|
1051
|
+
self.logger.info("check for a release triggered by %s", by)
|
|
1052
|
+
return Response(data=self.releases.check(by_hand=True))
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _port(value: str) -> int | None:
|
|
1056
|
+
"""The port of the page of a box from its header ``X-Box-Page``; None if it sent none or no port."""
|
|
1057
|
+
try:
|
|
1058
|
+
port = int(value)
|
|
1059
|
+
except ValueError:
|
|
1060
|
+
return None
|
|
1061
|
+
return port if 0 < port < 65536 else None
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def qr_svg(text: str) -> str:
|
|
1065
|
+
"""``text`` as a QR code: an SVG document of its own, with its namespace - the page shows it as an image."""
|
|
1066
|
+
buffer = io.BytesIO()
|
|
1067
|
+
segno.make(text, error="m").save(buffer, kind="svg", xmldecl=False, svgns=True, nl=False, scale=5, border=2,
|
|
1068
|
+
light="#fff") # fmt: skip
|
|
1069
|
+
return buffer.getvalue().decode("utf-8")
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
1073
|
+
"""Console script ``tinyjukebox-library``."""
|
|
1074
|
+
parser = argparse.ArgumentParser(description="The library of Tiny Jukebox: catalog, albums and box versions.")
|
|
1075
|
+
where = parser.add_mutually_exclusive_group(required=True)
|
|
1076
|
+
where.add_argument("-c", "--config", help="config file (yaml)")
|
|
1077
|
+
where.add_argument("--data", metavar="DIR", help="everything below this folder, without a config file of its own")
|
|
1078
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="log to the console as well")
|
|
1079
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
1080
|
+
arguments = parser.parse_args(argv)
|
|
1081
|
+
config = library_config(arguments.data) if arguments.data else read_config(arguments.config)
|
|
1082
|
+
error = validate_config(config, get_schema())
|
|
1083
|
+
if error is not None:
|
|
1084
|
+
raise SystemExit(f"{arguments.config or arguments.data}: {error}")
|
|
1085
|
+
log_file = os.path.expanduser(config["logger"]["log-file"])
|
|
1086
|
+
os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True)
|
|
1087
|
+
logger = create_logger(config["logger"], "Library")
|
|
1088
|
+
add_stdout_handler("INFO" if arguments.verbose else "WARNING")
|
|
1089
|
+
|
|
1090
|
+
def stop(signum: int, frame: object) -> None:
|
|
1091
|
+
raise KeyboardInterrupt(signal.Signals(signum).name)
|
|
1092
|
+
|
|
1093
|
+
for signum in (signal.SIGTERM, signal.SIGHUP):
|
|
1094
|
+
signal.signal(signum, stop)
|
|
1095
|
+
try:
|
|
1096
|
+
if arguments.data:
|
|
1097
|
+
first_admin(os.path.expanduser(config["library"]["rest-api"]["users-file"]), logger)
|
|
1098
|
+
library = Library(config["library"], logger)
|
|
1099
|
+
# a backup that is to be put back: the library stops as on SIGTERM, the folders are swapped while nothing
|
|
1100
|
+
# uses them, and the library starts again in this process - nothing on the NAS would start it
|
|
1101
|
+
library.restore.on_confirm = lambda: threading.Timer(0.5, signal.raise_signal, (signal.SIGTERM,)).start()
|
|
1102
|
+
library.run()
|
|
1103
|
+
if library.restore.confirmed:
|
|
1104
|
+
library.restore.apply()
|
|
1105
|
+
logging.shutdown()
|
|
1106
|
+
os.execv(sys.executable, [sys.executable, *sys.orig_argv[1:]]) # noqa: S606 - the same program again
|
|
1107
|
+
except ApiError as failure:
|
|
1108
|
+
raise SystemExit(failure.message) from None
|
|
1109
|
+
except Exception:
|
|
1110
|
+
logger.exception("the library ends with an exception")
|
|
1111
|
+
raise
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
if __name__ == "__main__": # the start script of the package on the NAS runs the module
|
|
1115
|
+
main()
|