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,1386 @@
1
+ """What the library stores, and the rules by which the changes of the boxes are merged into it
2
+ (docs/developer/library.md, section 4).
3
+
4
+ - The catalog are JSON files that a box receives whole: tags and targets (albums and stations), and the list of the
5
+ covers of albums and stations.
6
+ Every entry has a revision; a deleted entry stays as a mark (``value`` is null).
7
+ - Per box: positions and settings (written by the box), the answers to its changes, the albums it is uploading.
8
+ - The id table resolves the provisional ids of the boxes. It is never emptied.
9
+ - The albums themselves lie in the albums folder, their manifests next to the catalog.
10
+ - The broken files of the albums: what the library found itself and what the boxes reported.
11
+
12
+ The lock of the store is never held while the working data of the library (``tinyjukebox.data``) is called: a change
13
+ that a box made is announced with ``changed`` after the lock was released.
14
+ """
15
+
16
+ import contextlib
17
+ import glob
18
+ import json
19
+ import logging
20
+ import os
21
+ import secrets
22
+ import shutil
23
+ import threading
24
+ from collections.abc import Callable, Iterator
25
+ from typing import Any
26
+
27
+ import jsonschema
28
+ import mutagen
29
+
30
+ from tinyjukebox.data.common import is_unmapped
31
+ from tinyjukebox.images import ALBUM_PREFIX, STATION_PREFIX, image_extension, is_cover
32
+ from tinyjukebox.library.journal import Journal
33
+ from tinyjukebox.metadata import mp3_info, stored_cover
34
+ from tinyjukebox.schema.activities import schema as activities_schema
35
+ from tinyjukebox.schema.tags import schema as tags_schema
36
+ from tinyjukebox.schema.targets import schema as targets_schema
37
+ from tinyjukebox.sync import KINDS, LIBRARY, SOFTWARE
38
+ from tinyjukebox.sync import manifest as manifests
39
+ from tinyjukebox.sync.handshake import hash_secret, same
40
+ from tinyjukebox.sync.util import (
41
+ read_json,
42
+ sha256_bytes,
43
+ sha256_file,
44
+ timestamp,
45
+ version_key,
46
+ write_atomic,
47
+ write_json,
48
+ )
49
+
50
+ __all__ = [
51
+ "APP",
52
+ "BOX",
53
+ "CATALOG",
54
+ "FORMAT",
55
+ "IMAGES",
56
+ "NO_MP3",
57
+ "STAGING_FOLDER",
58
+ "STATES",
59
+ "Invalid",
60
+ "Store",
61
+ "Unknown",
62
+ "knows_album_covers",
63
+ ]
64
+
65
+ CATALOG = {"tag": "tags.json", "target": "targets.json"}
66
+ IMAGES = "images.json" # the covers of albums and stations: a list of files, no entries
67
+ CATALOG_FILES = (*CATALOG.values(), IMAGES)
68
+ FORMAT = 1 # format version of the catalog files and of the state of a box
69
+ STATES = ("use", "manual", "dontuse")
70
+ STAGING_FOLDER = ".tinyjukebox-staging" # in the albums folder: albums that boxes are uploading
71
+ IMAGES_SEEN = "images-seen.json" # size and time of change of every cover when its checksum was taken
72
+ BACKUP_SKIPS = ("archive", "json", "settings.json", IMAGES_SEEN) # in the data folder: what a backup leaves out
73
+ APP, BOX = "app", "box" # the kinds of devices: a box, and the app on a phone (docs/developer/app.md)
74
+ NO_MP3 = "not a valid mp3 file" # what the library notes about a track that it cannot read
75
+ _SCHEMAS = {
76
+ "tag": tags_schema["additionalProperties"],
77
+ "target": targets_schema["additionalProperties"],
78
+ "position": activities_schema["additionalProperties"],
79
+ }
80
+
81
+
82
+ class Unknown(Exception): # noqa: N818
83
+ """Something the library does not have."""
84
+
85
+
86
+ class Invalid(Exception): # noqa: N818
87
+ """A change that the library rejects. ``details`` go into the answer to a request of the web page, as the
88
+ details of an :class:`~tinyjukebox.errors.ApiError` do (its ``code``, the values of its text)."""
89
+
90
+ def __init__(self, message: str, **details: Any) -> None:
91
+ super().__init__(message)
92
+ self.details = details
93
+
94
+
95
+ class _Waiting(Exception): # noqa: N818
96
+ """A change that depends on an album whose files have not all arrived."""
97
+
98
+ def __init__(self, missing: list[str] | None = None) -> None:
99
+ super().__init__("waiting")
100
+ self.missing = missing
101
+
102
+
103
+ class _Gone(Exception): # noqa: N818
104
+ """A change that refers to something the library deleted."""
105
+
106
+
107
+ def _may_change(entry: dict[str, Any], side: str, base: int) -> bool:
108
+ """Whether a change by ``side`` that is based on revision ``base`` fits ``entry`` (section 4.4).
109
+
110
+ Equal: the normal case. Higher: the library lost data, the box wins. Lower: someone else changed the entry in the
111
+ meantime - unless every change since ``base`` was made by ``side`` itself, e.g. two changes made without
112
+ connection.
113
+ """
114
+ return base >= entry["rev"] or (entry.get("by") == side and base >= entry.get("since", entry["rev"]))
115
+
116
+
117
+ def _changed(entry: dict[str, Any], side: str, base: int, who: str) -> None:
118
+ if entry.get("by") != side:
119
+ entry["by"] = side
120
+ entry["since"] = base
121
+ entry["rev"] = max(entry["rev"], base) + 1
122
+ entry["who"] = who
123
+ entry["when"] = timestamp()
124
+
125
+
126
+ def _new_entry(side: str) -> dict[str, Any]:
127
+ return {"value": None, "rev": 0, "by": side, "since": 0}
128
+
129
+
130
+ def _is_album(value: Any) -> bool:
131
+ return isinstance(value, dict) and value.get("target-type") == "album"
132
+
133
+
134
+ def _sameness(value: Any) -> tuple[Any, ...] | None:
135
+ """What makes a station or an entry of Spotify the same as another one (section 4.8): name and address. None for an
136
+ album, which is the same if its tracks are."""
137
+ if not isinstance(value, dict):
138
+ return None
139
+ kind = value.get("target-type")
140
+ if kind == "radio":
141
+ return kind, value.get("name"), value.get("url")
142
+ if kind == "spotify":
143
+ return kind, value.get("name"), value.get("uri")
144
+ return None
145
+
146
+
147
+ def knows_album_covers(version: object) -> bool:
148
+ """Whether the box software ``version`` knows the cover of an album as an image of the images folder, named by
149
+ the album (since 0.32). An older one gets the catalog without them (:meth:`Store.catalog_file`)."""
150
+ try:
151
+ return version_key(str(version)) >= (0, 32, 0)
152
+ except ValueError:
153
+ return True
154
+
155
+
156
+ class Store:
157
+ def __init__(
158
+ self,
159
+ folder: str,
160
+ albums_folder: str,
161
+ images_folder: str,
162
+ logger: logging.Logger,
163
+ notify: Callable[[str], None],
164
+ changed: Callable[[], None] | None = None,
165
+ ) -> None:
166
+ """``notify`` tells the boxes that "catalog" or "versions" changed; ``changed`` tells the library itself that
167
+ a box changed the catalog."""
168
+ self.folder = folder
169
+ self.albums_folder = albums_folder
170
+ self.images_folder = images_folder
171
+ self.lock = threading.RLock()
172
+ self._logger = logger
173
+ self._notify = notify
174
+ self.changed = changed
175
+ self._depth = 0
176
+ self._dirty = False
177
+ for name in ("catalog", "boxes", "manifests", "archive"):
178
+ os.makedirs(os.path.join(folder, name), exist_ok=True)
179
+ for name in (albums_folder, images_folder, self._path_staging()):
180
+ os.makedirs(name, exist_ok=True)
181
+ self._catalog: dict[str, dict[str, Any]] = {}
182
+ for kind, name in CATALOG.items():
183
+ self._catalog[kind] = read_json(self._path("catalog", name), {"format": FORMAT, "entries": {}})
184
+ self._catalog["target"].setdefault("last-id", 0)
185
+ self._images: dict[str, Any] = read_json(self._path("catalog", IMAGES), None) or self._take_over_images()
186
+ self._ids: dict[str, str] = read_json(self._path("ids.json"), {})
187
+ self._versions: dict[str, Any] = read_json(self._path("versions.json"), {})
188
+ self._broken: dict[str, dict[str, Any]] = read_json(self._path("broken.json"), {})
189
+ self._seen: dict[str, list[int]] = read_json(self._path(IMAGES_SEEN), {})
190
+ self._boxes: dict[str, dict[str, Any]] = {}
191
+ for filename in glob.glob(self._path("boxes", "*.json")):
192
+ box = read_json(filename)
193
+ box.setdefault("kind", BOX) # until 0.39 every device was a box
194
+ self._boxes[box["id"]] = box
195
+ self.journal = Journal(self._path("journal.sqlite"))
196
+ for kind, name in CATALOG.items():
197
+ if not os.path.exists(self._path("catalog", name)):
198
+ self._save_catalog(kind, quiet=True)
199
+ if not os.path.exists(self._path("catalog", IMAGES)):
200
+ write_json(self._path("catalog", IMAGES), self._images)
201
+ self._take_over_covers()
202
+ self.verify_albums()
203
+ self.scan_images()
204
+
205
+ def close(self) -> None:
206
+ self.journal.close()
207
+
208
+ def backup_data(self) -> list[tuple[str, bytes]]:
209
+ """The files of the data folder at one moment, for a backup (:mod:`tinyjukebox.backup`): (name, content).
210
+
211
+ Left out: the settings, which are all secret; the working files, which are generated; and the archive of the
212
+ box versions - their entries say that the files are to be fetched again. The key of a box stays as the
213
+ library stores it: the hash of a long random number, which lets nobody in."""
214
+ found: list[tuple[str, bytes]] = []
215
+ with self.lock:
216
+ for root, folders, names in os.walk(self.folder):
217
+ if os.path.abspath(root) == os.path.abspath(self.folder):
218
+ folders[:] = [name for name in folders if name not in BACKUP_SKIPS]
219
+ folders.sort()
220
+ for name in sorted(names):
221
+ inside = os.path.relpath(os.path.join(root, name), self.folder).replace(os.sep, "/")
222
+ if inside in BACKUP_SKIPS or inside.startswith("journal.sqlite") or name.endswith(".tmp"):
223
+ continue
224
+ with open(os.path.join(root, name), "rb") as infile:
225
+ found.append((f"data/{inside}", infile.read()))
226
+ versions = {version: dict(entry, files={}) for version, entry in self._versions.items()}
227
+ content = json.dumps(versions, indent=1, sort_keys=True, ensure_ascii=False) + "\n"
228
+ found = [item for item in found if item[0] != "data/versions.json"]
229
+ found.append(("data/versions.json", content.encode("utf-8")))
230
+ found.append(("data/journal.sqlite", self.journal.snapshot()))
231
+ return sorted(found)
232
+
233
+ def _path(self, *parts: str) -> str:
234
+ return os.path.join(self.folder, *parts)
235
+
236
+ def _path_staging(self, *parts: str) -> str:
237
+ return os.path.join(self.albums_folder, STAGING_FOLDER, *parts)
238
+
239
+ def _album_path(self, value: dict[str, Any]) -> str:
240
+ """The folder of an album below the albums folder.
241
+
242
+ :raises Invalid: for an ``album-folder`` that leads out of it, or that is no folder of an album - a box or a
243
+ filling that sends one gets no file of the library and puts none there."""
244
+ name = value.get("album-folder")
245
+ if not isinstance(name, str) or not manifests.valid_folder(name):
246
+ raise Invalid(f"{name!r} is not the folder of an album")
247
+ folder = os.path.join(self.albums_folder, name)
248
+ if not manifests.inside(self.albums_folder, folder):
249
+ raise Invalid(f"the folder of the album {name!r} leads out of the albums folder")
250
+ return folder
251
+
252
+ @contextlib.contextmanager
253
+ def _section(self) -> Iterator[None]:
254
+ """The lock of the store. When the outermost section ends and a box changed the catalog in it, the library
255
+ is told - after the lock was released, also when the section ends with an exception: what was changed before
256
+ it is in the catalog."""
257
+ tell = False
258
+ try:
259
+ with self.lock:
260
+ self._depth += 1
261
+ try:
262
+ yield
263
+ finally:
264
+ self._depth -= 1
265
+ tell = self._dirty and self._depth == 0
266
+ if tell:
267
+ self._dirty = False
268
+ finally:
269
+ if tell and self.changed is not None:
270
+ self.changed()
271
+
272
+ # --- catalog -----------------------------------------------------------------------------------------------------
273
+
274
+ def _entries(self, kind: str) -> dict[str, Any]:
275
+ return self._catalog[kind]["entries"] # type: ignore[no-any-return]
276
+
277
+ def _save_catalog(self, kind: str, quiet: bool = False) -> None:
278
+ write_json(self._path("catalog", CATALOG[kind]), self._catalog[kind])
279
+ if not quiet:
280
+ self._notify("catalog")
281
+
282
+ def catalog_index(self, album_covers: bool = True) -> dict[str, Any]:
283
+ """Checksum and format version of every catalog file, as :meth:`catalog_file` delivers it."""
284
+ with self.lock:
285
+ index = {}
286
+ for name in CATALOG_FILES:
287
+ data = self.catalog_file(name, album_covers)
288
+ index[name] = {"sha256": sha256_bytes(data), "format": FORMAT, "size": len(data)}
289
+ return index
290
+
291
+ def catalog_file(self, name: str, album_covers: bool = True) -> bytes:
292
+ """A file of the catalog. Without ``album_covers`` it is what a box before 0.32 knows: no album names a
293
+ cover, and the list of the images holds the covers of the stations only. Such a box would not keep the file
294
+ it reads at its start, and it would fetch every cover of an album with every pass."""
295
+ if name not in CATALOG_FILES:
296
+ raise Unknown(name)
297
+ with self.lock, open(self._path("catalog", name), "rb") as infile:
298
+ data = infile.read()
299
+ if album_covers or name == CATALOG["tag"]:
300
+ return data
301
+ content = json.loads(data)
302
+ if name == IMAGES:
303
+ content["images"] = {
304
+ key: image for key, image in content["images"].items() if key.startswith(STATION_PREFIX)
305
+ }
306
+ else:
307
+ for entry in content["entries"].values():
308
+ if _is_album(entry["value"]):
309
+ entry["value"] = {field: value for field, value in entry["value"].items() if field != "cover"}
310
+ return (json.dumps(content, indent=1, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8")
311
+
312
+ def catalog_path(self, name: str) -> str:
313
+ if name not in CATALOG_FILES:
314
+ raise Unknown(name)
315
+ return self._path("catalog", name)
316
+
317
+ def working(self, kind: str) -> dict[str, Any]:
318
+ """What the library itself works with: the entries of ``kind`` without the marks of deleted ones."""
319
+ with self.lock:
320
+ return {key: entry["value"] for key, entry in self._entries(kind).items() if entry["value"] is not None}
321
+
322
+ def entry(self, kind: str, key: str) -> dict[str, Any] | None:
323
+ with self.lock:
324
+ entry = self._entries(kind).get(key)
325
+ return dict(entry) if entry is not None else None
326
+
327
+ def new_target_id(self) -> str:
328
+ """An id is never assigned twice, also not after its entry was deleted."""
329
+ with self.lock:
330
+ targets = self._catalog["target"]
331
+ numbers = [int(key) for key in targets["entries"] if key.isascii() and key.isdigit()]
332
+ targets["last-id"] = max([targets["last-id"], *numbers]) + 1
333
+ self._save_catalog("target", quiet=True)
334
+ return str(targets["last-id"])
335
+
336
+ # --- changes on the page of the library --------------------------------------------------------------------------
337
+
338
+ def set(self, kind: str, key: str, value: Any, who: str, imported: bool = False) -> None:
339
+ """A change made in the library itself; ``value`` None deletes the entry.
340
+
341
+ ``imported`` is the first filling (section 3.5): the entry keeps the revision 0, so that a box that brings
342
+ the same entry along with a change of its own wins.
343
+ """
344
+ album = self._describe_album(value) if isinstance(value, dict) and _is_album(value) else None
345
+ with self._section():
346
+ entries = self._entries(kind)
347
+ entry = entries.get(key)
348
+ if entry is None:
349
+ if value is None:
350
+ return
351
+ entry = entries[key] = _new_entry(LIBRARY)
352
+ elif entry["value"] == value and entry.get("album", {}).get("checksum") == (album or {}).get("checksum"):
353
+ return
354
+ if imported:
355
+ entry.update({"who": who, "when": timestamp()})
356
+ else:
357
+ _changed(entry, LIBRARY, entry["rev"], who)
358
+ self._put(kind, key, entry, value, album)
359
+ self._save_catalog(kind)
360
+ if value is not None:
361
+ self._apply_all_waiting()
362
+
363
+ def _put(self, kind: str, key: str, entry: dict[str, Any], value: Any, album: dict[str, Any] | None) -> None:
364
+ """Store ``value`` in ``entry``; for an album also what the library knows of its files."""
365
+ was_album = _is_album(entry["value"])
366
+ entry["value"] = value
367
+ if album is not None:
368
+ manifest = album.pop("manifest")
369
+ write_json(self._path("manifests", f"{key}.json"), manifest)
370
+ entry["album"] = album
371
+ self._check(key, entry, manifest)
372
+ elif kind == "target" and was_album and value is None:
373
+ entry.pop("album", None)
374
+ with contextlib.suppress(FileNotFoundError):
375
+ os.remove(self._path("manifests", f"{key}.json"))
376
+ if self._broken.pop(key, None) is not None:
377
+ write_json(self._path("broken.json"), self._broken)
378
+ if kind == "target" and key.isascii() and key.isdigit():
379
+ self._catalog["target"]["last-id"] = max(self._catalog["target"]["last-id"], int(key))
380
+
381
+ def _describe_album(self, value: dict[str, Any], manifest: dict[str, Any] | None = None) -> dict[str, Any] | None:
382
+ """What the catalog says about the files of an album, and its manifest. None for an album that cannot be
383
+ read: it is an entry of the catalog without files, and no box fetches anything for it."""
384
+ folder = self._album_path(value)
385
+ if manifest is None:
386
+ try:
387
+ manifest = manifests.create(folder, value["playlist"])
388
+ except OSError as error:
389
+ self._logger.warning("the album in '%s' cannot be read: %s", value["album-folder"], error)
390
+ return None
391
+ return {
392
+ "identity": manifests.identity(manifest),
393
+ "checksum": manifests.checksum(manifest),
394
+ "files": len(manifest["files"]),
395
+ "size": sum(entry["size"] for entry in manifest["files"]),
396
+ "lacks": manifests.missing(folder, manifest),
397
+ "manifest": manifest,
398
+ }
399
+
400
+ def album_changed(self, key: str, who: str) -> None:
401
+ """The files of an album changed in the library, e.g. its playlist was written anew."""
402
+ with self.lock:
403
+ entry = self._entries("target").get(key)
404
+ if entry is None or not _is_album(entry["value"]):
405
+ raise Unknown(f"album {key}")
406
+ value: dict[str, Any] = entry["value"]
407
+ album = self._describe_album(value)
408
+ with self.lock:
409
+ if album is None or entry.get("album", {}).get("checksum") == album["checksum"]:
410
+ return
411
+ _changed(entry, LIBRARY, entry["rev"], who)
412
+ self._put("target", key, entry, value, album)
413
+ self._save_catalog("target")
414
+
415
+ def fill(self, kind: str, entries: dict[str, Any], who: str) -> list[str]:
416
+ """The first filling (section 3.5): take over the entries the library does not have yet, under their ids.
417
+ Returns the keys that were taken over; what is there already is skipped."""
418
+ taken = []
419
+ with self._section():
420
+ for key, value in entries.items():
421
+ if key in self._entries(kind):
422
+ continue
423
+ self._validate(kind, value)
424
+ self.set(kind, key, value, who, imported=True)
425
+ taken.append(key)
426
+ if taken:
427
+ self._dirty = True
428
+ return taken
429
+
430
+ def describe_album(self, key: str, manifest: dict[str, Any], who: str) -> list[str]:
431
+ """The manifest of an album whose files are sent to the library from outside (first filling). Returns the
432
+ files that the library lacks."""
433
+ if not manifests.valid(manifest):
434
+ raise Invalid("the manifest is not valid")
435
+ with self.lock:
436
+ entry = self._album(key)
437
+ album = self._describe_album(entry["value"], manifest)
438
+ if album is not None and entry.get("album", {}).get("checksum") != album["checksum"]:
439
+ _changed(entry, LIBRARY, entry["rev"], who)
440
+ self._put("target", key, entry, entry["value"], album)
441
+ self._save_catalog("target")
442
+ self.verify_albums()
443
+ return list(entry.get("album", {}).get("lacks", []))
444
+
445
+ def set_position(self, box_id: str, uid: str, value: Any, who: str) -> None:
446
+ """A deliberate change of a position on the page of the library: the library wins against the box."""
447
+ self._set_own(box_id, "positions", uid, value, who)
448
+
449
+ def set_setting(self, box_id: str, key: str, value: Any, who: str) -> None:
450
+ self._set_own(box_id, "settings", key, value, who)
451
+
452
+ def _set_own(self, box_id: str, kind: str, key: str, value: Any, who: str) -> None:
453
+ with self.lock:
454
+ box = self.box(box_id)
455
+ entry = box[kind].setdefault(key, _new_entry(LIBRARY))
456
+ _changed(entry, LIBRARY, entry["rev"], who)
457
+ entry["value"] = value
458
+ self.save_box(box)
459
+ self._notify("catalog")
460
+
461
+ # --- boxes -------------------------------------------------------------------------------------------------------
462
+
463
+ def save_box(self, box: dict[str, Any]) -> None:
464
+ write_json(self._path("boxes", f"{box['id']}.json"), box)
465
+
466
+ def box(self, box_id: str) -> dict[str, Any]:
467
+ box = self._boxes.get(box_id)
468
+ if box is None:
469
+ raise Unknown(f"box {box_id}")
470
+ return box
471
+
472
+ def boxes(self) -> list[dict[str, Any]]:
473
+ with self.lock:
474
+ return [dict(box) for box in self._boxes.values()]
475
+
476
+ def box_name(self, box: dict[str, Any]) -> str:
477
+ return str(box["settings"].get("name", {}).get("value") or box["id"])
478
+
479
+ def register(
480
+ self, box_id: str, name: str, secret: str, kind: str = BOX, settings: dict[str, Any] | None = None
481
+ ) -> None:
482
+ """Store the key of a box after its handshake. A box that is known already gets a new key. ``kind``: what the
483
+ password of the handshake said - the bootstrap password registers a box, a one-time password the app on a
484
+ phone. ``settings`` are given to a device that is new.
485
+
486
+ :raises Invalid: for a device of the other kind - the one-time password of a phone never takes over a box,
487
+ nor the bootstrap password a phone."""
488
+ with self.lock:
489
+ box = self._boxes.get(box_id)
490
+ if box is not None and box["kind"] != kind and not box.get("removed"):
491
+ raise Invalid(f"{box_id} is a {'phone' if box['kind'] == APP else 'box'}")
492
+ if box is None:
493
+ box = self._boxes[box_id] = {
494
+ "id": box_id,
495
+ "kind": kind,
496
+ "registered": timestamp(),
497
+ "positions": {},
498
+ "settings": {"name": {"value": name, "rev": 1, "by": LIBRARY, "since": 0}},
499
+ "results": {},
500
+ "waiting": {},
501
+ "staging": {},
502
+ "provisional": {},
503
+ "journal": 0,
504
+ "history": 0,
505
+ "defective": [],
506
+ "status": {},
507
+ }
508
+ for key, value in (settings or {}).items():
509
+ box["settings"][key] = {"value": value, "rev": 1, "by": LIBRARY, "since": 0}
510
+ self._logger.info("%s %s (%s) registered", "phone" if kind == APP else "box", box_id, name)
511
+ box["kind"] = kind
512
+ box["key"] = hash_secret(secret)
513
+ box["removed"] = False
514
+ self.save_box(box)
515
+
516
+ def authenticate(self, box_id: str, secret: str) -> dict[str, Any] | None:
517
+ with self.lock:
518
+ box = self._boxes.get(box_id)
519
+ if box is None or box.get("removed") or not box.get("key") or not same(box["key"], hash_secret(secret)):
520
+ return None
521
+ return box
522
+
523
+ def remove_box(self, box_id: str) -> None:
524
+ """The key of the box is invalid from now on; its data stays."""
525
+ with self.lock:
526
+ box = self.box(box_id)
527
+ box["removed"] = True
528
+ box["key"] = ""
529
+ self.save_box(box)
530
+ self._logger.info("box %s removed", box_id)
531
+
532
+ def take_over(self, box_id: str, old_id: str, who: str) -> None:
533
+ """A box that was set up again takes over the positions and settings of an old one (section 4.7).
534
+
535
+ :raises Invalid: for the box itself - the old one is removed afterwards."""
536
+ if box_id == old_id:
537
+ raise Invalid("a box cannot take itself over.", code="boxes.takeover_self")
538
+ with self.lock:
539
+ box, old = self.box(box_id), self.box(old_id)
540
+ for kind in ("positions", "settings"):
541
+ for key, entry in old[kind].items():
542
+ mine = box[kind].get(key, {"rev": 0})
543
+ box[kind][key] = {
544
+ **entry,
545
+ "rev": max(entry["rev"], mine["rev"]) + 1,
546
+ "by": LIBRARY,
547
+ "since": mine["rev"],
548
+ "who": who,
549
+ "when": timestamp(),
550
+ }
551
+ old["removed"] = True
552
+ old["key"] = ""
553
+ self.save_box(box)
554
+ self.save_box(old)
555
+ self._logger.info("box %s took over box %s", box_id, old_id)
556
+ self._notify("catalog")
557
+
558
+ def box_state(self, box_id: str) -> dict[str, Any]:
559
+ """Positions and settings of the box with their revisions, and the numbers the library knows of it."""
560
+ with self.lock:
561
+ box = self.box(box_id)
562
+
563
+ def plain(entries: dict[str, Any]) -> dict[str, Any]:
564
+ return {key: {"value": entry["value"], "rev": entry["rev"]} for key, entry in entries.items()}
565
+
566
+ return {
567
+ "format": FORMAT,
568
+ "positions": plain(box["positions"]),
569
+ "settings": plain(box["settings"]),
570
+ "provisional": box["provisional"],
571
+ "journal": box["journal"],
572
+ }
573
+
574
+ def contact(
575
+ self, box: dict[str, Any], software: str, interface: int, address: str = "", page: int | None = None
576
+ ) -> None:
577
+ """A box asked: when, what it runs, and where - the address it asked from and the port of its page."""
578
+ with self.lock:
579
+ box["contact"] = timestamp()
580
+ box["software"] = software
581
+ box["interface"] = interface
582
+ box["address"] = address
583
+ box["page"] = page
584
+
585
+ # --- changes of a box (sections 4.4 to 4.6) ----------------------------------------------------------------------
586
+
587
+ @staticmethod
588
+ def _provisional(box_id: str, value: Any) -> int | None:
589
+ """The running number of a provisional id of this box (``<box>-<number>``), else None."""
590
+ prefix = f"{box_id}-"
591
+ if isinstance(value, str) and value.startswith(prefix) and value[len(prefix) :].isdigit():
592
+ return int(value[len(prefix) :])
593
+ return None
594
+
595
+ def _reference(self, box: dict[str, Any], value: Any) -> str:
596
+ """The final id behind the id of a target that a box sent; a provisional id is resolved by the id table."""
597
+ if isinstance(value, int) and not isinstance(value, bool):
598
+ value = str(value)
599
+ if not isinstance(value, str) or not value:
600
+ raise Invalid(f"'{value}' is not an id")
601
+ number = self._provisional(box["id"], value)
602
+ if number is None:
603
+ return value
604
+ final = self._ids.get(f"{box['id']}/target/{number}")
605
+ if final is not None:
606
+ return final
607
+ if str(number) in box["staging"] or any(
608
+ entry["op"] == "create" and entry.get("key") == value for entry in box["waiting"].values()
609
+ ):
610
+ raise _Waiting
611
+ raise Invalid(f"the id {value} cannot be resolved")
612
+
613
+ def _target(self, box: dict[str, Any], value: Any) -> str:
614
+ """The id of a target that a tag or a position of a box refers to. It must exist."""
615
+ final = self._reference(box, value)
616
+ entry = self._entries("target").get(final)
617
+ if entry is None:
618
+ raise Invalid(f"the target {final} does not exist")
619
+ if entry["value"] is None:
620
+ raise _Gone(f"the target {final} was deleted")
621
+ return final
622
+
623
+ def apply(self, box_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
624
+ """Merge entries of the outbox of a box, in order. An entry that is sent again gets the same answer."""
625
+ with self._section():
626
+ box = self.box(box_id)
627
+ numbers = [entry["n"] for entry in entries if isinstance(entry, dict) and isinstance(entry.get("n"), int)]
628
+ if len(numbers) != len(entries):
629
+ raise Invalid("every entry needs a running number")
630
+ for old in [key for key in box["results"] if numbers and int(key) < min(numbers)]:
631
+ del box["results"][old] # the box has dropped these entries, so it has seen their answers
632
+ results = [{"n": entry["n"], **self._answer(box, entry)} for entry in entries]
633
+ self.save_box(box)
634
+ return results
635
+
636
+ def _answer(self, box: dict[str, Any], entry: dict[str, Any]) -> dict[str, Any]:
637
+ number = str(entry["n"])
638
+ if number in box["results"]:
639
+ return box["results"][number] # type: ignore[no-any-return]
640
+ try:
641
+ for field in ("op", "kind", "key", "by", "version"):
642
+ if not isinstance(entry.get(field), str) or not entry[field]:
643
+ raise Invalid(f"'{field}' is missing")
644
+ if box["kind"] == APP and not self._app_may(entry):
645
+ raise Invalid(f"a phone may not change this: {entry['op']} {entry['kind']}")
646
+ if entry["version"] in box["defective"]:
647
+ result = self._discard_entry(box, entry)
648
+ else:
649
+ result = self._apply_entry(box, entry)
650
+ except _Waiting as waiting:
651
+ box["waiting"][number] = entry
652
+ return {"result": "waiting", **({"missing": waiting.missing} if waiting.missing is not None else {})}
653
+ except (Invalid, KeyError, TypeError, ValueError) as error:
654
+ reason = str(error) if isinstance(error, Invalid) else f"malformed: {error!r}"
655
+ self._logger.error("box %s: change %s rejected (%s): %s", box["id"], entry["n"], reason, entry)
656
+ self.journal.record("rejected", box["id"], {"entry": entry, "reason": reason})
657
+ result = {"result": "rejected", "reason": reason}
658
+ box["waiting"].pop(number, None)
659
+ box["results"][number] = result
660
+ return result
661
+
662
+ @staticmethod
663
+ def _app_may(entry: dict[str, Any]) -> bool:
664
+ """What the app on a phone may change (docs/developer/app.md): a position, and a tag that the software created
665
+ as not linked. Its key is lost more easily than a box, and a child needs nothing else."""
666
+ if entry["op"] != "set":
667
+ return False
668
+ if entry["kind"] == "position":
669
+ return True
670
+ value = entry.get("value")
671
+ return (
672
+ entry["kind"] == "tag"
673
+ and entry["by"] == SOFTWARE
674
+ and isinstance(value, dict)
675
+ and isinstance(value.get("target"), dict)
676
+ and is_unmapped(value["target"].get("id"))
677
+ )
678
+
679
+ def _conflict(self, box: dict[str, Any], entry: dict[str, Any], what: str, has: Any) -> dict[str, Any]:
680
+ """Both sides changed the same entry: the value of the library stays, the change of the box is dropped."""
681
+ clock = entry.get("clock", {})
682
+ wanted = entry.get("value")
683
+ self._logger.warning(
684
+ "conflict: box %s changed %s, the library wins. The box wanted %s, the library has %s. Changed by %s at %s "
685
+ "(clock of the box %s)",
686
+ box["id"], what, wanted, has, entry["by"], clock.get("time"),
687
+ "synchronized" if clock.get("synchronized") else "not synchronized",
688
+ ) # fmt: skip
689
+ self.journal.record("conflict", box["id"], {"what": what, "wanted": wanted, "library": has, "entry": entry})
690
+ return {"result": "conflict", "what": what, "wanted": wanted, "library": has}
691
+
692
+ def _discard_entry(self, box: dict[str, Any], entry: dict[str, Any]) -> dict[str, Any]:
693
+ """An entry written with a version that this box reported as defective (section 4.11)."""
694
+ if entry["by"] != SOFTWARE:
695
+ self._logger.warning(
696
+ "box %s: change %s was entered by %s with the defective version %s and is not applied: %s",
697
+ box["id"], entry["n"], entry["by"], entry["version"], entry,
698
+ ) # fmt: skip
699
+ self.journal.record("discarded", box["id"], {"entry": entry})
700
+ return {"result": "discarded"}
701
+
702
+ @staticmethod
703
+ def _validate(kind: str, value: Any) -> None:
704
+ if kind in _SCHEMAS:
705
+ try:
706
+ jsonschema.validate(value, _SCHEMAS[kind])
707
+ except jsonschema.ValidationError as error:
708
+ raise Invalid(f"the {kind} does not fit its schema: {error.message}") from None
709
+ if kind == "target" and _is_album(value) and not manifests.valid_folder(value["album-folder"]):
710
+ raise Invalid(f"{value['album-folder']!r} is not the folder of an album")
711
+
712
+ def _apply_entry(self, box: dict[str, Any], entry: dict[str, Any]) -> dict[str, Any]:
713
+ op, kind = entry["op"], entry["kind"]
714
+ if op == "create" and kind == "target":
715
+ return self._create_target(box, entry)
716
+ if op != "set" or kind not in KINDS:
717
+ raise Invalid(f"unknown change '{op} {kind}'")
718
+ base = entry["base"]
719
+ if not isinstance(base, int) or isinstance(base, bool):
720
+ raise Invalid("'base' is not a revision")
721
+ own = kind in ("position", "setting")
722
+ entries = box[f"{kind}s"] if own else self._entries(kind)
723
+ try:
724
+ key, value = self._resolved(box, kind, entry["key"], entry.get("value"), entry["version"])
725
+ except _Gone as gone:
726
+ what = f"{kind} {entry['key']}"
727
+ if own: # a position in an album that the library deleted: nothing can resume it
728
+ return {"result": "conflict", "what": what, "wanted": entry.get("value"), "library": None}
729
+ return self._conflict(box, entry, what, str(gone))
730
+ current = entries.get(key)
731
+ if current is None:
732
+ if kind == "target":
733
+ raise Invalid(f"the target {key} does not exist")
734
+ if value is None:
735
+ return {"result": "applied", "rev": 0}
736
+ current = entries[key] = _new_entry(box["id"])
737
+ elif (verdict := self._judge(box, entry, kind, key, value, current)) is not None:
738
+ return verdict
739
+ if kind == "position" and current["rev"] > 0 and current.get("version") != entry["version"]:
740
+ # the state before a version wrote: restored if this version turns out to be defective
741
+ current.setdefault("history", []).append({"value": current["value"], "version": current.get("version")})
742
+ _changed(current, box["id"], base, entry["by"])
743
+ if own:
744
+ current["value"] = value
745
+ current["version"] = entry["version"]
746
+ else:
747
+ self._put(kind, key, current, value, self._album_of(key, entry) if _is_album(value) else None)
748
+ self._save_catalog(kind)
749
+ self._dirty = True
750
+ return {"result": "applied", "rev": current["rev"]}
751
+
752
+ def _resolved(self, box: dict[str, Any], kind: str, key: str, value: Any, version: str) -> tuple[str, Any]:
753
+ """Key and value of a change with the final ids in place of provisional ones, the value checked. ``version``
754
+ is the box software that made the change."""
755
+ if kind == "target":
756
+ key = self._reference(box, key)
757
+ if value is not None:
758
+ value = {**value, "target-id": key}
759
+ has: dict[str, Any] = (self._entries("target").get(key) or {}).get("value") or {}
760
+ if _is_album(value) and _is_album(has) and has.get("cover") and not knows_album_covers(version):
761
+ value["cover"] = has["cover"] # a box before 0.32 changed the album: it does not know the cover
762
+ elif kind == "tag" and value is not None and not is_unmapped(value["target"].get("id")):
763
+ value = {**value, "target": {**value["target"], "id": self._target(box, value["target"]["id"])}}
764
+ elif kind == "position" and value is not None:
765
+ value = {**value, "target-id": self._target(box, value["target-id"])}
766
+ if value is not None:
767
+ self._validate(kind, value)
768
+ owner = {"tag": lambda: value["tag"]["uid"], "position": lambda: value["tag-uid"]}.get(kind)
769
+ if owner is not None and owner() != key:
770
+ raise Invalid(f"the {kind} does not belong to the key {key}")
771
+ return key, value
772
+
773
+ def _judge(
774
+ self, box: dict[str, Any], entry: dict[str, Any], kind: str, key: str, value: Any, current: dict[str, Any]
775
+ ) -> dict[str, Any] | None:
776
+ """The answer to a change of an entry that exists, if it is not applied (section 4.5); None if it is."""
777
+ by_itself = kind == "tag" and entry["by"] == SOFTWARE
778
+ # the files of an album changed on the box: the entry may be what it was, the change is in its manifest
779
+ manifest = entry.get("manifest")
780
+ other_files = manifest is not None and (
781
+ not manifests.valid(manifest) or manifests.checksum(manifest) != current.get("album", {}).get("checksum")
782
+ )
783
+ if current["value"] == value and not other_files and (current["rev"] > 0 or value is not None):
784
+ return {"result": "applied", "rev": current["rev"]} # both did the same
785
+ if by_itself and current["value"] is not None:
786
+ # the box created the tag by itself as "not linked" and the library knows it already: no deliberate change
787
+ return {"result": "applied", "rev": current["rev"]}
788
+ if _may_change(current, box["id"], entry["base"]) or by_itself:
789
+ return None
790
+ what = f"{kind} {key}"
791
+ if kind in ("position", "setting"):
792
+ # no conflict of two people: the library changed the entry on purpose and wins (section 4.4)
793
+ self._logger.info("box %s: %s was changed in the library, its value stays", box["id"], what)
794
+ return {"result": "conflict", "what": what, "wanted": value, "library": current["value"]}
795
+ return self._conflict(box, entry, what, current["value"] if current["value"] is not None else "deleted")
796
+
797
+ def _album_of(self, key: str, entry: dict[str, Any]) -> dict[str, Any] | None:
798
+ """What the catalog says about the files of an album that a box changed. With a new manifest the files that
799
+ differ are removed here; the box sends them, because the library lacks them then."""
800
+ manifest = entry.get("manifest")
801
+ if manifest is None:
802
+ return None
803
+ if not manifests.valid(manifest):
804
+ raise Invalid("the manifest is not valid")
805
+ folder = self._album_path(entry["value"])
806
+ old = read_json(self._path("manifests", f"{key}.json"), {"files": []})
807
+ checksums = {item["name"]: item["sha256"] for item in manifest["files"]}
808
+ for item in old["files"]:
809
+ if checksums.get(item["name"]) != item["sha256"]:
810
+ with contextlib.suppress(FileNotFoundError):
811
+ os.remove(manifests.path(folder, item["name"]))
812
+ return self._describe_album(entry["value"], manifest)
813
+
814
+ def resolve(self, box_id: str, kind: str, value: str) -> dict[str, Any]:
815
+ """The final id of a provisional id - or, for an album that is not complete, the files that are missing."""
816
+ with self.lock:
817
+ box = self.box(box_id)
818
+ number = self._provisional(box_id, value)
819
+ if number is None or kind != "target":
820
+ raise Unknown(value)
821
+ final = self._ids.get(f"{box_id}/target/{number}")
822
+ if final is not None:
823
+ return {"id": final, "value": self._entries("target")[final]["value"]}
824
+ if str(number) in box["staging"]:
825
+ return {"missing": self._staging_missing(box, str(number))}
826
+ try:
827
+ self._reference(box, value)
828
+ except _Waiting:
829
+ return {"waiting": True}
830
+ except Invalid:
831
+ pass
832
+ raise Unknown(value)
833
+
834
+ # --- targets that a box created (sections 4.6, 4.8) --------------------------------------------------------------
835
+
836
+ def _staging_folder(self, box: dict[str, Any], number: str) -> str:
837
+ return self._path_staging(f"{box['id']}-{number}")
838
+
839
+ def _staging_missing(self, box: dict[str, Any], number: str) -> list[str]:
840
+ return manifests.missing(self._staging_folder(box, number), box["staging"][number]["manifest"])
841
+
842
+ def _create_target(self, box: dict[str, Any], entry: dict[str, Any]) -> dict[str, Any]:
843
+ box_id, value = box["id"], entry["value"]
844
+ number = self._provisional(box_id, entry["key"])
845
+ if number is None:
846
+ raise Invalid("a new target needs a provisional id of its box")
847
+ self._validate("target", value)
848
+ # an album that its box cannot read comes without a manifest: it is an entry of the catalog without files
849
+ album = _is_album(value) and entry.get("manifest") is not None
850
+ if album and not manifests.valid(entry["manifest"]):
851
+ raise Invalid("the manifest is not valid")
852
+ key = f"{box_id}/target/{number}"
853
+ targets = self._entries("target")
854
+ # the entry carries a mark of the provisional id it was created under: entry and resolution are stored in
855
+ # one step. A checksum, because a provisional id that is visible on a box means "not settled yet"
856
+ origin = sha256_bytes(key.encode())[:16]
857
+ final = self._ids.get(key) or next((i for i, target in targets.items() if target.get("origin") == origin), None)
858
+ if final is None and album:
859
+ # an album with identical files exists: nothing is uploaded (two rips of one record are not recognized)
860
+ wanted = manifests.identity(entry["manifest"])
861
+ final = next(
862
+ (i for i, target in targets.items() if target.get("album", {}).get("identity") == wanted), None
863
+ )
864
+ if final is not None:
865
+ self._logger.info("box %s: album %s has the files of album %s", box_id, entry["key"], final)
866
+ elif final is None and not _is_album(value):
867
+ # a station, or an entry of Spotify, with the same name and address
868
+ same = _sameness(value)
869
+ final = next(
870
+ (
871
+ i
872
+ for i, target in targets.items()
873
+ if target["value"] is not None and _sameness(target["value"]) == same
874
+ ),
875
+ None,
876
+ )
877
+ if final is None and album:
878
+ staged = box["staging"].setdefault(str(number), {"value": value, "manifest": entry["manifest"]})
879
+ box["provisional"]["target"] = max(box["provisional"].get("target", 0), number)
880
+ still = manifests.missing(self._staging_folder(box, str(number)), staged["manifest"])
881
+ if still:
882
+ raise _Waiting(still)
883
+ final = self._finish_album(box, str(number), entry["by"])
884
+ elif final is None:
885
+ final = self.new_target_id()
886
+ targets[final] = {
887
+ "value": {**value, "target-id": final},
888
+ "rev": 1,
889
+ "by": box_id,
890
+ "since": 0,
891
+ "who": entry["by"],
892
+ "when": timestamp(),
893
+ "origin": origin,
894
+ }
895
+ self._save_catalog("target")
896
+ self._dirty = True
897
+ self._ids[key] = final
898
+ write_json(self._path("ids.json"), self._ids)
899
+ box["provisional"]["target"] = max(box["provisional"].get("target", 0), number)
900
+ return {"result": "applied", "id": final, "value": targets[final]["value"]}
901
+
902
+ def _free_folder(self, wanted: str) -> str:
903
+ """A folder name for a new album: two boxes may have uploaded different albums under the same name."""
904
+ known = {
905
+ entry["value"]["album-folder"] for entry in self._entries("target").values() if _is_album(entry["value"])
906
+ }
907
+ name, count = wanted, 1
908
+ while name in known or os.path.exists(os.path.join(self.albums_folder, name)):
909
+ count += 1
910
+ name = f"{wanted} ({count})"
911
+ return name
912
+
913
+ def _finish_album(self, box: dict[str, Any], number: str, who: str) -> str:
914
+ """Every file is there and verified: the album moves into the library and gets its final id."""
915
+ staged = box["staging"][number]
916
+ final = self.new_target_id()
917
+ folder = self._free_folder(os.path.normpath(staged["value"]["album-folder"]))
918
+ target = self._album_path({"album-folder": folder})
919
+ os.makedirs(os.path.dirname(target), exist_ok=True)
920
+ os.replace(self._staging_folder(box, number), target)
921
+ value = {**staged["value"], "target-id": final, "album-folder": folder}
922
+ entry = {
923
+ "value": None,
924
+ "rev": 1,
925
+ "by": box["id"],
926
+ "since": 0,
927
+ "who": who,
928
+ "when": timestamp(),
929
+ "origin": sha256_bytes(f"{box['id']}/target/{number}".encode())[:16],
930
+ }
931
+ self._put("target", final, entry, value, self._describe_album(value, staged["manifest"]))
932
+ self._entries("target")[final] = entry
933
+ self._save_catalog("target")
934
+ del box["staging"][number]
935
+ self._dirty = True
936
+ self._logger.info("album %s of box %s is complete: album %s in '%s'", number, box["id"], final, folder)
937
+ return final
938
+
939
+ def _apply_waiting(self, box: dict[str, Any]) -> None:
940
+ """Changes that waited for an album are applied now, in their order."""
941
+ for number in sorted(box["waiting"], key=int):
942
+ self._answer(box, box["waiting"][number])
943
+
944
+ def _apply_all_waiting(self) -> None:
945
+ for box in self._boxes.values():
946
+ if box["waiting"]:
947
+ self._apply_waiting(box)
948
+ self.save_box(box)
949
+
950
+ # --- albums: files with a manifest (section 4.8) -----------------------------------------------------------------
951
+
952
+ def _album(self, album_id: str) -> dict[str, Any]:
953
+ entry = self._entries("target").get(album_id)
954
+ if entry is None or not _is_album(entry["value"]):
955
+ raise Unknown(f"album {album_id}")
956
+ return entry # type: ignore[no-any-return]
957
+
958
+ def manifest(self, album_id: str) -> dict[str, Any]:
959
+ with self.lock:
960
+ entry = self._album(album_id)
961
+ manifest = read_json(self._path("manifests", f"{album_id}.json"))
962
+ if manifest is None:
963
+ raise Unknown(f"manifest of album {album_id}")
964
+ return {"id": album_id, **manifest, "lacks": entry["album"]["lacks"]}
965
+
966
+ def album_file(self, album_id: str, name: str) -> str:
967
+ manifest = self.manifest(album_id)
968
+ with self.lock:
969
+ try:
970
+ folder = self._album_path(self._album(album_id)["value"])
971
+ except Invalid:
972
+ raise Unknown(f"files of album {album_id}") from None
973
+ filename = manifests.path(folder, name)
974
+ if name not in [entry["name"] for entry in manifest["files"]] or not os.path.isfile(filename):
975
+ raise Unknown(f"file {name} of album {album_id}")
976
+ return filename
977
+
978
+ def receive_file(self, box_id: str, album: str, name: str, save: Callable[[str], str]) -> dict[str, Any]:
979
+ """A file that a box sends: for an album it is uploading, or one that the library lacks.
980
+
981
+ ``save`` stores the file under the name it is given and returns its checksum. The file gets its real name only
982
+ after the checksum was verified.
983
+ """
984
+ with self.lock:
985
+ box = self._boxes.get(box_id, {"staging": {}}) # not a box: the first filling
986
+ number = self._provisional(box_id, album)
987
+ staged = number is not None and str(number) in box["staging"]
988
+ if staged:
989
+ folder, manifest = self._staging_folder(box, str(number)), box["staging"][str(number)]["manifest"]
990
+ elif album in self._entries("target") and _is_album(self._entries("target")[album]["value"]):
991
+ folder = self._album_path(self._album(album)["value"])
992
+ manifest = self.manifest(album)
993
+ else:
994
+ raise Unknown(f"album {album}")
995
+ entry = next((entry for entry in manifest["files"] if entry["name"] == name), None)
996
+ if entry is None:
997
+ raise Unknown(f"file {name}")
998
+ filename = manifests.path(folder, name)
999
+ temporary = os.path.join(os.path.dirname(filename), f".{os.path.basename(filename)}.part")
1000
+ checksum = save(temporary)
1001
+ with self._section():
1002
+ if checksum != entry["sha256"]:
1003
+ os.remove(temporary)
1004
+ self._logger.warning("%s: file %s of album %s has a wrong checksum, discarded", box_id, name, album)
1005
+ raise Invalid("wrong checksum")
1006
+ os.replace(temporary, filename)
1007
+ self._logger.info("%s: file %s of album %s received", box_id, name, album)
1008
+ if staged:
1009
+ still = self._staging_missing(box, str(number))
1010
+ if not still:
1011
+ final = self._finish_album(box, str(number), box_id)
1012
+ self._ids[f"{box_id}/target/{number}"] = final
1013
+ write_json(self._path("ids.json"), self._ids)
1014
+ self._apply_waiting(box)
1015
+ self.save_box(box)
1016
+ return {"missing": still}
1017
+ self.verify_albums()
1018
+ return {"missing": self._album(album)["album"]["lacks"]}
1019
+
1020
+ def verify_albums(self) -> None:
1021
+ """Note in the catalog which files the library itself lacks; a box that has them sends them."""
1022
+ with self.lock:
1023
+ changed = False
1024
+ for album_id, entry in self._entries("target").items():
1025
+ if not _is_album(entry["value"]) or "album" not in entry:
1026
+ continue
1027
+ manifest = read_json(self._path("manifests", f"{album_id}.json"))
1028
+ if manifest is None:
1029
+ continue
1030
+ try:
1031
+ folder = self._album_path(entry["value"])
1032
+ except Invalid as error:
1033
+ self._logger.error("album %s: %s - its files are not looked at", album_id, error)
1034
+ continue
1035
+ lacks = manifests.missing(folder, manifest)
1036
+ if lacks != entry["album"]["lacks"]:
1037
+ entry["album"]["lacks"] = lacks
1038
+ entry["rev"] += 1
1039
+ changed = True
1040
+ if lacks:
1041
+ self._logger.warning("album %s lacks %s", album_id, lacks)
1042
+ changed = self._check(album_id, entry, manifest) or changed
1043
+ if changed:
1044
+ self._save_catalog("target")
1045
+
1046
+ # --- broken files ------------------------------------------------------------------------------------------------
1047
+
1048
+ def _check(self, key: str, entry: dict[str, Any], manifest: dict[str, Any]) -> bool:
1049
+ """Read the tracks of an album - once for every state of its files, as soon as the library has all of them.
1050
+ The catalog gets the number of the tracks and their playing time, so a box knows them of an album that it
1051
+ has not fetched. Tracks that are no valid mp3 files are noted; what was noted about a file that was replaced
1052
+ since is dropped. Returns whether the catalog changed."""
1053
+ album = entry["album"]
1054
+ state = self._broken.get(key, {"checksum": None, "files": {}})
1055
+ if album["lacks"] or (state["checksum"] == album["checksum"] and "length" in album):
1056
+ return False
1057
+ try:
1058
+ folder = self._album_path(entry["value"])
1059
+ except Invalid:
1060
+ return False
1061
+ checksums = {item["name"]: item["sha256"] for item in manifest["files"]}
1062
+ files = {name: found for name, found in state["files"].items() if found["sha256"] == checksums.get(name)}
1063
+ length = 0.0
1064
+ for name in manifest["order"]:
1065
+ try:
1066
+ length += mp3_info(manifests.path(folder, name))[0]
1067
+ except mutagen.MutagenError:
1068
+ found = files.setdefault(name, {"sha256": checksums[name], "boxes": {}})
1069
+ if "library" not in found:
1070
+ self._logger.warning("album %s: file %s is %s", key, name, NO_MP3)
1071
+ found["library"] = NO_MP3
1072
+ album.update({"tracks": len(manifest["order"]), "length": round(length, 2)})
1073
+ self._broken[key] = {"checksum": album["checksum"], "files": files}
1074
+ write_json(self._path("broken.json"), self._broken)
1075
+ return True
1076
+
1077
+ def reported(self, box_id: str, line: dict[str, Any]) -> None:
1078
+ """The decoder of a box found a file damaged (line ``broken`` of its journal). Noted for the file as the
1079
+ library has it: the files of a box are the ones of the library, compared by their checksums. The time is the
1080
+ one of the box if its clock was synchronized, otherwise the time the line arrived."""
1081
+ with self.lock:
1082
+ name, error, clock = line.get("file"), str(line.get("error") or ""), line.get("clock") or {}
1083
+ try:
1084
+ key = self._reference(self.box(box_id), line.get("album"))
1085
+ manifest = self.manifest(key)
1086
+ except (Unknown, Invalid, _Waiting):
1087
+ key, manifest = str(line.get("album")), {"files": []}
1088
+ item = next((item for item in manifest["files"] if item["name"] == name), None)
1089
+ self._logger.warning("box %s found file %s of album %s damaged: %s", box_id, name, key, error)
1090
+ if item is None:
1091
+ return # an album or a file that the library does not have (yet)
1092
+ state = self._broken.setdefault(key, {"checksum": None, "files": {}})
1093
+ found = state["files"].setdefault(name, {"sha256": item["sha256"], "boxes": {}})
1094
+ when = clock.get("time") if clock.get("synchronized") else None
1095
+ found["boxes"][box_id] = {"when": when or timestamp(), "error": error}
1096
+ write_json(self._path("broken.json"), self._broken)
1097
+
1098
+ def broken(self, key: str | None = None) -> dict[str, dict[str, Any]]:
1099
+ """The broken files of every album that has some - or of the album ``key`` only - by album and name:
1100
+ ``library`` is what the library found itself (missing if nothing), ``boxes`` what boxes reported."""
1101
+ with self.lock:
1102
+ result = {}
1103
+ for album_id, state in self._broken.items():
1104
+ if state["files"] and key in (None, album_id):
1105
+ result[album_id] = {
1106
+ name: {field: value for field, value in found.items() if field != "sha256"}
1107
+ for name, found in state["files"].items()
1108
+ }
1109
+ return result
1110
+
1111
+ def forget_reports(self, key: str) -> None:
1112
+ """Drop what boxes reported about the files of an album, e.g. after a single failure of a box that says
1113
+ nothing about the file. What the library found itself stays."""
1114
+ with self.lock:
1115
+ self._album(key)
1116
+ state = self._broken.get(key)
1117
+ if state is None:
1118
+ return
1119
+ for name in list(state["files"]):
1120
+ state["files"][name]["boxes"] = {}
1121
+ if "library" not in state["files"][name]:
1122
+ del state["files"][name]
1123
+ write_json(self._path("broken.json"), self._broken)
1124
+
1125
+ def uploads(self) -> list[dict[str, Any]]:
1126
+ """Albums that are on their way from a box, e.g. "only on box 1, 7 of 12 files transferred"."""
1127
+ with self.lock:
1128
+ result = []
1129
+ for box in self._boxes.values():
1130
+ for number, staged in box["staging"].items():
1131
+ total = len(staged["manifest"]["files"])
1132
+ result.append({
1133
+ "box": box["id"], "name": self.box_name(box), "id": f"{box['id']}-{number}",
1134
+ "folder": staged["value"]["album-folder"], "files": total,
1135
+ "transferred": total - len(self._staging_missing(box, number)),
1136
+ }) # fmt: skip
1137
+ return result
1138
+
1139
+ # --- covers of albums and stations (section 4.8) ------------------------------------------------------------------
1140
+
1141
+ def _take_over_covers(self) -> None:
1142
+ """Until 0.31 the cover of an album was a file of the album, ``cover.jpg`` or ``cover.png`` in its folder,
1143
+ which only a backup with the albums holds. Move the stored cover of every album that names no cover into
1144
+ the images folder. The file leaves the manifest of the album in the same step: the album has a new checksum,
1145
+ and a box that takes it over removes its own stored cover and fetches nothing else. No track is read for
1146
+ it."""
1147
+ moved = []
1148
+ for key, entry in self._entries("target").items():
1149
+ value = entry["value"]
1150
+ if not _is_album(value) or value.get("cover"):
1151
+ continue
1152
+ try:
1153
+ folder = self._album_path(value)
1154
+ except Invalid as error:
1155
+ self._logger.error("album %s: %s", key, error)
1156
+ continue
1157
+ stored = stored_cover(folder)
1158
+ if stored is None:
1159
+ continue
1160
+ try:
1161
+ with open(stored, "rb") as infile:
1162
+ data = infile.read()
1163
+ extension = image_extension(data)
1164
+ if extension is None:
1165
+ raise ValueError("neither a jpeg nor a png image")
1166
+ name = f"{ALBUM_PREFIX}{secrets.token_hex(4)}{extension}"
1167
+ write_atomic(os.path.join(self.images_folder, name), data)
1168
+ except (OSError, ValueError) as error:
1169
+ self._logger.warning("the cover '%s' stays where it is: %s", stored, error)
1170
+ continue
1171
+ value = {**value, "cover": name}
1172
+ manifest = read_json(self._path("manifests", f"{key}.json"))
1173
+ album = None
1174
+ if manifest is not None and "album" in entry:
1175
+ before = entry["album"]
1176
+ gone = os.path.relpath(stored, folder).replace(os.sep, "/")
1177
+ manifest = {**manifest, "files": [item for item in manifest["files"] if item["name"] != gone]}
1178
+ album = self._describe_album(value, manifest)
1179
+ state = self._broken.get(key)
1180
+ if album is not None and "length" in before and state and state["checksum"] == before["checksum"]:
1181
+ # the tracks are what they were: what was read from them stays
1182
+ album.update({"tracks": before["tracks"], "length": before["length"]})
1183
+ state["checksum"] = album["checksum"]
1184
+ _changed(entry, LIBRARY, entry["rev"], SOFTWARE)
1185
+ self._put("target", key, entry, value, album)
1186
+ moved.append(stored)
1187
+ if moved:
1188
+ write_json(self._path("broken.json"), self._broken)
1189
+ self._save_catalog("target", quiet=True)
1190
+ for stored in moved: # only now: the catalog names the images
1191
+ os.remove(stored)
1192
+ self._logger.info(
1193
+ "%d covers of albums moved from their album folders to '%s'", len(moved), self.images_folder
1194
+ )
1195
+
1196
+ def _take_over_images(self) -> dict[str, Any]:
1197
+ """Until 0.28 the list of the images was part of ``cards.json``, the catalog file of the card images of tags.
1198
+ Its covers of stations become the list; the file goes."""
1199
+ old = self._path("catalog", "cards.json")
1200
+ images = {name: image for name, image in read_json(old, {}).get("images", {}).items() if is_cover(name)}
1201
+ with contextlib.suppress(FileNotFoundError):
1202
+ os.remove(old)
1203
+ return {"format": FORMAT, "images": images}
1204
+
1205
+ def _save_images(self) -> None:
1206
+ write_json(self._path("catalog", IMAGES), self._images)
1207
+ self._notify("catalog")
1208
+
1209
+ def scan_images(self) -> None:
1210
+ """Bring the list of the covers in line with the folder. An image that was removed stays as a mark, so that a
1211
+ box removes it too instead of sending it again. The checksum of an image is taken again when its size or the
1212
+ time of its change differ from when it was taken last - an image replaced by hand reaches the boxes, also
1213
+ one of the same size."""
1214
+ with self.lock:
1215
+ images = self._images["images"]
1216
+ found, seen = {}, {}
1217
+ for name in sorted(os.listdir(self.images_folder)):
1218
+ if is_cover(name):
1219
+ filename = os.path.join(self.images_folder, name)
1220
+ known = images.get(name, {})
1221
+ stat = os.stat(filename)
1222
+ seen[name] = [stat.st_size, stat.st_mtime_ns]
1223
+ same = known.get("size") == stat.st_size and self._seen.get(name) == seen[name]
1224
+ found[name] = {"sha256": known["sha256"] if same else sha256_file(filename), "size": stat.st_size}
1225
+ for name in images:
1226
+ if name not in found:
1227
+ found[name] = {"deleted": True}
1228
+ if seen != self._seen:
1229
+ self._seen = seen
1230
+ write_json(self._path(IMAGES_SEEN), seen)
1231
+ if found != images:
1232
+ self._images["images"] = found
1233
+ self._save_images()
1234
+
1235
+ def image_file(self, name: str) -> str:
1236
+ with self.lock:
1237
+ if "sha256" not in self._images["images"].get(name, {}):
1238
+ raise Unknown(f"image {name}")
1239
+ return os.path.join(self.images_folder, name)
1240
+
1241
+ def receive_image(self, box_id: str, name: str, save: Callable[[str], str]) -> None:
1242
+ """The cover of an album or a station that was set on a box."""
1243
+ if name != os.path.basename(name) or not is_cover(name):
1244
+ raise Invalid(f"'{name}' is not the name of a cover")
1245
+ with self.lock:
1246
+ if name in self._images["images"]:
1247
+ return # the library has it, or removed it on purpose
1248
+ save(os.path.join(self.images_folder, f".{name}.part"))
1249
+ with self._section():
1250
+ os.replace(os.path.join(self.images_folder, f".{name}.part"), os.path.join(self.images_folder, name))
1251
+ self._logger.info("%s: cover %s received", box_id, name)
1252
+ self.scan_images()
1253
+ self._dirty = True
1254
+
1255
+ # --- versions of the box software and their states (section 6.2) -------------------------------------------------
1256
+
1257
+ def versions(self) -> dict[str, Any]:
1258
+ with self.lock:
1259
+ return {version: dict(entry) for version, entry in self._versions.items()}
1260
+
1261
+ def add_version(self, description: dict[str, Any], files: dict[str, str], by: str) -> bool:
1262
+ """Put a slot into the archive; files that lie in the folder of the archive already stay where they are.
1263
+ A new version starts in the state ``use``."""
1264
+ with self.lock:
1265
+ version = description["version"]
1266
+ new = version not in self._versions
1267
+ for kind, source in files.items():
1268
+ target = self._path("archive", os.path.basename(source))
1269
+ if os.path.abspath(source) != target:
1270
+ # never written in place: a file of the archive is replaced in one step
1271
+ shutil.copyfile(source, f"{target}.tmp")
1272
+ os.replace(f"{target}.tmp", target)
1273
+ files[kind] = os.path.basename(source)
1274
+ entry = self._versions.setdefault(
1275
+ version, {"state": "use", "rev": 1, "by": by, "when": timestamp(), "why": "new release"}
1276
+ )
1277
+ entry.update({"description": description, "files": files})
1278
+ write_json(self._path("versions.json"), self._versions)
1279
+ if new:
1280
+ self._logger.info("version %s is in the archive, state %s", version, entry["state"])
1281
+ self.journal.record(
1282
+ "version", None, {"version": version, "state": entry["state"], "by": by, "why": "new release"}
1283
+ )
1284
+ if new:
1285
+ self._notify("versions")
1286
+ return new
1287
+
1288
+ def version_file(self, version: str, kind: str) -> str:
1289
+ with self.lock:
1290
+ entry = self._versions.get(version)
1291
+ if entry is None or kind not in entry.get("files", {}):
1292
+ raise Unknown(f"version {version}")
1293
+ return self._path("archive", entry["files"][kind])
1294
+
1295
+ def drop_version_files(self, version: str) -> None:
1296
+ """Remove a version from the archive; its entry and its state stay, the files can be fetched again."""
1297
+ with self.lock:
1298
+ entry = self._versions[version]
1299
+ for name in entry["files"].values():
1300
+ with contextlib.suppress(FileNotFoundError):
1301
+ os.remove(self._path("archive", name))
1302
+ entry["files"] = {}
1303
+ write_json(self._path("versions.json"), self._versions)
1304
+
1305
+ def set_version(self, version: str, state: str, by: str, why: str) -> None:
1306
+ """Set the state of a version by hand. Who set it, when and why is recorded."""
1307
+ with self.lock:
1308
+ entry = self._versions.get(version)
1309
+ if entry is None or state not in STATES:
1310
+ raise Unknown(f"version {version} / state {state}")
1311
+ if entry["state"] == state:
1312
+ return
1313
+ entry.update({"state": state, "rev": entry["rev"] + 1, "by": by, "when": timestamp(), "why": why})
1314
+ write_json(self._path("versions.json"), self._versions)
1315
+ self._logger.info("version %s is %s now (%s: %s)", version, state, by, why)
1316
+ self.journal.record("version", None, {"version": version, "state": state, "by": by, "why": why})
1317
+ self._notify("versions")
1318
+
1319
+ def box_left_version(self, box: dict[str, Any], version: str, base: int, why: str) -> None:
1320
+ """A box went back from a version: the version is ``manual`` for all boxes (section 6.2).
1321
+
1322
+ If the library set the version differently in the meantime, the library wins. A box that has never seen
1323
+ the list of the versions (``base`` 0) cannot know of that; it went back all the same.
1324
+ """
1325
+ with self.lock:
1326
+ entry = self._versions.get(version)
1327
+ if entry is None:
1328
+ return
1329
+ if entry["state"] == "use" and base in (entry["rev"], 0):
1330
+ self.set_version(version, "manual", f"box {self.box_name(box)} ({box['id']})", why)
1331
+ else:
1332
+ self._logger.info(
1333
+ "box %s went back from version %s; the library has it as %s already",
1334
+ box["id"],
1335
+ version,
1336
+ entry["state"],
1337
+ )
1338
+
1339
+ # --- status of a box, data of a defective version ----------------------------------------------------------------
1340
+
1341
+ def report(self, box_id: str, status: dict[str, Any]) -> dict[str, Any]:
1342
+ """What a box reports with every contact: what runs, its switches of version, versions it went back from."""
1343
+ with self.lock:
1344
+ box = self.box(box_id)
1345
+ lines = status.pop("history", [])
1346
+ if lines and lines[0]["n"] == 1 and box["history"] >= lines[0]["n"]:
1347
+ # the switches of the box begin anew (a card set up again): its numbers start at 1 again
1348
+ self._logger.info("box %s: its list of switches begins anew", box_id)
1349
+ box["history"] = 0
1350
+ for line in lines:
1351
+ if line["n"] > box["history"]:
1352
+ box["history"] = line["n"]
1353
+ self.journal.record("switch", box_id, line)
1354
+ self._logger.info("box %s switched: %s", box_id, line)
1355
+ left = status.pop("left", [])
1356
+ for item in left:
1357
+ self.box_left_version(box, item["version"], item["base"], item.get("why", ""))
1358
+ defective = status.pop("defective", [])
1359
+ for version in defective:
1360
+ self.discard(box, version)
1361
+ box["status"] = status
1362
+ self.save_box(box)
1363
+ return {"history": box["history"], "left": [item["version"] for item in left], "defective": defective}
1364
+
1365
+ def discard(self, box: dict[str, Any], version: str) -> None:
1366
+ """Do not trust what a defective version wrote by itself: the positions go back to the state before."""
1367
+ if version in box["defective"]:
1368
+ return
1369
+ box["defective"].append(version)
1370
+ restored = []
1371
+ for key, position in box["positions"].items():
1372
+ if position.get("version") != version:
1373
+ continue
1374
+ history = position.get("history", [])
1375
+ before = history.pop() if history else {"value": None, "version": None}
1376
+ # a deliberate change: the library wins against the box
1377
+ _changed(position, LIBRARY, position["rev"], f"defective version {version}")
1378
+ position.update({"value": before["value"], "version": before["version"]})
1379
+ restored.append(key)
1380
+ discarded = self.journal.discard(box["id"], version)
1381
+ self._logger.warning(
1382
+ "box %s reported version %s as defective: %d positions set back, %d journal entries flagged",
1383
+ box["id"], version, len(restored), discarded,
1384
+ ) # fmt: skip
1385
+ self.journal.record("defective", box["id"], {"version": version, "positions": restored})
1386
+ self._notify("catalog")