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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
tinyjukebox/sync/data.py
ADDED
|
@@ -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()
|