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,447 @@
1
+ """Albums and the covers of albums and stations on a box (docs/developer/library.md, section 4.8).
2
+
3
+ A file that one side lacks is copied from the side that has it, in both directions with the same manifest. A file gets
4
+ its name only after its checksum was verified, so a power cut in the middle of a transfer leaves no half file.
5
+
6
+ An album is fetched on demand: first the files that are not played (the playlist), then the track that is to be
7
+ played, then the rest in the order of the playlist. The covers lie in the images folder and are fetched all.
8
+ """
9
+
10
+ import contextlib
11
+ import os
12
+ import shutil
13
+ import threading
14
+ import urllib.parse
15
+ from collections.abc import Callable
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from tinyjukebox.images import is_cover
19
+ from tinyjukebox.sync import manifest as manifests
20
+ from tinyjukebox.sync.client import Client, HttpError, Unreachable
21
+ from tinyjukebox.sync.link import V
22
+ from tinyjukebox.sync.util import read_json, sha256_file, write_json
23
+
24
+ if TYPE_CHECKING:
25
+ from tinyjukebox.sync.box import BoxSync
26
+
27
+ __all__ = ["ATTEMPTS", "AlbumFiles", "ImageFiles"]
28
+
29
+ ATTEMPTS = 3 # a file whose checksum is wrong is discarded and requested again, this often
30
+
31
+
32
+ def _is_album(value: Any) -> bool:
33
+ """An album whose folder can be used as a path below the albums folder: what the library delivers is checked
34
+ first (:func:`tinyjukebox.sync.manifest.valid_folder`)."""
35
+ return (
36
+ isinstance(value, dict)
37
+ and value.get("target-type") == "album"
38
+ and manifests.valid_folder(value.get("album-folder"))
39
+ )
40
+
41
+
42
+ class AlbumFiles:
43
+ def __init__(self, box: "BoxSync", albums_folder: str, manifests_folder: str, keep_free: int = 0) -> None:
44
+ """``keep_free``: bytes that stay free on the card of the box (0: nothing is ever deleted for space)."""
45
+ self._box = box
46
+ self._logger = box.logger
47
+ self._albums = albums_folder
48
+ self._manifests = manifests_folder
49
+ self._keep_free = keep_free
50
+ os.makedirs(manifests_folder, exist_ok=True)
51
+ self._played_file = os.path.join(os.path.dirname(manifests_folder), "played.json")
52
+ self._lock = threading.Lock()
53
+ self._wanted: dict[str, int] = {} # album -> index of the track that is needed first
54
+ self._fetching: set[str] = set()
55
+ self._failed: dict[str, str] = {} # album -> why it cannot be fetched
56
+ self._listeners: list[Callable[[str], None]] = []
57
+
58
+ # --- what is here ------------------------------------------------------------------------------------------------
59
+
60
+ def _album(self, album: str) -> dict[str, Any] | None:
61
+ value = self._box.data.working("target").get(album)
62
+ return value if _is_album(value) else None
63
+
64
+ def folder(self, value: dict[str, Any]) -> str:
65
+ return os.path.join(self._albums, value["album-folder"])
66
+
67
+ def _manifest_file(self, album: str) -> str:
68
+ return os.path.join(self._manifests, f"{album}.json")
69
+
70
+ def manifest(self, album: str) -> dict[str, Any] | None:
71
+ manifest: dict[str, Any] | None = read_json(self._manifest_file(album))
72
+ return manifest
73
+
74
+ def lacking(self, album: str) -> list[str] | None:
75
+ """The files of the album that this box lacks; None if the box does not know them yet.
76
+
77
+ An album that the library has never seen has no files to fetch: it is complete as it is.
78
+ """
79
+ value = self._album(album)
80
+ if value is None or self._box.data.album(self._box.data.final(album)) is None:
81
+ return []
82
+ manifest = self.manifest(album)
83
+ return None if manifest is None else manifests.missing(self.folder(value), manifest)
84
+
85
+ def ready(self, album: str, track: int) -> bool:
86
+ """Whether the track ``track`` of the playlist can be played: it is here, and so are the files that are not
87
+ played."""
88
+ lacking = self.lacking(album)
89
+ manifest = self.manifest(album)
90
+ if lacking is None or (lacking and manifest is None):
91
+ return False
92
+ if not lacking or manifest is None:
93
+ return True
94
+ needed = manifests.extras(manifest) + manifest["order"][track : track + 1]
95
+ return not any(name in lacking for name in needed)
96
+
97
+ def failed(self, album: str) -> str | None:
98
+ with self._lock:
99
+ return self._failed.get(album)
100
+
101
+ def forget(self) -> None:
102
+ """The box left its library: what it knew about the files of the albums there goes. The files stay."""
103
+ with self._lock:
104
+ self._wanted.clear()
105
+ self._failed.clear()
106
+ for name in os.listdir(self._manifests):
107
+ os.remove(os.path.join(self._manifests, name))
108
+ with contextlib.suppress(FileNotFoundError):
109
+ os.remove(self._played_file)
110
+
111
+ def fetching(self) -> list[str]:
112
+ with self._lock:
113
+ return sorted(self._fetching)
114
+
115
+ def subscribe(self, listener: Callable[[str], None]) -> None:
116
+ """``listener(album)`` is called whenever a file of an album arrived or its download ended."""
117
+ with self._lock:
118
+ self._listeners.append(listener)
119
+
120
+ def unsubscribe(self, listener: Callable[[str], None]) -> None:
121
+ with self._lock, contextlib.suppress(ValueError):
122
+ self._listeners.remove(listener)
123
+
124
+ def _announce(self, album: str) -> None:
125
+ with self._lock:
126
+ listeners = list(self._listeners)
127
+ for listener in listeners:
128
+ listener(album)
129
+
130
+ # --- space on the card (section 4.16) ----------------------------------------------------------------------------
131
+
132
+ def played(self, album: str) -> None:
133
+ """The album was played just now. The order is kept as an order, not as times: without WLAN the clock of
134
+ the box is wrong."""
135
+ with self._lock:
136
+ order = [entry for entry in read_json(self._played_file, []) if entry != album]
137
+ write_json(self._played_file, [*order, album])
138
+
139
+ def _expendable(self) -> list[str]:
140
+ """Albums whose audio files may be deleted here, the one not played for the longest time first: the library
141
+ has all their files, and they are fetched again when their tag is placed. Never the album that plays, and
142
+ never one that exists only on this box."""
143
+ with self._lock:
144
+ order: list[str] = read_json(self._played_file, [])
145
+ busy = set(self._fetching)
146
+ candidates = []
147
+ for album, value in self._box.data.working("target").items():
148
+ described = self._box.data.album(album)
149
+ manifest = self.manifest(album)
150
+ if not _is_album(value) or described is None or described["lacks"] or manifest is None:
151
+ continue
152
+ if self._box.data.is_provisional(album) or album in busy or self._box.playing(album):
153
+ continue
154
+ if len(manifests.missing(self.folder(value), manifest)) < len(manifest["files"]):
155
+ candidates.append(album)
156
+ return sorted(candidates, key=lambda album: order.index(album) if album in order else -1)
157
+
158
+ def make_room(self, needed: int = 0) -> None:
159
+ """Keep the configured amount of space free: delete the audio files of albums until it is, the album not
160
+ played for the longest time first. The album stays in the catalog."""
161
+ if not self._keep_free or not os.path.isdir(self._albums):
162
+ return
163
+ for album in self._expendable():
164
+ if shutil.disk_usage(self._albums).free - needed >= self._keep_free:
165
+ return
166
+ value, manifest = self._album(album), self.manifest(album)
167
+ if value is None or manifest is None:
168
+ continue
169
+ for entry in manifest["files"]:
170
+ with contextlib.suppress(FileNotFoundError):
171
+ os.remove(manifests.path(self.folder(value), entry["name"]))
172
+ self._logger.info(
173
+ "space on the card: the files of album %s (%s) were deleted here; the library has them",
174
+ album, value["album-folder"],
175
+ ) # fmt: skip
176
+
177
+ # --- an album that was added on this box -------------------------------------------------------------------------
178
+
179
+ def described(self, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
180
+ """``entries`` of the outbox, every new album with its manifest. A manifest is created only now, when the
181
+ album is about to be sent: computing the checksums of an album takes time. An album that cannot be read is
182
+ sent without one, as the entry it is."""
183
+ return [self._described(entry) for entry in entries]
184
+
185
+ def _described(self, entry: dict[str, Any]) -> dict[str, Any]:
186
+ if entry["op"] != "create" or not _is_album(entry["value"]) or "manifest" in entry:
187
+ return entry
188
+ manifest = None
189
+ try:
190
+ manifest = manifests.create(self.folder(entry["value"]), entry["value"]["playlist"])
191
+ write_json(self._manifest_file(entry["key"]), manifest)
192
+ except OSError as error:
193
+ self._logger.warning("the album %s cannot be read: %s", entry["value"]["album-folder"], error)
194
+ self._box.data.amend(entry["n"], manifest=manifest)
195
+ return {**entry, "manifest": manifest}
196
+
197
+ def changed(self, album: str) -> None:
198
+ """The files of an album changed on this box, e.g. its playlist was written anew: tell the library."""
199
+ value = self._album(album)
200
+ if value is None or self.manifest(album) is None:
201
+ return # not sent yet: its manifest is created when it is
202
+ manifest = manifests.create(self.folder(value), value["playlist"])
203
+ self._box.data.add("target", album, value, manifest=manifest)
204
+ # after the entry of the outbox: a manifest that differs from the library's without such an entry would
205
+ # mean "changed in the library", and :meth:`balance` would delete what was changed here
206
+ write_json(self._manifest_file(album), manifest)
207
+
208
+ def renamed(self, provisional: str, final: str, value: dict[str, Any] | None) -> None:
209
+ """The library assigned the final id - or found that it has an album with identical files already. In the
210
+ library the folder may have another name, because two boxes uploaded different albums under one name."""
211
+ if os.path.exists(self._manifest_file(provisional)):
212
+ os.replace(self._manifest_file(provisional), self._manifest_file(final))
213
+ old = next(
214
+ (e["value"] for e in self._box.data.outbox() if e["op"] == "create" and e["key"] == provisional), None
215
+ )
216
+ if (
217
+ not _is_album(old)
218
+ or not _is_album(value)
219
+ or old is None
220
+ or value is None
221
+ or old["album-folder"] == value["album-folder"]
222
+ ):
223
+ return
224
+ source, target = self.folder(old), self.folder(value)
225
+ if os.path.isdir(source) and not os.path.exists(target):
226
+ os.makedirs(os.path.dirname(target), exist_ok=True)
227
+ os.replace(source, target)
228
+ self._logger.info("album folder '%s' is '%s' now", old["album-folder"], value["album-folder"])
229
+
230
+ def removed(self, value: dict[str, Any]) -> None:
231
+ """An album that was removed in the library is removed from the box as well."""
232
+ if not _is_album(value):
233
+ return
234
+ album = str(value["target-id"])
235
+ with contextlib.suppress(FileNotFoundError):
236
+ os.remove(self._manifest_file(album))
237
+ folder = self.folder(value)
238
+ if manifests.inside(self._albums, folder) and os.path.isdir(folder):
239
+ shutil.rmtree(os.path.realpath(folder), ignore_errors=True)
240
+ self._logger.info("album %s was removed in the library: folder '%s' deleted", album, value["album-folder"])
241
+
242
+ # --- towards the library -----------------------------------------------------------------------------------------
243
+
244
+ def send(self, client: Client, album: str, missing: list[str]) -> bool:
245
+ """Send the files that the library lacks. No list of the progress is kept: the library says what is missing."""
246
+ value = self._album(self._box.data.final(album))
247
+ sent = False
248
+ for name in missing if value is not None else []:
249
+ filename = manifests.path(self.folder(value), name) if value is not None else ""
250
+ if os.path.isfile(filename):
251
+ client.request("PUT", f"{V}/albums/{album}/files/{_quoted(name)}", upload=filename)
252
+ self._logger.info("file %s of album %s sent to the library", name, album)
253
+ sent = True
254
+ return sent
255
+
256
+ def balance(self, client: Client) -> bool:
257
+ """Compare the albums on this box with the library: take over a changed manifest, send what the library
258
+ lacks, go on fetching what was begun here."""
259
+ sent = False
260
+ data = self._box.data
261
+ # albums with a change of this box that the library has not answered yet: what the library says about their
262
+ # files is the older state, so nothing is taken over from it until the change is settled
263
+ pending = {str(data.final(entry["key"])) for entry in data.outbox() if entry["kind"] == "target"}
264
+ for album, value in data.working("target").items():
265
+ described = data.album(album)
266
+ manifest = self.manifest(album)
267
+ if not _is_album(value) or described is None or manifest is None:
268
+ continue
269
+ differs = manifests.checksum(manifest) != described["checksum"]
270
+ if differs and album not in pending and not self._box.playing(album):
271
+ manifest = self._take_over(client, album, value, manifest)
272
+ lacking = manifests.missing(self.folder(value), manifest)
273
+ here = [name for name in described["lacks"] if name not in lacking]
274
+ if here:
275
+ sent = self.send(client, album, here) or sent
276
+ if lacking and os.path.isdir(self.folder(value)):
277
+ self.need(album, 0)
278
+ return sent
279
+
280
+ def _take_over(self, client: Client, album: str, value: dict[str, Any], old: dict[str, Any]) -> dict[str, Any]:
281
+ """The files of the album changed in the library: files that differ are removed here and fetched again."""
282
+ new = client.get(f"{V}/albums/{album}/manifest")
283
+ checksums = {entry["name"]: entry["sha256"] for entry in new["files"]}
284
+ for entry in old["files"]:
285
+ if checksums.get(entry["name"]) != entry["sha256"]:
286
+ with contextlib.suppress(FileNotFoundError):
287
+ os.remove(manifests.path(self.folder(value), entry["name"]))
288
+ manifest = {"files": new["files"], "order": new["order"]}
289
+ write_json(self._manifest_file(album), manifest)
290
+ self._logger.info("the files of album %s changed in the library", album)
291
+ return manifest
292
+
293
+ # --- an album that is needed here --------------------------------------------------------------------------------
294
+
295
+ def need(self, album: str, track: int) -> None:
296
+ """Fetch the album from the library, beginning with the track ``track`` of its playlist. A second call for
297
+ another track pulls that one forward."""
298
+ with self._lock:
299
+ self._wanted[album] = track
300
+ self._failed.pop(album, None)
301
+ if album in self._fetching:
302
+ return
303
+ self._fetching.add(album)
304
+ threading.Thread(target=self._fetch, args=(album,), name=f"AlbumFiles.fetch-{album}", daemon=True).start()
305
+
306
+ def _fetch(self, album: str) -> None:
307
+ try:
308
+ self._fetch_files(album)
309
+ except (Unreachable, HttpError) as error:
310
+ self._logger.info("fetching album %s is interrupted: %s", album, error)
311
+ with self._lock:
312
+ self._failed[album] = str(error)
313
+ except Exception as error:
314
+ with self._lock:
315
+ self._failed[album] = repr(error)
316
+ self._box.unexpected(error)
317
+ finally:
318
+ with self._lock:
319
+ self._fetching.discard(album)
320
+ self._announce(album)
321
+
322
+ def _client(self) -> Client:
323
+ client: Client | None = self._box.link.client
324
+ if client is None or not self._box.link.connected:
325
+ raise Unreachable("the files are not on this box, and the library cannot be reached")
326
+ return client
327
+
328
+ def _fetch_files(self, album: str) -> None:
329
+ value = self._album(album)
330
+ if value is None:
331
+ raise Unreachable(f"album {album} is not in the catalog")
332
+ folder = self.folder(value)
333
+ manifest = self.manifest(album)
334
+ if manifest is None:
335
+ answer = self._client().get(f"{V}/albums/{album}/manifest")
336
+ manifest = {"files": answer["files"], "order": answer["order"]}
337
+ write_json(self._manifest_file(album), manifest)
338
+ entries = {entry["name"]: entry for entry in manifest["files"]}
339
+ attempts: dict[str, int] = {}
340
+ failed: set[str] = set()
341
+ while True:
342
+ missing = [name for name in manifests.missing(folder, manifest) if name not in failed]
343
+ if not missing:
344
+ if failed:
345
+ raise Unreachable(f"{len(failed)} files have a wrong checksum again and again")
346
+ self._logger.info("album %s is complete", album)
347
+ return
348
+ name = self._next(album, manifest, missing)
349
+ target = manifests.path(folder, name)
350
+ part = os.path.join(os.path.dirname(target), f".{os.path.basename(target)}.part")
351
+ os.makedirs(os.path.dirname(target), exist_ok=True)
352
+ self.make_room(entries[name]["size"])
353
+ # continues a part that is there
354
+ self._client().request("GET", f"{V}/albums/{album}/files/{_quoted(name)}", sink=part)
355
+ if os.path.getsize(part) == entries[name]["size"] and sha256_file(part) == entries[name]["sha256"]:
356
+ os.replace(part, target)
357
+ self._logger.info("file %s of album %s fetched", name, album)
358
+ else:
359
+ os.remove(part)
360
+ attempts[name] = attempts.get(name, 0) + 1
361
+ if attempts[name] < ATTEMPTS:
362
+ self._logger.warning("file %s of album %s has a wrong checksum: requested again", name, album)
363
+ else:
364
+ self._logger.error("file %s of album %s has a wrong checksum again and again", name, album)
365
+ self._box.data.journal("file", album=album, file=name, error="wrong checksum")
366
+ failed.add(name)
367
+ self._announce(album)
368
+
369
+ def _next(self, album: str, manifest: dict[str, Any], missing: list[str]) -> str:
370
+ """The file to fetch now: what is not played first, then the wanted track, then on in the playlist."""
371
+ other = [name for name in manifests.extras(manifest) if name in missing]
372
+ if other:
373
+ return other[0]
374
+ order: list[str] = manifest["order"]
375
+ with self._lock:
376
+ start = min(max(self._wanted.get(album, 0), 0), max(len(order) - 1, 0))
377
+ return next(name for name in order[start:] + order[:start] if name in missing)
378
+
379
+
380
+ def _quoted(name: str) -> str:
381
+ """A file name as one segment of a path: a file in a folder of the album has ``/`` in its name."""
382
+ return urllib.parse.quote(name, safe="")
383
+
384
+
385
+ class ImageFiles:
386
+ """The covers of albums and stations: a box fetches all of them once, and afterwards only what has changed."""
387
+
388
+ def __init__(self, box: "BoxSync", folder: str) -> None:
389
+ self._box = box
390
+ self._logger = box.logger
391
+ self._folder = folder
392
+ self._refused: set[str] = set() # covers that the library does not take
393
+ self._checksums: dict[str, tuple[int, float, str]] = {} # name -> (size, modified, checksum)
394
+
395
+ def _checksum(self, name: str) -> str:
396
+ filename = os.path.join(self._folder, name)
397
+ stat = os.stat(filename)
398
+ known = self._checksums.get(name)
399
+ if known is None or known[:2] != (stat.st_size, stat.st_mtime):
400
+ known = self._checksums[name] = (stat.st_size, stat.st_mtime, sha256_file(filename))
401
+ return known[2]
402
+
403
+ def _local(self) -> list[str]:
404
+ try:
405
+ names = os.listdir(self._folder)
406
+ except FileNotFoundError:
407
+ return []
408
+ return sorted(name for name in names if is_cover(name))
409
+
410
+ def balance(self, client: Client) -> bool:
411
+ """Fetch the images that are missing or differ here, remove what the library removed, and send the images
412
+ that were uploaded on this box. Returns whether something was sent."""
413
+ images = self._box.data.images()
414
+ local = self._local()
415
+ sent = False
416
+ for name in local:
417
+ if name not in images and name not in self._refused:
418
+ try:
419
+ client.request("PUT", f"{V}/images/{_quoted(name)}", upload=os.path.join(self._folder, name))
420
+ except HttpError as error:
421
+ if error.status != 422:
422
+ raise
423
+ # a library before 0.32 knows the covers of stations only; the rest of the pass must not wait
424
+ self._refused.add(name)
425
+ self._logger.warning("the library does not take the cover %s: %s", name, error)
426
+ continue
427
+ self._logger.info("cover %s sent to the library", name)
428
+ sent = True
429
+ for name, image in images.items():
430
+ if image.get("deleted"):
431
+ if name in local:
432
+ os.remove(os.path.join(self._folder, name))
433
+ self._logger.info("cover %s was removed in the library", name)
434
+ continue
435
+ if name in local and self._checksum(name) == image["sha256"]:
436
+ continue
437
+ os.makedirs(self._folder, exist_ok=True)
438
+ part = os.path.join(self._folder, f".{name}.part")
439
+ with contextlib.suppress(FileNotFoundError):
440
+ os.remove(part)
441
+ client.request("GET", f"{V}/images/{_quoted(name)}", sink=part)
442
+ if sha256_file(part) == image["sha256"]:
443
+ os.replace(part, os.path.join(self._folder, name))
444
+ else:
445
+ os.remove(part)
446
+ self._logger.warning("cover %s has a wrong checksum, discarded", name)
447
+ return sent
@@ -0,0 +1,42 @@
1
+ """The initial handshake of a box with the library (docs/developer/library.md, section 4.14).
2
+
3
+ Both sides prove that they know the bootstrap password without sending it: each sends a random number, and each answers
4
+ with a checksum that can only be computed with the password. Both checksums cover the certificate of the library as
5
+ the box sees it, so a program between the two, which has to show a certificate of its own, makes both proofs fail.
6
+ """
7
+
8
+ import hashlib
9
+ import hmac
10
+ import secrets
11
+ import ssl
12
+
13
+ __all__ = ["fingerprint", "hash_secret", "new_nonce", "new_secret", "proof", "same"]
14
+
15
+
16
+ def new_nonce() -> str:
17
+ return secrets.token_hex(16)
18
+
19
+
20
+ def new_secret() -> str:
21
+ """The key of one box: a long random secret."""
22
+ return secrets.token_urlsafe(48)
23
+
24
+
25
+ def hash_secret(secret: str) -> str:
26
+ """What the library stores of the key of a box. The secret is long and random, so a plain hash is enough."""
27
+ return hashlib.sha256(secret.encode("utf-8")).hexdigest()
28
+
29
+
30
+ def fingerprint(certificate: str) -> str:
31
+ """SHA-256 of a certificate (PEM)."""
32
+ return hashlib.sha256(ssl.PEM_cert_to_DER_cert(certificate)).hexdigest()
33
+
34
+
35
+ def proof(password: str, role: str, box: str, nonce_box: str, nonce_library: str, certificate: str) -> str:
36
+ """Checksum that the side ``role`` ("box" or "library") can only compute with the bootstrap password."""
37
+ message = "\n".join([role, box, nonce_box, nonce_library, fingerprint(certificate)])
38
+ return hmac.new(password.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
39
+
40
+
41
+ def same(left: str, right: str) -> bool:
42
+ return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))