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,486 @@
1
+ """A box that works with a library (docs/developer/library.md): what it stores, the connection, the files.
2
+
3
+ ``BoxSync`` connects the synchronization with the data of the box (``tinyjukebox.data``):
4
+
5
+ - every change of tags, targets, activities and volume limits - whoever made it - is recorded in
6
+ the outbox,
7
+ - what the library delivers is put into the same collections, and the tag on the box is evaluated again if it is
8
+ concerned.
9
+
10
+ A box works with a library as soon as the address of one was entered (file ``library`` in the folder ``identity``).
11
+ From then on the json files are generated from what the library delivered and the changes of the box. A box without
12
+ a library is not touched: its json files stay what they are. A box that is unlinked from its library
13
+ (:meth:`BoxSync.unlink`) is such a box again, with everything it has on its card.
14
+ """
15
+
16
+ import contextlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import secrets
21
+ import socket
22
+ import threading
23
+ from collections.abc import Callable
24
+ from typing import Any, Protocol
25
+
26
+ from tinyjukebox.core import unexpected
27
+ from tinyjukebox.data import degraded
28
+ from tinyjukebox.data.activities import Activities
29
+ from tinyjukebox.data.tags import Tags
30
+ from tinyjukebox.data.targets import LAST_ID_SUFFIX, Targets
31
+ from tinyjukebox.data.volumelimits import VolumeLimits
32
+ from tinyjukebox.diagnostics import Collector
33
+ from tinyjukebox.images import Images
34
+ from tinyjukebox.slots import Health, Slots
35
+ from tinyjukebox.sync import INTERFACE, acting_as
36
+ from tinyjukebox.sync.data import BoxData
37
+ from tinyjukebox.sync.files import AlbumFiles, ImageFiles
38
+ from tinyjukebox.sync.link import FITS, Link
39
+ from tinyjukebox.sync.util import clock_synchronized, read_json, write_atomic, write_json
40
+
41
+ __all__ = ["MIGRATION", "BoxSync", "Playback"]
42
+
43
+ MIGRATION = "migration" # who made the changes that stand for what a box had before it met a library
44
+ VOLUME = "volume." # settings of a box: the volume limits of a profile, e.g. "volume.speaker"
45
+ ALIVE_SECONDS = 600.0 # a journal line this often: switching the box off leaves none (section 7)
46
+
47
+
48
+ class Playback(Protocol):
49
+ """What the synchronization needs of :class:`~tinyjukebox.stm.stmoperation.STMOperation`."""
50
+
51
+ def get_current_uid(self) -> str | None: ...
52
+
53
+ def get_album(self) -> Any: ...
54
+
55
+ def recheck_tag(self) -> None: ...
56
+
57
+
58
+ def _why_left(history: list[dict[str, Any]], version: str) -> str:
59
+ """Why the box went back from ``version``: the last switch away from it, with its details - e.g. "health check
60
+ failed: stage 1 of the health check not passed within 120 s". Empty if the list of switches does not say."""
61
+ for line in reversed(history):
62
+ if str(line.get("from", "")).endswith(f":{version}") and not str(line.get("to", "")).endswith(f":{version}"):
63
+ return ": ".join(part for part in (line.get("why"), line.get("details")) if part)
64
+ return ""
65
+
66
+
67
+ class BoxSync:
68
+ def __init__(
69
+ self,
70
+ folder: str,
71
+ identity: str,
72
+ version: str,
73
+ logger: logging.Logger,
74
+ albums_folder: str,
75
+ images_folder: str,
76
+ retry_seconds: float = 30.0,
77
+ keep_free: int = 0,
78
+ ) -> None:
79
+ """``folder``: copy, outbox and journal; ``identity``: what identifies this box - its id, its key, address
80
+ and certificate of the library. No version of the box software overwrites ``identity``."""
81
+ self.logger = logger
82
+ self.identity = identity
83
+ self.version = version
84
+ self.retry_seconds = retry_seconds
85
+ self.fit = FITS
86
+ self.page_port: int | None = None # the port of the page of this box, for its link on the page of the library
87
+ self._folder = folder
88
+ self.data = BoxData(folder, self._box_id(), version, logger, clock_synchronized, self._working_changed)
89
+ self.link = Link(self)
90
+ self._stopped = threading.Event()
91
+ self.files = AlbumFiles(self, albums_folder, os.path.join(folder, "manifests"), keep_free)
92
+ self.images = ImageFiles(self, images_folder)
93
+ self._tags: Tags | None = None
94
+ self._targets: Targets | None = None
95
+ self._activities: Activities | None = None
96
+ self._limits: VolumeLimits | None = None
97
+ self._playback: Playback | None = None
98
+ self._positions: dict[str, Any] = {}
99
+ self._applying = threading.Lock()
100
+ self.slots: Slots | None = None # which version runs (tinyjukebox.slots) - None on a box without slots
101
+ self.health: Health | None = None
102
+ self.on_fit: Callable[[], None] | None = None # box and library fit together, or not any more
103
+ self.diagnostics: Collector | None = None # what the library gets of the log (section 4.22)
104
+ self.fit = self._stored("fit") or FITS
105
+
106
+ def _box_id(self) -> str:
107
+ """The internal id of this box: generated on the first start, never changed, typed by nobody (section 4.7)."""
108
+ filename = os.path.join(self.identity, "box-id")
109
+ try:
110
+ with open(filename, encoding="utf-8") as infile:
111
+ return infile.read().strip()
112
+ except FileNotFoundError:
113
+ box_id = "b" + secrets.token_hex(4)
114
+ os.makedirs(self.identity, mode=0o700, exist_ok=True)
115
+ write_atomic(filename, box_id + "\n")
116
+ return box_id
117
+
118
+ # --- with or without a library -----------------------------------------------------------------------------------
119
+
120
+ def _stored(self, name: str) -> str:
121
+ try:
122
+ with open(os.path.join(self._folder, name), encoding="utf-8") as infile:
123
+ return infile.read().strip()
124
+ except FileNotFoundError:
125
+ return ""
126
+
127
+ def _identity(self, name: str) -> str:
128
+ try:
129
+ with open(os.path.join(self.identity, name), encoding="utf-8") as infile:
130
+ return infile.read().strip()
131
+ except FileNotFoundError:
132
+ return ""
133
+
134
+ @property
135
+ def library(self) -> str:
136
+ """The address of the library, empty if none is configured."""
137
+ return self._identity("library")
138
+
139
+ @property
140
+ def attached(self) -> bool:
141
+ """Whether this box works with a library: its changes are recorded, its json files are generated."""
142
+ return os.path.exists(os.path.join(self._folder, "attached"))
143
+
144
+ def set_library(self, url: str, bootstrap: str | None = None) -> None:
145
+ """Enter the address of the library, and the bootstrap password for the first contact. The box works with
146
+ the library from its next start."""
147
+ os.makedirs(self.identity, mode=0o700, exist_ok=True)
148
+ write_atomic(os.path.join(self.identity, "library"), url.strip() + "\n")
149
+ if bootstrap:
150
+ filename = os.path.join(self.identity, "bootstrap")
151
+ write_atomic(filename, bootstrap.strip() + "\n")
152
+ os.chmod(filename, 0o600)
153
+ self.link.wake()
154
+
155
+ def unlink(self) -> None:
156
+ """Leave the library (section 4.20). The box keeps what is on its card - its json files are the truth again -
157
+ and forgets the library: address, key, certificate, the copy and the changes that wait. The library is not
158
+ asked; it may be switched off, or not exist any more."""
159
+ self.link.stop()
160
+ with self._applying:
161
+ if self._targets is not None:
162
+ self._targets.id_source = None # new targets get a number again
163
+ # from here on nothing is recorded, and nothing of the copy is put into the data of the box
164
+ self._tags = self._targets = self._activities = self._limits = None
165
+ with contextlib.suppress(FileNotFoundError):
166
+ os.remove(os.path.join(self._folder, "attached"))
167
+ for name in ("library", "key", "library.crt", "bootstrap"):
168
+ with contextlib.suppress(FileNotFoundError):
169
+ os.remove(os.path.join(self.identity, name))
170
+ self.data.forget_library()
171
+ self.files.forget()
172
+ self.data.journal("unlinked")
173
+ self.data.journal_limit()
174
+ self.set_fit(FITS) # a library that was too old for the box does not stop it any more
175
+ self.logger.info("unlinked from the library: this box works for itself")
176
+ self.look_for_versions() # from now on they come from the releases (section 6.16)
177
+ if self._playback is not None:
178
+ self._playback.recheck_tag()
179
+
180
+ def absent(self) -> int:
181
+ """Albums of which files are missing on this box. Only the library has them."""
182
+ if not self.attached:
183
+ return 0
184
+ return sum(1 for target in self.data.working("target") if self.files.lacking(target) != [])
185
+
186
+ def prepare(self, files: dict[str, str]) -> None:
187
+ """Called before the data of the box is loaded. ``files``: the json files of tags (``tag``), targets
188
+ (``target``), activities (``position``) and volume limits (``volume``).
189
+
190
+ With a library: on the first start everything the box has becomes a change that the library has not seen
191
+ yet; on every start the json files are generated anew from copy and outbox (section 4.2).
192
+ """
193
+ if not self.library and not self.attached:
194
+ return
195
+ if not self.attached:
196
+ self._attach(files)
197
+ limits = {
198
+ key[len(VOLUME) :]: value for key, value in self.data.working("setting").items() if key.startswith(VOLUME)
199
+ }
200
+ for kind, data in (("tag", None), ("target", None), ("position", None), ("volume", limits)):
201
+ write_json(files[kind], self.data.working(kind) if data is None else data)
202
+
203
+ def _attach(self, files: dict[str, str]) -> None:
204
+ """The first start with a library. What the box has may be unknown there, so every target gets a provisional
205
+ id - the old id with the id of the box in front - and is settled like one that was created without a
206
+ connection: the library recognizes an album or a station it has already."""
207
+ self.logger.info("first start with a library: everything on this box is recorded as a change of its own")
208
+ self.data.clear_outbox()
209
+ targets = read_json(files["target"], {})
210
+ used = [int(key) for key in targets if key.isascii() and key.isdigit()]
211
+ last = read_json(os.path.splitext(files["target"])[0] + LAST_ID_SUFFIX, {}).get("last-id", 0)
212
+ number = max([0, *used, last if isinstance(last, int) else 0])
213
+ ids = {}
214
+ for key in targets:
215
+ if not (key.isascii() and key.isdigit()):
216
+ number += 1
217
+ ids[key] = f"{self.data.box_id}-{key if key.isascii() and key.isdigit() else number}"
218
+ self.data.numbers["target"] = number
219
+
220
+ def mapped(target_id: Any) -> Any:
221
+ """The new id of a target; a link to a target that does not exist is no link."""
222
+ return ids.get(str(target_id), -1)
223
+
224
+ for key, value in targets.items():
225
+ self.data.add("target", ids[key], {**value, "target-id": ids[key]}, by=MIGRATION)
226
+ for key, tag in read_json(files["tag"], {}).items():
227
+ target = tag.get("target", {})
228
+ linked = {**tag, "target": {**target, "id": mapped(target["id"])}} if "id" in target else tag
229
+ self.data.add("tag", key, linked, by=MIGRATION)
230
+ for key, value in read_json(files["position"], {}).items():
231
+ if str(value["target-id"]) in ids:
232
+ self.data.add("position", key, {**value, "target-id": ids[str(value["target-id"])]}, by=MIGRATION)
233
+ for key, value in read_json(files["volume"], {}).items():
234
+ self.data.add("setting", VOLUME + key, value, by=MIGRATION)
235
+ # only now the box counts as attached: a start that was interrupted above begins anew
236
+ write_atomic(os.path.join(self._folder, "attached"), self.data.box_id + "\n")
237
+
238
+ def bind(
239
+ self,
240
+ tags: Tags,
241
+ targets: Targets,
242
+ activities: Activities,
243
+ images: Images,
244
+ limits: VolumeLimits,
245
+ playback: Playback | None,
246
+ ) -> None:
247
+ """Connect the data of the box: from now on its changes are recorded, and what the library delivers is put
248
+ into it."""
249
+ self._playback = playback
250
+ if not self.attached:
251
+ return
252
+ self._tags, self._targets, self._activities = tags, targets, activities
253
+ self._limits = limits
254
+ self._positions = dict(self.data.working("position"))
255
+ targets.id_source = self.data.new_target_id
256
+ tags.listen(lambda key, value: self._local("tag", key, value))
257
+ targets.listen(lambda key, value: self._local("target", key, value))
258
+ activities.listen(lambda key, value: self._local("position", key, value))
259
+ images.listen(self.link.wake)
260
+ limits.listen(lambda key, value: self._local("setting", VOLUME + key, value))
261
+
262
+ def start(self) -> None:
263
+ if self.attached:
264
+ self.link.start()
265
+ self._stopped.clear()
266
+ threading.Thread(target=self._alive, name="Box.alive", daemon=True).start()
267
+ else:
268
+ self.data.journal_limit()
269
+
270
+ def stop(self) -> None:
271
+ self._stopped.set()
272
+ self.link.stop()
273
+
274
+ def _alive(self) -> None:
275
+ """The last line of a start tells the library how long the box ran: a line from time to time, so that the
276
+ hours after the last tag count too."""
277
+ while not self._stopped.wait(ALIVE_SECONDS):
278
+ if self.attached: # not unlinked since the start
279
+ self.journal("alive")
280
+
281
+ # --- changes made on this box ------------------------------------------------------------------------------------
282
+
283
+ def _local(self, kind: str, key: str, value: Any) -> None:
284
+ """A change of the data of the box: into the outbox. It took effect already, with or without a connection."""
285
+ if not self.attached:
286
+ return # unlinked since the start
287
+ tell = True
288
+ if kind == "position":
289
+ # the position moves on all the time; the library hears of it with a change of track, a pause or the end
290
+ before, self._positions[key] = self._positions.get(key), value
291
+ tell = (
292
+ before is None
293
+ or value is None
294
+ or any(before.get(field) != value.get(field) for field in ("target-id", "track-id", "album-finished"))
295
+ )
296
+ self.data.add(kind, key, value)
297
+ if tell:
298
+ self.link.wake()
299
+
300
+ def journal(self, event: str, **fields: Any) -> None:
301
+ """Note what happened (section 7). Without a library the journal is kept short, like a log file."""
302
+ self.data.journal(event, **fields)
303
+ if not self.attached:
304
+ self.data.journal_limit()
305
+ elif event == "tag" and fields.get("kind") == "album":
306
+ self.files.played(str(fields.get("target"))) # the album played last is the last to make room
307
+
308
+ def tell(self) -> None:
309
+ """Something worth telling the library happened, e.g. the playback paused."""
310
+ if self.attached:
311
+ self.link.wake()
312
+
313
+ def name(self) -> str | None:
314
+ """The name of this box for people - a box gets one when it works with a library."""
315
+ return self.data.working("setting").get("name") if self.attached else None
316
+
317
+ def set_name(self, name: str) -> None:
318
+ self.data.add("setting", "name", name)
319
+ self.link.wake()
320
+
321
+ def album_changed(self, album: str) -> None:
322
+ """The files of an album changed on this box, e.g. its playlist was written anew."""
323
+ if self.attached:
324
+ self.files.changed(album)
325
+ self.link.wake()
326
+
327
+ def lookup(self, timeout: float = 3.0) -> None:
328
+ """An unknown tag: compare the catalog with the library before it is taken for a new one (section 5)."""
329
+ if self.attached and self.library:
330
+ self.link.wait_pass(timeout)
331
+
332
+ # --- what the library delivered ----------------------------------------------------------------------------------
333
+
334
+ def _signature(self) -> Any:
335
+ """What the tag on the box plays: if this changes, the tag is evaluated again."""
336
+ uid = self._playback.get_current_uid() if self._playback is not None else None
337
+ if uid is None or self._tags is None or self._targets is None:
338
+ return None
339
+ tag = self._tags.tags.get(uid)
340
+ if tag is None:
341
+ return (uid,)
342
+ target = self._targets.targets.get(str(tag.target_id))
343
+ described = {k: v for k, v in target.to_dict().items() if k != "target-id"} if target is not None else None
344
+ return (uid, tag.target_type, self.data.final(str(tag.target_id)), described)
345
+
346
+ def _working_changed(self) -> None:
347
+ """Copy or outbox changed: put the working data into the collections of the box."""
348
+ if self._tags is None or self._targets is None or self._activities is None:
349
+ return
350
+ with self._applying, acting_as("library"):
351
+ before = self._signature()
352
+ self._targets.sync(lambda: self.data.working("target"), self.data.renamed())
353
+ self._tags.sync(lambda: self.data.working("tag"))
354
+ self._activities.sync(lambda: self.data.working("position"))
355
+ self._positions = dict(self.data.working("position"))
356
+ settings = self.data.working("setting")
357
+ if self._limits is not None:
358
+ self._limits.sync({k[len(VOLUME) :]: v for k, v in settings.items() if k.startswith(VOLUME)})
359
+ if self._playback is not None and before != self._signature():
360
+ # e.g. the tag on the box was linked on the page of the library: it plays at once
361
+ self._playback.recheck_tag()
362
+
363
+ # --- what the connection asks ------------------------------------------------------------------------------------
364
+
365
+ def unexpected(self, error: BaseException) -> None:
366
+ """The central place for an error nobody expected in the threads of the synchronization."""
367
+ self.logger.error("unexpected exception in the synchronization: %r", error, exc_info=error)
368
+ unexpected.report(error, "synchronization")
369
+
370
+ def set_fit(self, fit: str) -> None:
371
+ """Whether box and library fit together (section 4.15)."""
372
+ if fit == self.fit:
373
+ return
374
+ self.fit = fit
375
+ write_atomic(os.path.join(self._folder, "fit"), fit + "\n") # the state survives a restart
376
+ if self.slots is not None and self.slots.available:
377
+ self.slots.run("_fit", fit) # "tinyjukebox-slot status" shows it
378
+ if self.on_fit is not None:
379
+ self.on_fit()
380
+ if self.blocked:
381
+ self.logger.error("this box stops working: %s", fit)
382
+ else:
383
+ self.logger.info("box and library: %s", fit)
384
+ self.data.journal("fit", state=fit)
385
+
386
+ @property
387
+ def blocked(self) -> bool:
388
+ return self.fit.startswith("library-too-old")
389
+
390
+ def playing(self, target_id: str) -> bool:
391
+ album = self._playback.get_album() if self._playback is not None else None
392
+ if album is None:
393
+ return False
394
+ playing = str(album.get_info().get("target_id"))
395
+ return playing in (target_id, self.data.final(target_id))
396
+
397
+ def learn(self, provisional: str, final: str, value: dict[str, Any] | None) -> bool:
398
+ """Apply the final id of a target. Returns False if this has to wait: the album is playing and its folder
399
+ has another name in the library."""
400
+ created = [e["value"] for e in self.data.outbox() if e["op"] == "create" and e["key"] == provisional]
401
+ moves = bool(created and value and created[0].get("album-folder") != value.get("album-folder"))
402
+ if moves and self.playing(provisional):
403
+ return False
404
+ self.files.renamed(provisional, final, value)
405
+ self.data.learn(provisional, final, value)
406
+ self.logger.info("the provisional id %s is %s", provisional, final)
407
+ return True
408
+
409
+ def target_removed(self, value: dict[str, Any]) -> None:
410
+ if not self.playing(str(value.get("target-id"))):
411
+ self.files.removed(value)
412
+
413
+ def look_for_versions(self) -> None:
414
+ """The versions of the box software may have changed in the library."""
415
+ if self.health is not None:
416
+ self.health.look_for_versions()
417
+
418
+ def _slot_status(self) -> dict[str, Any]:
419
+ return self.slots.status() if self.slots is not None and self.slots.available else {}
420
+
421
+ def discard_defective(self) -> None:
422
+ """Do not trust the data of a version that the health check sent back (section 4.11): what it left in the
423
+ outbox is dropped. What a person entered with it is logged with its full content, not applied."""
424
+ for version in self._slot_status().get("discard", []):
425
+ for entry in self.data.discard_version(version):
426
+ self.logger.warning(
427
+ "entered by %s with the defective version %s, not applied: %s",
428
+ entry["by"], version, json.dumps(entry),
429
+ ) # fmt: skip
430
+ self.data.journal("discarded", entry=entry)
431
+
432
+ def status_report(self) -> dict[str, Any]:
433
+ """What the box tells the library with every contact: what runs here, and what the box did to its versions
434
+ since the last report."""
435
+ report: dict[str, Any] = {
436
+ "version": self.version, "interface": INTERFACE, "host": socket.gethostname(), "fit": self.fit,
437
+ "degraded": degraded.notes(), # what this version could not load and works without (section 4.21)
438
+ } # fmt: skip
439
+ status = self._slot_status()
440
+ if status:
441
+ acknowledged = self.data.numbers["history"]
442
+ if status["history"] and status["history"][-1]["n"] < acknowledged:
443
+ # the switches begin anew - a card set up again with the folder of the synchronization of before:
444
+ # a number from the old list would keep every new switch from the library
445
+ acknowledged = 0
446
+ report.update({
447
+ "slot": status["active"], "slots": status["slots"], "mode": status["mode"],
448
+ "newest": status["newest"], "problem": status["problem"],
449
+ "history": [line for line in status["history"] if line["n"] > acknowledged],
450
+ "left": [
451
+ {"version": entry["version"], "base": entry["rev"],
452
+ "why": entry.get("why") or _why_left(status["history"], entry["version"])}
453
+ for entry in status["versions"] if entry["origin"] == "box"
454
+ ],
455
+ "defective": status["discard"],
456
+ }) # fmt: skip
457
+ return report
458
+
459
+ def reported(self, answer: dict[str, Any]) -> None:
460
+ """The library has the report: it knows the switches, the versions this box went back from, and the versions
461
+ that are defective here."""
462
+ if self.slots is None or not self.slots.available:
463
+ return
464
+ if answer.get("left"):
465
+ self.slots.run("_reported", *answer["left"])
466
+ for version in answer.get("defective", []):
467
+ self.slots.run("_discarded", version)
468
+ self.data.history_reported(answer.get("history", self.data.numbers["history"]))
469
+
470
+ def info(self) -> dict[str, Any]:
471
+ """The state of the synchronization for the page of the box."""
472
+ return {
473
+ "box": self.data.box_id,
474
+ "name": self.name(),
475
+ "library": self.library,
476
+ "attached": self.attached,
477
+ "connected": self.link.connected,
478
+ "alone": self.link.alone,
479
+ "alone_code": self.link.alone_code,
480
+ "fit": self.fit,
481
+ "blocked": self.blocked,
482
+ "outbox": len(self.data.outbox()),
483
+ "absent": self.absent(),
484
+ "unsettled": self.data.provisional_ids(),
485
+ "fetching": self.files.fetching(),
486
+ }
@@ -0,0 +1,189 @@
1
+ """HTTP(S) client on the standard library: JSON, files that can be resumed, server-sent events.
2
+
3
+ The client accepts exactly one certificate, the one it was given (docs/developer/library.md, section 4.14);
4
+ the name in it does not matter. The counterpart is the web server of the library (``tinyjukebox.web.server``).
5
+ """
6
+
7
+ import contextlib
8
+ import http.client
9
+ import json
10
+ import os
11
+ import socket
12
+ import ssl
13
+ import urllib.parse
14
+ from collections.abc import Iterator
15
+ from typing import Any
16
+
17
+ __all__ = ["KEEP_ALIVE", "Client", "HttpError", "Response", "Unreachable", "WrongCertificate"]
18
+
19
+ KEEP_ALIVE = 5.0 # seconds without an event after which an event stream sends a comment, so that both ends notice a cut
20
+
21
+
22
+ class HttpError(Exception):
23
+ """An answer with a status of 400 or above - raised by a route, and by the client when it receives one."""
24
+
25
+ def __init__(self, status: int, message: str = "", **details: Any) -> None:
26
+ super().__init__(f"{status} {message}".strip())
27
+ self.status = status
28
+ self.message = message
29
+ self.details = details
30
+
31
+
32
+ class Unreachable(Exception): # noqa: N818 - a state of the network, not a programming error
33
+ """The other side cannot be reached, or the connection broke."""
34
+
35
+
36
+ class WrongCertificate(Exception): # noqa: N818
37
+ """The server presented another certificate than the one that is expected."""
38
+
39
+
40
+ class Response:
41
+ def __init__(self, status: int, headers: http.client.HTTPMessage, body: bytes) -> None:
42
+ self.status = status
43
+ self.headers = headers
44
+ self.body = body
45
+
46
+ def json(self) -> Any:
47
+ return json.loads(self.body) if self.body else None
48
+
49
+
50
+ class Client:
51
+ """Requests to one server. With ``certificate`` (PEM) only a server that presents exactly this one is accepted."""
52
+
53
+ def __init__(
54
+ self, url: str, certificate: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0
55
+ ) -> None:
56
+ split = urllib.parse.urlsplit(url)
57
+ self._https = split.scheme == "https"
58
+ self._host = split.hostname or "localhost"
59
+ self._port = split.port or (443 if self._https else 80)
60
+ self._prefix = split.path.rstrip("/")
61
+ self._certificate = ssl.PEM_cert_to_DER_cert(certificate) if certificate else None
62
+ self.headers = dict(headers or {})
63
+ self._timeout = timeout
64
+ self.peer_certificate: str | None = None # PEM of the certificate the server presented last
65
+ self._stream: socket.socket | None = None # the connection that events are read from
66
+
67
+ def _connect(self, timeout: float | None = None) -> http.client.HTTPConnection:
68
+ timeout = self._timeout if timeout is None else timeout
69
+ try:
70
+ if not self._https:
71
+ connection = http.client.HTTPConnection(self._host, self._port, timeout=timeout)
72
+ connection.connect()
73
+ return connection
74
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
75
+ context.check_hostname = False
76
+ context.verify_mode = ssl.CERT_NONE # checked below: equal to the one certificate that is expected
77
+ secure = http.client.HTTPSConnection(self._host, self._port, timeout=timeout, context=context)
78
+ secure.connect()
79
+ except (OSError, http.client.HTTPException) as error:
80
+ raise Unreachable(f"{self._host}:{self._port}: {error}") from None
81
+ presented = secure.sock.getpeercert(binary_form=True) or b""
82
+ self.peer_certificate = ssl.DER_cert_to_PEM_cert(presented)
83
+ if self._certificate is not None and presented != self._certificate:
84
+ secure.close()
85
+ raise WrongCertificate(f"{self._host}:{self._port} presents another certificate than the stored one")
86
+ return secure
87
+
88
+ def request(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ body: Any = None,
93
+ *,
94
+ upload: str | None = None,
95
+ sink: str | None = None,
96
+ headers: dict[str, str] | None = None,
97
+ ) -> Response:
98
+ """One request. ``body`` is sent as JSON, ``upload`` names a file to send, ``sink`` a file for the answer.
99
+
100
+ A ``sink`` that exists already is continued. An answer with a status of 400 or above raises ``HttpError``.
101
+ """
102
+ send_headers = {**self.headers, **(headers or {})}
103
+ data: Any = None
104
+ if upload is not None:
105
+ send_headers["Content-Length"] = str(os.path.getsize(upload))
106
+ send_headers["Content-Type"] = "application/octet-stream"
107
+ elif body is not None:
108
+ data = json.dumps(body).encode("utf-8")
109
+ send_headers["Content-Type"] = "application/json"
110
+ offset = os.path.getsize(sink) if sink is not None and os.path.exists(sink) else 0
111
+ if offset:
112
+ send_headers["Range"] = f"bytes={offset}-"
113
+ connection = self._connect()
114
+ try:
115
+ if upload is not None:
116
+ with open(upload, "rb") as infile:
117
+ connection.request(method, self._prefix + path, body=infile, headers=send_headers)
118
+ else:
119
+ connection.request(method, self._prefix + path, body=data, headers=send_headers)
120
+ answer = connection.getresponse()
121
+ if sink is not None and answer.status in (200, 206):
122
+ # the rest of what is there already: 206; 200 with the range from a library before 0.45, and for a
123
+ # file that is complete already
124
+ rest = offset > 0 and str(answer.headers.get("Content-Range", "")).startswith(f"bytes {offset}-")
125
+ if answer.status == 206 and not rest:
126
+ raise Unreachable(f"{self._host}:{self._port}: {path} answered another range than asked for")
127
+ with open(sink, "ab" if rest else "wb") as outfile:
128
+ while chunk := answer.read(1 << 16):
129
+ outfile.write(chunk)
130
+ outfile.flush()
131
+ os.fsync(outfile.fileno())
132
+ content = b""
133
+ else:
134
+ content = answer.read()
135
+ except (OSError, http.client.HTTPException) as error:
136
+ raise Unreachable(f"{self._host}:{self._port}: {error!r}") from None
137
+ finally:
138
+ connection.close()
139
+ response = Response(answer.status, answer.headers, content)
140
+ if answer.status >= 400:
141
+ details: dict[str, Any] = {}
142
+ with contextlib.suppress(ValueError):
143
+ details = response.json() or {}
144
+ failure = HttpError(answer.status, str(details.pop("error", "") or details.get("message", "")))
145
+ failure.details = details
146
+ raise failure
147
+ return response
148
+
149
+ def get(self, path: str) -> Any:
150
+ return self.request("GET", path).json()
151
+
152
+ def post(self, path: str, body: Any = None) -> Any:
153
+ return self.request("POST", path, body).json()
154
+
155
+ def put(self, path: str, body: Any = None) -> Any:
156
+ return self.request("PUT", path, body).json()
157
+
158
+ def delete(self, path: str) -> Any:
159
+ return self.request("DELETE", path).json()
160
+
161
+ def events(self, path: str) -> Iterator[tuple[str, Any]]:
162
+ """Server-sent events until the connection breaks; then ``Unreachable`` is raised."""
163
+ connection = self._connect(timeout=3 * KEEP_ALIVE)
164
+ try:
165
+ connection.request("GET", self._prefix + path, headers=self.headers)
166
+ self._stream = connection.sock # the answer takes the socket over: the connection forgets it
167
+ answer = connection.getresponse()
168
+ if answer.status != 200:
169
+ raise HttpError(answer.status, "no events")
170
+ event = ""
171
+ while line := answer.readline():
172
+ text = line.decode("utf-8").rstrip("\n")
173
+ if text.startswith("event: "):
174
+ event = text[7:]
175
+ elif text.startswith("data: "):
176
+ yield event, json.loads(text[6:])
177
+ except (TimeoutError, OSError, http.client.HTTPException) as error:
178
+ raise Unreachable(f"{self._host}:{self._port}: {error!r}") from None
179
+ finally:
180
+ self._stream = None
181
+ connection.close()
182
+ raise Unreachable(f"{self._host}:{self._port}: the event stream ended")
183
+
184
+ def interrupt(self) -> None:
185
+ """End :meth:`events` from another thread: its connection is cut."""
186
+ stream = self._stream
187
+ if stream is not None:
188
+ with contextlib.suppress(OSError):
189
+ stream.shutdown(socket.SHUT_RDWR)