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