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
|
@@ -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")
|