tinyjukebox 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,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()