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,379 @@
1
+ """The synchronization of a box with the library (docs/developer/library.md, section 4).
2
+
3
+ The box always opens the connection. One pass: who are you and do we fit together; send the outbox; fetch what
4
+ differs; settle provisional ids and files; deliver the journal; report the status. A pass runs on start, on an event
5
+ of the library and after a change on the box; without a connection it is tried again.
6
+ """
7
+
8
+ import contextlib
9
+ import os
10
+ import socket
11
+ import threading
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from tinyjukebox.sync import INTERFACE, handshake
15
+ from tinyjukebox.sync.client import Client, HttpError, Unreachable, WrongCertificate
16
+ from tinyjukebox.sync.data import CATALOG_FILES, COPY, STATE
17
+ from tinyjukebox.sync.util import sha256_bytes, write_atomic
18
+
19
+ if TYPE_CHECKING:
20
+ from tinyjukebox.sync.box import BoxSync
21
+
22
+ __all__ = ["FITS", "Link", "V"]
23
+
24
+ V = f"/api/sync/v{INTERFACE}"
25
+ FITS = "ok"
26
+ IDLE_SECONDS = 60.0 # a pass at least this often while nothing happens
27
+ BATCH = 20 # entries of the outbox in one request
28
+
29
+
30
+ class _Alone(Exception): # noqa: N818
31
+ """The box works without the library for now; the text says why."""
32
+
33
+
34
+ class Link:
35
+ def __init__(self, box: "BoxSync") -> None:
36
+ self._box = box
37
+ self._data = box.data
38
+ self._logger = box.logger
39
+ self._identity = box.identity
40
+ self._wake = threading.Event()
41
+ self._stop = threading.Event()
42
+ self._passed = threading.Condition()
43
+ self._passes = 0
44
+ self._threads: list[threading.Thread] = []
45
+ self._said = "" # the reason for working alone that was logged last
46
+ self._reported: Any = None
47
+ self._checked = False # whether the versions were looked at since the connection came back
48
+ self._unheard = False # the library does not take diagnostics (older than 0.41): asked again after a break
49
+ self.connected = False
50
+ self.alone = "" # why the box works without the library, for its page
51
+ self.alone_code = "" # ... as a code, if the page has a text for this reason
52
+ self.client: Client | None = None
53
+ self._listening: Client | None = None # the client whose events are read
54
+
55
+ # --- threads -----------------------------------------------------------------------------------------------------
56
+
57
+ def start(self) -> None:
58
+ self._stop.clear()
59
+ self._threads = [
60
+ threading.Thread(target=self._run, name="Link.sync", daemon=True),
61
+ threading.Thread(target=self._listen, name="Link.events", daemon=True),
62
+ ]
63
+ for thread in self._threads:
64
+ thread.start()
65
+
66
+ def stop(self) -> None:
67
+ self._stop.set()
68
+ self._wake.set()
69
+ listening = self._listening
70
+ if listening is not None:
71
+ listening.interrupt()
72
+ for thread in self._threads:
73
+ thread.join(timeout=5)
74
+ self._threads = []
75
+ self.connected = False
76
+
77
+ def wake(self) -> None:
78
+ self._wake.set()
79
+
80
+ def wait_pass(self, timeout: float) -> bool:
81
+ """Trigger a pass and wait until it is over."""
82
+ with self._passed:
83
+ before = self._passes
84
+ self._wake.set()
85
+ return self._passed.wait_for(lambda: self._passes > before, timeout)
86
+
87
+ def _run(self) -> None:
88
+ while not self._stop.is_set():
89
+ self._wake.clear()
90
+ again = False
91
+ try:
92
+ again = self._pass()
93
+ self._said = self.alone = self.alone_code = ""
94
+ except _Alone as alone:
95
+ self._alone(str(alone))
96
+ except Unreachable as error:
97
+ self._alone(f"the library cannot be reached: {error}", code="unreachable")
98
+ except WrongCertificate as error:
99
+ self._alone(str(error), error=True, code="certificate")
100
+ except HttpError as error:
101
+ self._alone(f"the library answered {error}", error=error.status != 410, code="answered")
102
+ except Exception as error:
103
+ self._box.unexpected(error)
104
+ with self._passed:
105
+ self._passes += 1
106
+ self._passed.notify_all()
107
+ if again:
108
+ continue
109
+ # without a connection, and while something waits to be settled, the box tries again by itself
110
+ pending = not self.connected or bool(self._data.outbox()) or bool(self._data.journal_lines(1))
111
+ self._wake.wait(self._box.retry_seconds if pending else IDLE_SECONDS)
112
+
113
+ def _alone(self, reason: str, error: bool = False, code: str = "") -> None:
114
+ self.connected = False
115
+ self._checked = False
116
+ self._unheard = False
117
+ self.alone = reason
118
+ self.alone_code = code
119
+ if reason != self._said: # said once, not with every attempt
120
+ self._logger.log(40 if error else 20, "working without the library: %s", reason)
121
+ self._said = reason
122
+
123
+ def _listen(self) -> None:
124
+ """Keep a connection open on which the library announces changes (section 4.9)."""
125
+ while not self._stop.is_set():
126
+ client = self.client
127
+ if client is not None and self.connected and self._box.fit == FITS:
128
+ self._listening = client
129
+ with contextlib.suppress(Unreachable, WrongCertificate, HttpError):
130
+ for event, _ in client.events(f"{V}/events"):
131
+ if self._stop.is_set():
132
+ return
133
+ # "connected" comes first: what changed while nobody listened is caught up by a pass
134
+ self._logger.log(10 if event == "connected" else 20, "event of the library: %s", event)
135
+ if event == "versions":
136
+ self._checked = False
137
+ self._wake.set()
138
+ self._wake.set() # the connection broke: the pass finds out whether the library is gone
139
+ self._stop.wait(self._box.retry_seconds)
140
+ else:
141
+ self._stop.wait(0.2)
142
+
143
+ # --- identity and access (sections 4.7, 4.14) --------------------------------------------------------------------
144
+
145
+ def _read(self, name: str) -> str:
146
+ try:
147
+ with open(os.path.join(self._identity, name), encoding="utf-8") as infile:
148
+ return infile.read().strip()
149
+ except FileNotFoundError:
150
+ return ""
151
+
152
+ def _connect(self) -> Client:
153
+ url = self._read("library")
154
+ if not url:
155
+ raise _Alone("no library is configured")
156
+ if not self._read("key"):
157
+ self._handshake(url)
158
+ headers = {
159
+ "Authorization": f"Box {self._data.box_id}:{self._read('key')}",
160
+ "X-Box-Version": self._box.version,
161
+ "X-Box-Interface": str(INTERFACE),
162
+ }
163
+ if self._box.page_port: # where the page of the library links to the page of this box
164
+ headers["X-Box-Page"] = str(self._box.page_port)
165
+ with open(os.path.join(self._identity, "library.crt"), encoding="utf-8") as infile:
166
+ return Client(url, certificate=infile.read(), headers=headers)
167
+
168
+ def _handshake(self, url: str) -> None:
169
+ """Prove the bootstrap password to the library and get a key of its own; check and store its certificate."""
170
+ password = self._read("bootstrap")
171
+ if not password:
172
+ raise _Alone("this box has no key for the library and no bootstrap password")
173
+ box_id, nonce = self._data.box_id, handshake.new_nonce()
174
+ first = Client(url)
175
+ nonce_library = first.post("/api/sync/boxes", {"box": box_id, "nonce": nonce})["nonce"]
176
+ certificate = first.peer_certificate or ""
177
+ proof = handshake.proof(password, "box", box_id, nonce, nonce_library, certificate)
178
+ try:
179
+ answer = Client(url, certificate=certificate).post(
180
+ "/api/sync/boxes", {"box": box_id, "proof": proof, "name": socket.gethostname()}
181
+ )
182
+ except HttpError as error:
183
+ if error.status == 401:
184
+ raise WrongCertificate(
185
+ "the handshake was rejected: wrong bootstrap password, or not the library"
186
+ ) from None
187
+ raise
188
+ expected = handshake.proof(password, "library", box_id, nonce, nonce_library, certificate)
189
+ if not handshake.same(str(answer.get("proof")), expected):
190
+ raise WrongCertificate("the handshake failed: the other side does not know the bootstrap password")
191
+ os.makedirs(self._identity, mode=0o700, exist_ok=True)
192
+ write_atomic(os.path.join(self._identity, "library.crt"), certificate)
193
+ key = os.path.join(self._identity, "key")
194
+ write_atomic(key, answer["key"] + "\n")
195
+ os.chmod(key, 0o600)
196
+ os.remove(os.path.join(self._identity, "bootstrap")) # this box does not need the password any more
197
+ self._logger.info(
198
+ "handshake with the library done: this box has its own key and the certificate of the library"
199
+ )
200
+
201
+ def _ask_interface(self) -> list[int]:
202
+ """Which versions of the interface the library serves. Repeats the handshake if the library refuses the key
203
+ or shows another certificate - which works only on a box that was given the bootstrap password again."""
204
+ try:
205
+ self.client = self._connect()
206
+ return list(self.client.get("/api/sync/interface")["serves"])
207
+ except (WrongCertificate, HttpError) as error:
208
+ if isinstance(error, HttpError) and error.status != 401:
209
+ raise
210
+ if not self._read("bootstrap"):
211
+ if isinstance(error, HttpError):
212
+ raise WrongCertificate("the library rejects the key of this box") from None
213
+ raise
214
+ self._handshake(self._read("library"))
215
+ self.client = self._connect()
216
+ return list(self.client.get("/api/sync/interface")["serves"])
217
+
218
+ # --- one pass ----------------------------------------------------------------------------------------------------
219
+
220
+ def _pass(self) -> bool:
221
+ """One pass; returns whether another one should follow at once."""
222
+ serves = self._ask_interface()
223
+ client = self.client
224
+ assert client is not None # noqa: S101
225
+ if not self.connected:
226
+ self._logger.info("connected to the library")
227
+ self.connected = True
228
+ if max(serves) < INTERFACE:
229
+ # only a person can repair this: the box stops working (section 4.15)
230
+ self._box.set_fit(f"library-too-old: this box speaks version {INTERFACE}, the library serves {serves}")
231
+ return False
232
+ if min(serves) > INTERFACE:
233
+ # the box repairs this itself: it works alone and fetches the newest version
234
+ self._box.set_fit(f"box-too-old: this box speaks version {INTERFACE}, the library serves {serves}")
235
+ self._look_for_versions()
236
+ return False
237
+ self._box.set_fit(FITS)
238
+ self._look_for_versions()
239
+ self._box.discard_defective()
240
+ done = self._push(client)
241
+ again = self._pull(client)
242
+ if again is None:
243
+ return True
244
+ # an entry leaves the outbox only now: the copy has what the library made of it
245
+ self._data.confirm(done)
246
+ again = self._settle(client) or again
247
+ self._deliver_journal(client)
248
+ self._deliver_diagnostics(client)
249
+ self._report(client)
250
+ return again
251
+
252
+ def _look_for_versions(self) -> None:
253
+ if not self._checked:
254
+ self._checked = True
255
+ self._box.look_for_versions()
256
+
257
+ def _push(self, client: Client) -> set[int]:
258
+ """Send the outbox in order; returns the numbers that the library answered (sections 4.4, 4.5)."""
259
+ done: set[int] = set()
260
+ outbox = self._data.outbox()
261
+ for start in range(0, len(outbox), BATCH):
262
+ entries = self._box.files.described(outbox[start : start + BATCH])
263
+ results = client.post(f"{V}/boxes/{self._data.box_id}/changes", {"entries": entries})["results"]
264
+ self._answered(entries, results, done)
265
+ return done
266
+
267
+ def _answered(self, entries: list[dict[str, Any]], results: list[dict[str, Any]], done: set[int]) -> None:
268
+ for entry, result in zip(entries, results, strict=True):
269
+ kind = result["result"]
270
+ if kind == "waiting":
271
+ continue # depends on an album whose files are not all in the library yet
272
+ done.add(entry["n"])
273
+ if kind == "applied" and entry["op"] == "create":
274
+ self._box.learn(entry["key"], result["id"], result.get("value"))
275
+ elif kind == "conflict" and entry["kind"] in COPY:
276
+ clock = entry["clock"]
277
+ self._logger.warning(
278
+ "conflict: this box changed %s, the library wins. The box wanted %s, the library has %s. Changed "
279
+ "by %s at %s (clock %s)",
280
+ result["what"], result["wanted"], result["library"], entry["by"], clock["time"],
281
+ "synchronized" if clock["synchronized"] else "not synchronized",
282
+ ) # fmt: skip
283
+ self._data.journal("conflict", what=result["what"], wanted=result["wanted"], library=result["library"])
284
+ elif kind == "conflict":
285
+ self._logger.info(
286
+ "%s was changed in the library, its value %s stays", result["what"], result["library"]
287
+ )
288
+ elif kind == "rejected":
289
+ self._logger.error("the library rejected the change %s (%s): %s", entry["n"], result["reason"], entry)
290
+ self._data.journal("rejected", entry=entry, reason=result["reason"])
291
+
292
+ def _pull(self, client: Client) -> bool | None:
293
+ """Compare the checksums of the copy with the library and fetch every file that differs, whole (4.3).
294
+ Returns whether another pass should follow, None if this one could not finish."""
295
+ for name, info in client.get(f"{V}/catalog")["files"].items():
296
+ if name not in CATALOG_FILES or self._data.checksum(name) == info["sha256"]:
297
+ continue
298
+ data = client.request("GET", f"{V}/catalog/{name}").body
299
+ if sha256_bytes(data) != info["sha256"]:
300
+ return None # the file changed in between: once more
301
+ for target in self._data.replace_copy(name, data).removed:
302
+ self._box.target_removed(target)
303
+ self._logger.info("catalog file %s fetched", name)
304
+ state = client.request("GET", f"{V}/boxes/{self._data.box_id}/state").body
305
+ again = False
306
+ if sha256_bytes(state) != self._data.checksum(STATE):
307
+ for kind, key, value, revision in self._data.replace_copy(STATE, state).lost:
308
+ self._logger.warning("the library lost the %s %s of this box: sent again", kind, key)
309
+ self._data.add(kind, key, value, base=revision)
310
+ again = True
311
+ return again
312
+
313
+ def _settle(self, client: Client) -> bool:
314
+ """Ask for the final id of every provisional id that still occurs here, and send the files the library lacks
315
+ (sections 4.6, 4.8). The question for the id doubles as the question for completeness."""
316
+ again = False
317
+ for provisional in self._data.provisional_ids():
318
+ try:
319
+ answer = client.get(f"{V}/boxes/{self._data.box_id}/ids/target/{provisional}")
320
+ except HttpError as error:
321
+ if error.status == 404:
322
+ continue
323
+ raise
324
+ if "id" in answer:
325
+ self._box.learn(provisional, answer["id"], answer.get("value"))
326
+ again = True
327
+ elif answer.get("missing"):
328
+ self._box.files.send(client, provisional, answer["missing"])
329
+ again = True
330
+ again = self._box.files.balance(client) or again
331
+ return self._box.images.balance(client) or again
332
+
333
+ def statistics(self) -> dict[str, Any]:
334
+ """What the library counted from the journal of this box (section 7): asked when the page of the box shows
335
+ it. The library keeps the journal, the box deletes what was confirmed."""
336
+ client = self.client
337
+ if client is None or not self.connected:
338
+ raise Unreachable("the library cannot be reached")
339
+ return dict(client.get(f"{V}/boxes/{self._data.box_id}/statistics"))
340
+
341
+ def _deliver_journal(self, client: Client) -> None:
342
+ """Send the journal in batches; the library confirms up to which number it has the lines (section 7)."""
343
+ while lines := self._data.journal_lines():
344
+ confirmed = client.post(f"{V}/boxes/{self._data.box_id}/journal", {"lines": lines})["confirmed"]
345
+ self._data.journal_confirmed(confirmed)
346
+ if confirmed < lines[0]["n"] - 1:
347
+ # the library waits for lines this box does not have: they are lost (section 4.21)
348
+ self._data.journal_gap(confirmed)
349
+ continue
350
+ if confirmed < lines[-1]["n"]:
351
+ break
352
+
353
+ def _deliver_diagnostics(self, client: Client) -> None:
354
+ """Send what the box collected of its log (section 4.22), in batches. A library before 0.41 does not know the
355
+ request: the records wait, bounded, until it does."""
356
+ collector = self._box.diagnostics
357
+ while collector is not None and not self._unheard:
358
+ records, dropped = collector.take()
359
+ if not records and not dropped:
360
+ return
361
+ envelope = {"boot": self._data.numbers["start"], "version": self._box.version, "records": records,
362
+ "dropped": dropped} # fmt: skip
363
+ try:
364
+ client.post(f"{V}/boxes/{self._data.box_id}/diagnostics", envelope)
365
+ except HttpError as error:
366
+ if error.status != 404:
367
+ raise
368
+ self._unheard = True
369
+ return
370
+ collector.delivered(records[-1]["seq"] if records else 0, dropped)
371
+
372
+ def _report(self, client: Client) -> None:
373
+ """Tell the library what runs here, and what the box did to its versions since the last report."""
374
+ report = self._box.status_report()
375
+ if report == self._reported:
376
+ return
377
+ answer = client.put(f"{V}/boxes/{self._data.box_id}/status", report)
378
+ self._box.reported(answer)
379
+ self._reported = {**report, "history": [], "left": [], "defective": []}
@@ -0,0 +1,162 @@
1
+ """The manifest of an album (docs/developer/library.md, section 4.8).
2
+
3
+ A manifest lists the files of the album folder with size and checksum, and the tracks in the order of the playlist.
4
+ Each side compares it with the files it has; a file is complete only if its checksum fits.
5
+
6
+ {"files": [{"name": "01.mp3", "size": 123, "sha256": "..."}, ...], "order": ["01.mp3", ...]}
7
+
8
+ Names are relative to the album folder, with ``/`` between folders. Files that are not in ``order`` - the playlist
9
+ itself, an image - are fetched before the tracks.
10
+ """
11
+
12
+ import hashlib
13
+ import os
14
+ from typing import Any
15
+
16
+ from tinyjukebox.data.target import read_playlist
17
+ from tinyjukebox.sync.util import sha256_file
18
+
19
+ __all__ = [
20
+ "checksum",
21
+ "create",
22
+ "extras",
23
+ "identity",
24
+ "inside",
25
+ "missing",
26
+ "path",
27
+ "skipped",
28
+ "valid",
29
+ "valid_folder",
30
+ "verified",
31
+ ]
32
+
33
+ SKIPPED_SUFFIXES = (".bak", ".part", ".tmp")
34
+
35
+
36
+ def skipped(name: str) -> bool:
37
+ """Files and folders that are no part of an album: hidden ones, leftovers, and ``@eaDir`` of the NAS."""
38
+ return name.startswith((".", "@")) or name.endswith(SKIPPED_SUFFIXES)
39
+
40
+
41
+ def path(folder: str, name: str) -> str:
42
+ """The file ``name`` of a manifest in ``folder``."""
43
+ return os.path.join(folder, *name.split("/"))
44
+
45
+
46
+ def create(folder: str, playlist: str) -> dict[str, Any]:
47
+ """The manifest of the album in ``folder``. :raises OSError: if the folder or its playlist cannot be read."""
48
+ names = []
49
+ for root, folders, files in os.walk(folder):
50
+ folders[:] = sorted(name for name in folders if not skipped(name))
51
+ relative = os.path.relpath(root, folder)
52
+ for name in sorted(files):
53
+ if not skipped(name):
54
+ names.append(name if relative == "." else "/".join([*relative.split(os.sep), name]))
55
+ files_ = [
56
+ {"name": name, "size": os.path.getsize(path(folder, name)), "sha256": sha256_file(path(folder, name))}
57
+ for name in names
58
+ ]
59
+ tracks = [entry.replace(os.sep, "/") for entry in read_playlist(os.path.join(folder, playlist), playlist)]
60
+ return {"files": files_, "order": [name for name in dict.fromkeys(tracks) if name in names]}
61
+
62
+
63
+ def _valid_name(name: Any) -> bool:
64
+ return (
65
+ isinstance(name, str)
66
+ and bool(name)
67
+ and not os.path.isabs(name)
68
+ and "\\" not in name
69
+ and all(part and part != ".." and not skipped(part) for part in name.split("/"))
70
+ )
71
+
72
+
73
+ def valid_folder(name: Any) -> bool:
74
+ """Whether ``name`` can be the folder of an album (``album-folder``): relative to the albums folder, written the
75
+ way it is stored (``a/b``, no ``./`` or trailing ``/``), and below it - no ``..``, no hidden folder like the ones
76
+ of the uploads and of the albums on their way, no ``@eaDir`` of the NAS. What a box or the library delivers is
77
+ never used as a path before it passed this."""
78
+ return (
79
+ isinstance(name, str)
80
+ and 0 < len(name) <= 1000
81
+ and not os.path.isabs(name)
82
+ and "\\" not in name
83
+ and "\0" not in name
84
+ and os.path.normpath(name) == name
85
+ and all(part and part not in (".", "..") and not part.startswith((".", "@")) for part in name.split("/"))
86
+ )
87
+
88
+
89
+ def inside(root: str, folder: str) -> bool:
90
+ """Whether ``folder`` lies below ``root`` once symbolic links are resolved - and is not ``root`` itself."""
91
+ real_root, real = os.path.realpath(root), os.path.realpath(folder)
92
+ return real != real_root and os.path.commonpath([real_root, real]) == real_root
93
+
94
+
95
+ def valid(manifest: Any) -> bool:
96
+ if not isinstance(manifest, dict) or not isinstance(manifest.get("files"), list) or not manifest["files"]:
97
+ return False
98
+ names = []
99
+ for entry in manifest["files"]:
100
+ if (
101
+ not isinstance(entry, dict)
102
+ or not _valid_name(entry.get("name"))
103
+ or not isinstance(entry.get("size"), int)
104
+ or isinstance(entry.get("size"), bool)
105
+ or not isinstance(entry.get("sha256"), str)
106
+ ):
107
+ return False
108
+ names.append(entry["name"])
109
+ order = manifest.get("order")
110
+ return (
111
+ len(set(names)) == len(names)
112
+ and isinstance(order, list)
113
+ and len(set(order)) == len(order)
114
+ and all(name in names for name in order)
115
+ )
116
+
117
+
118
+ def identity(manifest: dict[str, Any]) -> str:
119
+ """Two albums with the same identity have the same tracks: identical files, whatever their names and whatever
120
+ the playlist looks like (two rips of one record have not)."""
121
+ played = [entry for entry in manifest["files"] if entry["name"] in manifest["order"]] or manifest["files"]
122
+ checksums = sorted(entry["sha256"] for entry in played)
123
+ return hashlib.sha256("\n".join(checksums).encode()).hexdigest()
124
+
125
+
126
+ def checksum(manifest: dict[str, Any]) -> str:
127
+ """Changes whenever a file of the album or the order of its tracks changes."""
128
+ lines = sorted(f"{entry['sha256']} {entry['name']}" for entry in manifest["files"])
129
+ return hashlib.sha256("\n".join([*lines, "", *manifest["order"]]).encode()).hexdigest()
130
+
131
+
132
+ def extras(manifest: dict[str, Any]) -> list[str]:
133
+ """The files that are not played: the playlist, an image. They are small and needed before the first track."""
134
+ return [entry["name"] for entry in manifest["files"] if entry["name"] not in manifest["order"]]
135
+
136
+
137
+ def verified(folder: str, entry: dict[str, Any]) -> bool:
138
+ """Whether the file of ``entry`` is in ``folder`` with the right size and checksum."""
139
+ filename = path(folder, entry["name"])
140
+ return (
141
+ os.path.isfile(filename)
142
+ and os.path.getsize(filename) == entry["size"]
143
+ and sha256_file(filename) == entry["sha256"]
144
+ )
145
+
146
+
147
+ def missing(folder: str, manifest: dict[str, Any], thorough: bool = False) -> list[str]:
148
+ """Names of the files of the manifest that ``folder`` lacks: first the files that are not played, then the
149
+ tracks in the order of the playlist.
150
+
151
+ Files are renamed into the folder only after their checksum was verified, so by default name and size are enough;
152
+ ``thorough`` computes the checksums again.
153
+ """
154
+ entries = {entry["name"]: entry for entry in manifest["files"]}
155
+ result = []
156
+ for name in [*extras(manifest), *manifest["order"]]:
157
+ entry = entries[name]
158
+ filename = path(folder, name)
159
+ present = os.path.isfile(filename) and os.path.getsize(filename) == entry["size"]
160
+ if not present or (thorough and sha256_file(filename) != entry["sha256"]):
161
+ result.append(name)
162
+ return result
@@ -0,0 +1,137 @@
1
+ """Helpers of box and library: files written in one step, checksums, versions, time."""
2
+
3
+ import base64
4
+ import ctypes
5
+ import datetime
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import ssl
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "clock_synchronized",
17
+ "public_key_pin",
18
+ "read_json",
19
+ "sha256_bytes",
20
+ "sha256_file",
21
+ "timestamp",
22
+ "version_key",
23
+ "write_atomic",
24
+ "write_json",
25
+ ]
26
+
27
+ _clock: tuple[float, bool] | None = None # when the clock was asked last, and the answer
28
+
29
+
30
+ def sha256_bytes(data: bytes) -> str:
31
+ return hashlib.sha256(data).hexdigest()
32
+
33
+
34
+ def _der_element(data: bytes, at: int) -> tuple[int, int]:
35
+ """Where the element of DER at ``at`` ends, and where its content begins."""
36
+ length, start = data[at + 1], at + 2
37
+ if length & 0x80:
38
+ count = length & 0x7F
39
+ length, start = int.from_bytes(data[start : start + count], "big"), start + count
40
+ return start + length, start
41
+
42
+
43
+ def public_key_pin(certificate: str) -> str:
44
+ """The pin of the public key of a certificate (PEM), as ``curl --pinnedpubkey`` takes it: ``sha256//`` and the
45
+ SHA-256 of the SubjectPublicKeyInfo in Base64. Read from the DER of the certificate: tbsCertificate is its first
46
+ element, and the public key the seventh of that - after version, serial number, signature, issuer, validity and
47
+ subject (RFC 5280); the version is the only one that may be missing."""
48
+ der = ssl.PEM_cert_to_DER_cert(certificate)
49
+ _, tbs = _der_element(der, 0) # the content of Certificate: tbsCertificate first
50
+ _, at = _der_element(der, tbs)
51
+ fields = 6 if der[at] == 0xA0 else 5 # [0] version is there
52
+ for _ in range(fields):
53
+ at, _ = _der_element(der, at)
54
+ end, _ = _der_element(der, at)
55
+ return "sha256//" + base64.b64encode(hashlib.sha256(der[at:end]).digest()).decode()
56
+
57
+
58
+ def sha256_file(filename: str) -> str:
59
+ digest = hashlib.sha256()
60
+ with open(filename, "rb") as infile:
61
+ while chunk := infile.read(1 << 20):
62
+ digest.update(chunk)
63
+ return digest.hexdigest()
64
+
65
+
66
+ def write_atomic(filename: str, data: bytes | str) -> None:
67
+ """Write under a temporary name and rename: a power cut leaves the old or the new file, never half of one."""
68
+ os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
69
+ temporary = f"{filename}.tmp"
70
+ with open(temporary, "wb") as outfile:
71
+ outfile.write(data.encode("utf-8") if isinstance(data, str) else data)
72
+ outfile.flush()
73
+ os.fsync(outfile.fileno())
74
+ os.replace(temporary, filename)
75
+
76
+
77
+ def read_json(filename: str, default: Any = None) -> Any:
78
+ try:
79
+ with open(filename, encoding="utf-8") as infile:
80
+ return json.load(infile)
81
+ except FileNotFoundError:
82
+ return default
83
+
84
+
85
+ def write_json(filename: str, value: Any) -> None:
86
+ write_atomic(filename, json.dumps(value, indent=1, sort_keys=True, ensure_ascii=False) + "\n")
87
+
88
+
89
+ def version_key(version: str) -> tuple[int, ...]:
90
+ """Sort key of a version like ``0.8.0``."""
91
+ return tuple(int(part) for part in version.split("."))
92
+
93
+
94
+ def timestamp() -> str:
95
+ return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
96
+
97
+
98
+ TIMESYNC = "/run/systemd/timesync/synchronized" # systemd-timesyncd creates it when it has set the clock
99
+ MAX_ERROR = 16_000_000
100
+ """Microseconds: a kernel that reports a larger maximum error of its clock has not been set from the network - the rule
101
+ of systemd (NTPSynchronized), also for chrony, ntpd, and in a container, which has the clock of its host."""
102
+
103
+
104
+ def _kernel_max_error() -> int | None:
105
+ """The maximum error of the clock in microseconds as the kernel knows it (adjtimex without a change), None if it
106
+ cannot be asked - only on Linux with 64-bit ``long``, where ``maxerror`` is the fourth field of ``struct
107
+ timex``."""
108
+ if sys.platform != "linux" or ctypes.sizeof(ctypes.c_long) != 8:
109
+ return None
110
+ try:
111
+ timex = ctypes.create_string_buffer(512) # struct timex has 208 bytes; modes 0: nothing is changed
112
+ if ctypes.CDLL(None, use_errno=True).adjtimex(timex) < 0:
113
+ return None
114
+ except (OSError, AttributeError):
115
+ return None
116
+ return int.from_bytes(timex.raw[24:32], sys.byteorder, signed=True)
117
+
118
+
119
+ def clock_synchronized() -> bool:
120
+ """Whether the clock of this computer was set from the network. The Pi has no real-time clock: without WLAN its
121
+ time is wrong, and order is never decided by it."""
122
+ global _clock # noqa: PLW0603 - asked once a minute at most
123
+ if os.path.exists(TIMESYNC):
124
+ return True
125
+ max_error = _kernel_max_error()
126
+ if max_error is not None:
127
+ return 0 <= max_error < MAX_ERROR
128
+ if _clock is None or time.monotonic() - _clock[0] > 60:
129
+ try:
130
+ answer = subprocess.run(
131
+ ["timedatectl", "show", "--property=NTPSynchronized", "--value"],
132
+ capture_output=True, text=True, timeout=5, check=False,
133
+ ).stdout.strip() # fmt: skip
134
+ except (OSError, subprocess.SubprocessError):
135
+ answer = ""
136
+ _clock = (time.monotonic(), answer == "yes")
137
+ return _clock[1]