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,489 @@
1
+ """What a box stores for the synchronization (docs/developer/library.md, section 4.2).
2
+
3
+ - The copy: the catalog, and the positions and settings of this box, exactly as the library delivered them.
4
+ - The outbox: what was changed on this box and is not confirmed by the library yet, in order.
5
+ - What the box works with is the copy with the outbox applied on top (:meth:`BoxData.working`). The json files of
6
+ ``tinyjukebox.data`` are generated from it and can be deleted.
7
+ - The journal: lines that are only appended, numbered, and deleted when the library has confirmed them (section 7).
8
+
9
+ The lock is never held while ``tinyjukebox.data`` is called; ``changed`` announces that the working data differs.
10
+ """
11
+
12
+ import contextlib
13
+ import json
14
+ import logging
15
+ import os
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable
19
+ from typing import Any
20
+
21
+ from tinyjukebox.data import degraded
22
+ from tinyjukebox.data.common import is_unmapped
23
+ from tinyjukebox.sync import KINDS, SOFTWARE, actor
24
+ from tinyjukebox.sync.util import read_json, sha256_bytes, timestamp, write_atomic, write_json
25
+
26
+ __all__ = ["CATALOG_FILES", "COPY", "IMAGES", "JOURNAL_LIMIT", "RECOVERY_GAP", "BoxData", "Replaced"]
27
+
28
+ COPY = {"tag": "tags.json", "target": "targets.json"}
29
+ IMAGES = "images.json" # the covers of albums and stations that the library has: no entries, a list of files
30
+ CATALOG_FILES = (*COPY.values(), IMAGES)
31
+ STATE = "box.json" # positions and settings of this box
32
+ JOURNAL_LIMIT = 1000 # lines a box keeps that has no library, like a log file
33
+ STARTED = time.monotonic()
34
+ RECOVERY_GAP = "recovery_gap"
35
+ """The event of a journal line that stands for a lost line: no listening activity, only the number."""
36
+
37
+
38
+ def _lines(filename: str) -> tuple[list[dict[str, Any]], int]:
39
+ """The lines of a file that are complete, and how many are not: a power cut or a damaged card may leave a line
40
+ half written or filled with zeros. The other lines stay usable (docs/developer/library.md, section 4.21)."""
41
+ try:
42
+ with open(filename, "rb") as infile:
43
+ raw = infile.read()
44
+ except FileNotFoundError:
45
+ return [], 0
46
+ lines, damaged = [], 0
47
+ for text in raw.split(b"\n"):
48
+ if not text.strip(b" \t\r\0"):
49
+ damaged += bool(text.strip(b" \t\r")) # zeros only: a line that never reached the card
50
+ continue
51
+ try:
52
+ line = json.loads(text)
53
+ except ValueError: # also a line that is no UTF-8
54
+ damaged += 1
55
+ continue
56
+ if isinstance(line, dict) and isinstance(line.get("n"), int):
57
+ lines.append(line)
58
+ else:
59
+ damaged += 1
60
+ return lines, damaged
61
+
62
+
63
+ def _write_lines(filename: str, lines: list[dict[str, Any]]) -> None:
64
+ write_atomic(filename, "".join(json.dumps(line) + "\n" for line in lines))
65
+
66
+
67
+ def _append(filename: str, line: dict[str, Any]) -> None:
68
+ with open(filename, "a", encoding="utf-8") as outfile:
69
+ outfile.write(json.dumps(line) + "\n")
70
+ outfile.flush()
71
+ os.fsync(outfile.fileno())
72
+
73
+
74
+ def _ranges(numbers: list[int]) -> str:
75
+ """1, 2, 3, 7 -> '1-3, 7'"""
76
+ result: list[str] = []
77
+ for number in numbers:
78
+ if result and number == int(result[-1].split("-")[-1]) + 1:
79
+ result[-1] = f"{result[-1].split('-')[0]}-{number}"
80
+ else:
81
+ result.append(str(number))
82
+ return ", ".join(result)
83
+
84
+
85
+ class Replaced:
86
+ """What a new file of the copy means for the box."""
87
+
88
+ def __init__(self) -> None:
89
+ self.lost: list[tuple[str, str, Any, int]] = [] # (kind, key, value, revision) the library lost
90
+ self.removed: list[dict[str, Any]] = [] # targets that the library deleted, as the box had them
91
+
92
+
93
+ class BoxData:
94
+ def __init__(
95
+ self,
96
+ folder: str,
97
+ box_id: str,
98
+ version: str,
99
+ logger: logging.Logger,
100
+ clock_synchronized: Callable[[], bool] = lambda: True,
101
+ changed: Callable[[], None] | None = None,
102
+ ) -> None:
103
+ self.lock = threading.RLock()
104
+ self.box_id = box_id
105
+ self.version = version
106
+ self.changed = changed
107
+ self._logger = logger
108
+ self._clock_synchronized = clock_synchronized
109
+ self._folder = folder
110
+ os.makedirs(os.path.join(folder, "copy"), exist_ok=True)
111
+ self._copy: dict[str, Any] = {}
112
+ self._raw: dict[str, bytes] = {}
113
+ for name in (*CATALOG_FILES, STATE):
114
+ self._load_copy(name)
115
+ with contextlib.suppress(FileNotFoundError): # until 0.28: the card images of tags and the list of the images
116
+ os.remove(os.path.join(folder, "copy", "cards.json"))
117
+ # the last numbers this box assigned: a number is never assigned twice
118
+ self.numbers: dict[str, int] = {
119
+ "outbox": 0, "target": 0, "journal": 0, "start": 0, "history": 0,
120
+ **read_json(self._file("numbers.json"), {}),
121
+ } # fmt: skip
122
+ self.fresh = not os.path.exists(self._file("numbers.json")) # nothing was ever stored here
123
+ self._ids: dict[str, str] = read_json(self._file("ids.json"), {}) # provisional id -> final id, while needed
124
+ self.numbers["start"] += 1
125
+ self._save_numbers()
126
+ self._outbox, damaged = _lines(self._file("outbox"))
127
+ if damaged:
128
+ message = f"outbox: {damaged} damaged change(s) of this box left out - they do not reach the library"
129
+ logger.error(message)
130
+ degraded.note("outbox", "", message)
131
+ _write_lines(self._file("outbox"), self._outbox)
132
+ self._journal = self._load_journal()
133
+
134
+ def _file(self, name: str) -> str:
135
+ return os.path.join(self._folder, name)
136
+
137
+ def _save_numbers(self) -> None:
138
+ write_json(self._file("numbers.json"), self.numbers)
139
+
140
+ def _tell(self) -> None:
141
+ if self.changed is not None:
142
+ self.changed()
143
+
144
+ # --- copy --------------------------------------------------------------------------------------------------------
145
+
146
+ def _load_copy(self, name: str) -> None:
147
+ empty: dict[str, Any] = {"positions": {}, "settings": {}} if name == STATE else {"entries": {}}
148
+ try:
149
+ with open(os.path.join(self._folder, "copy", name), "rb") as infile:
150
+ self._raw[name] = infile.read()
151
+ self._copy[name] = json.loads(self._raw[name])
152
+ fields = ("positions", "settings") if name == STATE else () if name == IMAGES else ("entries",)
153
+ if not isinstance(self._copy[name], dict) or not all(
154
+ isinstance(self._copy[name].get(field), dict) for field in fields
155
+ ):
156
+ raise ValueError("not what the library delivers")
157
+ except FileNotFoundError:
158
+ self._raw[name] = b""
159
+ self._copy[name] = empty
160
+ except ValueError as error:
161
+ # the library delivers it again: the box asks for a file whose checksum differs from its own
162
+ self._logger.error("copy/%s cannot be read (%s) - the library delivers it again", name, error)
163
+ self._raw[name] = b""
164
+ self._copy[name] = empty
165
+
166
+ def _copied(self, kind: str) -> dict[str, Any]:
167
+ """The entries of ``kind`` in the copy, each with ``value`` and ``rev``."""
168
+ if kind in COPY:
169
+ return self._copy[COPY[kind]]["entries"] # type: ignore[no-any-return]
170
+ return self._copy[STATE][f"{kind}s"] # type: ignore[no-any-return]
171
+
172
+ def checksum(self, name: str) -> str:
173
+ with self.lock:
174
+ return sha256_bytes(self._raw[name])
175
+
176
+ def replace_copy(self, name: str, data: bytes) -> Replaced:
177
+ """Store a file as the library delivered it. The outbox stays on top, so no own change is lost.
178
+
179
+ For the positions and settings of this box the result lists what the library has with a lower revision than
180
+ the copy had: the library lost data, e.g. it was restored from a backup, and the box wins (section 4.12).
181
+ """
182
+ result = Replaced()
183
+ with self.lock:
184
+ old = self._copy[name]
185
+ json.loads(data) # a file that is not JSON never becomes the copy
186
+ write_atomic(os.path.join(self._folder, "copy", name), data)
187
+ self._load_copy(name)
188
+ new = self._copy[name]
189
+ if name == STATE:
190
+ for kind in ("position", "setting"):
191
+ for key, entry in old[f"{kind}s"].items():
192
+ if new[f"{kind}s"].get(key, {"rev": 0})["rev"] < entry["rev"]:
193
+ result.lost.append((kind, key, entry["value"], entry["rev"]))
194
+ # the highest provisional number the library knows of this box
195
+ self.numbers["target"] = max(self.numbers["target"], new.get("provisional", {}).get("target", 0))
196
+ self._save_numbers()
197
+ elif name == COPY["target"]:
198
+ for key, entry in old["entries"].items():
199
+ if entry["value"] is not None and new["entries"].get(key, {"value": None})["value"] is None:
200
+ result.removed.append(entry["value"])
201
+ self._tell()
202
+ return result
203
+
204
+ def album(self, key: str) -> dict[str, Any] | None:
205
+ """What the catalog says about the files of an album: identity, number and size, what the library lacks."""
206
+ with self.lock:
207
+ entry = self._copied("target").get(key)
208
+ return dict(entry["album"]) if entry is not None and "album" in entry else None
209
+
210
+ def images(self) -> dict[str, Any]:
211
+ """The covers that the library has: checksum and size each, or the mark of a removed one."""
212
+ with self.lock:
213
+ return dict(self._copy[IMAGES].get("images", {}))
214
+
215
+ # --- outbox ------------------------------------------------------------------------------------------------------
216
+
217
+ def new_target_id(self) -> str:
218
+ """A provisional id: the internal id of the box and a running number (section 4.6)."""
219
+ with self.lock:
220
+ self.numbers["target"] += 1
221
+ self._save_numbers()
222
+ return f"{self.box_id}-{self.numbers['target']}"
223
+
224
+ def is_provisional(self, value: Any) -> bool:
225
+ return isinstance(value, str) and value.startswith(f"{self.box_id}-")
226
+
227
+ def add(
228
+ self, kind: str, key: str, value: Any, by: str | None = None, base: int | None = None, **fields: Any
229
+ ) -> dict[str, Any]:
230
+ """A change made on this box: the entry ``key`` of ``kind`` is ``value`` now (None: deleted).
231
+
232
+ The entry carries a running number, the revision it is based on, who made it and with which version. An
233
+ earlier change of the same entry that is still in the outbox is replaced. A target that the library has
234
+ never seen is created there, with its provisional id.
235
+
236
+ ``fields`` are stored with the entry in the same step, e.g. the manifest of an album whose files changed: a
237
+ pass that runs at this moment never sends the entry without it. A manifest that an earlier change of the
238
+ entry carries and that was not sent yet stays with the entry.
239
+ """
240
+ if kind not in KINDS:
241
+ raise ValueError(f"unknown kind '{kind}'")
242
+ with self.lock:
243
+ copied = self._copied(kind).get(self.final(key))
244
+ created = [entry for entry in self._outbox if entry["op"] == "create" and entry["key"] == key]
245
+ creates = kind == "target" and copied is None and not created
246
+ if creates and value is None:
247
+ return {}
248
+ before = len(self._outbox)
249
+ if created and value is None:
250
+ # created and deleted before the library heard of it: nothing is left to tell
251
+ self._outbox = [e for e in self._outbox if (e["kind"], e["key"]) != (kind, key)]
252
+ _write_lines(self._file("outbox"), self._outbox)
253
+ return {}
254
+ replaced = [e for e in self._outbox if (e["op"], e["kind"], e["key"]) == ("set", kind, key)]
255
+ if replaced and "manifest" in replaced[-1] and value is not None:
256
+ fields.setdefault("manifest", replaced[-1]["manifest"])
257
+ self._outbox = [e for e in self._outbox if (e["op"], e["kind"], e["key"]) != ("set", kind, key)]
258
+ self.numbers["outbox"] += 1
259
+ self._save_numbers()
260
+ entry = {
261
+ "n": self.numbers["outbox"], "op": "create" if creates else "set", "kind": kind, "key": key,
262
+ "value": value, "by": by or actor(), "version": self.version,
263
+ "clock": {"time": timestamp(), "synchronized": self._clock_synchronized()},
264
+ } # fmt: skip
265
+ if not creates:
266
+ entry["base"] = (copied["rev"] if copied else 0) if base is None else base
267
+ entry.update(fields)
268
+ self._outbox.append(entry)
269
+ if len(self._outbox) == before + 1:
270
+ _append(self._file("outbox"), entry)
271
+ else:
272
+ _write_lines(self._file("outbox"), self._outbox)
273
+ return entry
274
+
275
+ def clear_outbox(self) -> None:
276
+ with self.lock:
277
+ self._outbox = []
278
+ _write_lines(self._file("outbox"), self._outbox)
279
+
280
+ def forget_library(self) -> None:
281
+ """The box leaves its library: the copy, the outbox and the ids that wait to be settled go. The numbers
282
+ stay - a number is never assigned twice, also not for a library the box meets later."""
283
+ with self.lock:
284
+ for name in (*CATALOG_FILES, STATE):
285
+ with contextlib.suppress(FileNotFoundError):
286
+ os.remove(os.path.join(self._folder, "copy", name))
287
+ self._load_copy(name)
288
+ self._ids = {}
289
+ write_json(self._file("ids.json"), self._ids)
290
+ self.clear_outbox()
291
+
292
+ def outbox(self) -> list[dict[str, Any]]:
293
+ with self.lock:
294
+ return [dict(entry) for entry in self._outbox]
295
+
296
+ def amend(self, number: int, **fields: Any) -> None:
297
+ """Add something to an entry that is not sent yet, e.g. the manifest of a new album."""
298
+ with self.lock:
299
+ for entry in self._outbox:
300
+ if entry["n"] == number:
301
+ entry.update(fields)
302
+ _write_lines(self._file("outbox"), self._outbox)
303
+
304
+ def confirm(self, numbers: set[int]) -> None:
305
+ """The library answered these entries, and the copy has what it made of them: they leave the outbox."""
306
+ with self.lock:
307
+ if not numbers:
308
+ return
309
+ self._outbox = [entry for entry in self._outbox if entry["n"] not in numbers]
310
+ _write_lines(self._file("outbox"), self._outbox)
311
+ self._forget_ids()
312
+ self._tell()
313
+
314
+ def discard_version(self, version: str) -> list[dict[str, Any]]:
315
+ """Drop what a defective version left in the outbox; return what a person had entered with it (4.11)."""
316
+ with self.lock:
317
+ dropped = [entry for entry in self._outbox if entry["version"] == version]
318
+ self.confirm({entry["n"] for entry in dropped})
319
+ return [entry for entry in dropped if entry["by"] != SOFTWARE]
320
+
321
+ # --- provisional ids ---------------------------------------------------------------------------------------------
322
+
323
+ def final(self, value: Any) -> Any:
324
+ """The final id behind a provisional one, as far as the library told it."""
325
+ return self._ids.get(value, value) if isinstance(value, str) else value
326
+
327
+ def _with_final_ids(self, kind: str, value: Any) -> Any:
328
+ if not isinstance(value, dict):
329
+ return value
330
+ if kind == "target":
331
+ return {**value, "target-id": self.final(value["target-id"])}
332
+ if kind == "tag" and not is_unmapped(value["target"].get("id")):
333
+ return {**value, "target": {**value["target"], "id": self.final(value["target"]["id"])}}
334
+ if kind == "position":
335
+ return {**value, "target-id": self.final(value["target-id"])}
336
+ return value
337
+
338
+ def learn(self, provisional: str, final: str, value: dict[str, Any] | None = None) -> None:
339
+ """Apply the final id that the library assigned, and what it made of the target (the folder of an album may
340
+ have another name there). The entry that created it keeps its provisional id on the way to the library."""
341
+ with self.lock:
342
+ self._ids[provisional] = final
343
+ write_json(self._file("ids.json"), self._ids)
344
+ if value is not None:
345
+ for entry in self._outbox:
346
+ if entry["op"] == "create" and entry["key"] == provisional:
347
+ entry["value"] = {**value, "target-id": provisional}
348
+ _write_lines(self._file("outbox"), self._outbox)
349
+ self._tell()
350
+
351
+ def _forget_ids(self) -> None:
352
+ used = self.provisional_ids(settled=True)
353
+ self._ids = {provisional: final for provisional, final in self._ids.items() if provisional in used}
354
+ write_json(self._file("ids.json"), self._ids)
355
+
356
+ def provisional_ids(self, settled: bool = False) -> list[str]:
357
+ """Provisional ids that still occur in the outbox: not settled with the library yet (``settled``: also the
358
+ ones whose final id is known)."""
359
+ with self.lock:
360
+ found: dict[str, None] = {}
361
+ for entry in self._outbox:
362
+ value = entry.get("value")
363
+ candidates: list[Any] = [entry["key"] if entry["kind"] == "target" else None]
364
+ if isinstance(value, dict) and entry["kind"] == "tag":
365
+ candidates.append(value["target"].get("id"))
366
+ elif isinstance(value, dict) and entry["kind"] == "position":
367
+ candidates.append(value["target-id"])
368
+ for candidate in candidates:
369
+ if self.is_provisional(candidate) and (settled or candidate not in self._ids):
370
+ found[str(candidate)] = None
371
+ return list(found)
372
+
373
+ # --- what the box works with -------------------------------------------------------------------------------------
374
+
375
+ def working(self, kind: str) -> dict[str, Any]:
376
+ """The entries of ``kind`` the box works with: the copy with the outbox applied on top."""
377
+ with self.lock:
378
+ result = {key: entry["value"] for key, entry in self._copied(kind).items() if entry["value"] is not None}
379
+ for entry in self._outbox:
380
+ if entry["kind"] != kind:
381
+ continue
382
+ key = self.final(entry["key"]) if kind == "target" else entry["key"]
383
+ if entry["value"] is None:
384
+ result.pop(key, None)
385
+ else:
386
+ result[key] = self._with_final_ids(kind, entry["value"])
387
+ return result
388
+
389
+ def renamed(self) -> dict[str, str]:
390
+ """Provisional ids of targets and the final ids that replace them in the working data."""
391
+ with self.lock:
392
+ return dict(self._ids)
393
+
394
+ # --- journal -----------------------------------------------------------------------------------------------------
395
+
396
+ def _line(self, number: int, event: str, **fields: Any) -> dict[str, Any]:
397
+ return {
398
+ "n": number, "start": self.numbers["start"], "uptime": round(time.monotonic() - STARTED, 1),
399
+ "clock": {"time": timestamp(), "synchronized": self._clock_synchronized()}, "version": self.version,
400
+ "event": event, **fields,
401
+ } # fmt: skip
402
+
403
+ def _gaps(self, numbers: range) -> list[dict[str, Any]]:
404
+ """Lines for numbers whose lines are lost: a mark that statistics ignore and that lets the library go on.
405
+ What was lost is not invented (docs/developer/library.md, section 7)."""
406
+ return [self._line(number, RECOVERY_GAP) for number in numbers]
407
+
408
+ def _load_journal(self) -> list[dict[str, Any]]:
409
+ """The lines that are complete. A number without its line - a damaged line, or a number that was reserved
410
+ but whose line never reached the card - becomes a ``recovery_gap``, and the file is written anew."""
411
+ lines, damaged = _lines(self._file("journal"))
412
+ result: list[dict[str, Any]] = []
413
+ lost: list[int] = []
414
+ for line in lines:
415
+ if result and line["n"] <= result[-1]["n"]:
416
+ damaged += 1 # not in order: a line that cannot be placed
417
+ continue
418
+ if result:
419
+ lost.extend(range(result[-1]["n"] + 1, line["n"]))
420
+ result.extend(self._gaps(range(result[-1]["n"] + 1, line["n"])))
421
+ result.append(line)
422
+ if result:
423
+ last = result[-1]["n"]
424
+ if last > self.numbers["journal"]:
425
+ self.numbers["journal"] = last
426
+ self._save_numbers()
427
+ lost.extend(range(last + 1, self.numbers["journal"] + 1))
428
+ result.extend(self._gaps(range(last + 1, self.numbers["journal"] + 1)))
429
+ if damaged or lost:
430
+ message = f"journal: {damaged} damaged line(s); lines lost: {_ranges(lost) or 'none'}"
431
+ self._logger.error("%s - noted as %s", message, RECOVERY_GAP)
432
+ degraded.note("journal", str(self.numbers["start"]), message)
433
+ _write_lines(self._file("journal"), result)
434
+ return result
435
+
436
+ def journal(self, event: str, **fields: Any) -> None:
437
+ """Append a line. It carries the start of the box, a running number, the time since the start, and whether
438
+ the clock was synchronized - order is never decided by the clock."""
439
+ with self.lock:
440
+ self.numbers["journal"] += 1
441
+ self._save_numbers()
442
+ line = self._line(self.numbers["journal"], event, **fields)
443
+ self._journal.append(line)
444
+ _append(self._file("journal"), line) # on the card before the next line: the power is simply cut
445
+
446
+ def journal_gap(self, confirmed: int) -> None:
447
+ """The library has the lines up to ``confirmed``, and the box has none of the lines after it up to its first
448
+ one: they are lost on both sides (a damaged journal, a library restored from a backup, a box new to its
449
+ library). They become ``recovery_gap``, so that the library can take the lines that follow."""
450
+ with self.lock:
451
+ if not self._journal or self._journal[0]["n"] <= confirmed + 1:
452
+ return
453
+ numbers = range(confirmed + 1, self._journal[0]["n"])
454
+ self._logger.warning("journal: lines %s are lost - noted as %s", _ranges(list(numbers)), RECOVERY_GAP)
455
+ self._journal = self._gaps(numbers) + self._journal
456
+ _write_lines(self._file("journal"), self._journal)
457
+
458
+ def journal_lines(self, limit: int | None = 200) -> list[dict[str, Any]]:
459
+ """The oldest lines the box still has; ``None``: all of them."""
460
+ with self.lock:
461
+ return self._journal[:limit]
462
+
463
+ def replace_journal(self, lines: list[dict[str, Any]]) -> None:
464
+ """The lines of a backup that is put back (a box without a library) replace the journal. Numbers and starts
465
+ go on after theirs: a line that is written later never looks older."""
466
+ with self.lock:
467
+ self._journal = list(lines)
468
+ _write_lines(self._file("journal"), self._journal)
469
+ for key, field in (("journal", "n"), ("start", "start")):
470
+ numbers = [line[field] for line in lines if isinstance(line.get(field), int)]
471
+ self.numbers[key] = max([self.numbers[key], *numbers])
472
+ self._save_numbers()
473
+
474
+ def journal_confirmed(self, number: int) -> None:
475
+ """The library has the lines up to ``number``: they are deleted here."""
476
+ with self.lock:
477
+ self._journal = [line for line in self._journal if line["n"] > number]
478
+ _write_lines(self._file("journal"), self._journal)
479
+
480
+ def journal_limit(self) -> None:
481
+ """Without a library the journal is kept short, like a log file."""
482
+ with self.lock:
483
+ if len(self._journal) > JOURNAL_LIMIT:
484
+ self.journal_confirmed(self._journal[-JOURNAL_LIMIT]["n"] - 1)
485
+
486
+ def history_reported(self, number: int) -> None:
487
+ with self.lock, contextlib.suppress(ValueError):
488
+ self.numbers["history"] = int(number)
489
+ self._save_numbers()