tinyjukebox 0.47.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
tinyjukebox/sync/link.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""The synchronization of a box with the library (docs/developer/library.md, section 4).
|
|
2
|
+
|
|
3
|
+
The box always opens the connection. One pass: who are you and do we fit together; send the outbox; fetch what
|
|
4
|
+
differs; settle provisional ids and files; deliver the journal; report the status. A pass runs on start, on an event
|
|
5
|
+
of the library and after a change on the box; without a connection it is tried again.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from tinyjukebox.sync import INTERFACE, handshake
|
|
15
|
+
from tinyjukebox.sync.client import Client, HttpError, Unreachable, WrongCertificate
|
|
16
|
+
from tinyjukebox.sync.data import CATALOG_FILES, COPY, STATE
|
|
17
|
+
from tinyjukebox.sync.util import sha256_bytes, write_atomic
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from tinyjukebox.sync.box import BoxSync
|
|
21
|
+
|
|
22
|
+
__all__ = ["FITS", "Link", "V"]
|
|
23
|
+
|
|
24
|
+
V = f"/api/sync/v{INTERFACE}"
|
|
25
|
+
FITS = "ok"
|
|
26
|
+
IDLE_SECONDS = 60.0 # a pass at least this often while nothing happens
|
|
27
|
+
BATCH = 20 # entries of the outbox in one request
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Alone(Exception): # noqa: N818
|
|
31
|
+
"""The box works without the library for now; the text says why."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Link:
|
|
35
|
+
def __init__(self, box: "BoxSync") -> None:
|
|
36
|
+
self._box = box
|
|
37
|
+
self._data = box.data
|
|
38
|
+
self._logger = box.logger
|
|
39
|
+
self._identity = box.identity
|
|
40
|
+
self._wake = threading.Event()
|
|
41
|
+
self._stop = threading.Event()
|
|
42
|
+
self._passed = threading.Condition()
|
|
43
|
+
self._passes = 0
|
|
44
|
+
self._threads: list[threading.Thread] = []
|
|
45
|
+
self._said = "" # the reason for working alone that was logged last
|
|
46
|
+
self._reported: Any = None
|
|
47
|
+
self._checked = False # whether the versions were looked at since the connection came back
|
|
48
|
+
self._unheard = False # the library does not take diagnostics (older than 0.41): asked again after a break
|
|
49
|
+
self.connected = False
|
|
50
|
+
self.alone = "" # why the box works without the library, for its page
|
|
51
|
+
self.alone_code = "" # ... as a code, if the page has a text for this reason
|
|
52
|
+
self.client: Client | None = None
|
|
53
|
+
self._listening: Client | None = None # the client whose events are read
|
|
54
|
+
|
|
55
|
+
# --- threads -----------------------------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def start(self) -> None:
|
|
58
|
+
self._stop.clear()
|
|
59
|
+
self._threads = [
|
|
60
|
+
threading.Thread(target=self._run, name="Link.sync", daemon=True),
|
|
61
|
+
threading.Thread(target=self._listen, name="Link.events", daemon=True),
|
|
62
|
+
]
|
|
63
|
+
for thread in self._threads:
|
|
64
|
+
thread.start()
|
|
65
|
+
|
|
66
|
+
def stop(self) -> None:
|
|
67
|
+
self._stop.set()
|
|
68
|
+
self._wake.set()
|
|
69
|
+
listening = self._listening
|
|
70
|
+
if listening is not None:
|
|
71
|
+
listening.interrupt()
|
|
72
|
+
for thread in self._threads:
|
|
73
|
+
thread.join(timeout=5)
|
|
74
|
+
self._threads = []
|
|
75
|
+
self.connected = False
|
|
76
|
+
|
|
77
|
+
def wake(self) -> None:
|
|
78
|
+
self._wake.set()
|
|
79
|
+
|
|
80
|
+
def wait_pass(self, timeout: float) -> bool:
|
|
81
|
+
"""Trigger a pass and wait until it is over."""
|
|
82
|
+
with self._passed:
|
|
83
|
+
before = self._passes
|
|
84
|
+
self._wake.set()
|
|
85
|
+
return self._passed.wait_for(lambda: self._passes > before, timeout)
|
|
86
|
+
|
|
87
|
+
def _run(self) -> None:
|
|
88
|
+
while not self._stop.is_set():
|
|
89
|
+
self._wake.clear()
|
|
90
|
+
again = False
|
|
91
|
+
try:
|
|
92
|
+
again = self._pass()
|
|
93
|
+
self._said = self.alone = self.alone_code = ""
|
|
94
|
+
except _Alone as alone:
|
|
95
|
+
self._alone(str(alone))
|
|
96
|
+
except Unreachable as error:
|
|
97
|
+
self._alone(f"the library cannot be reached: {error}", code="unreachable")
|
|
98
|
+
except WrongCertificate as error:
|
|
99
|
+
self._alone(str(error), error=True, code="certificate")
|
|
100
|
+
except HttpError as error:
|
|
101
|
+
self._alone(f"the library answered {error}", error=error.status != 410, code="answered")
|
|
102
|
+
except Exception as error:
|
|
103
|
+
self._box.unexpected(error)
|
|
104
|
+
with self._passed:
|
|
105
|
+
self._passes += 1
|
|
106
|
+
self._passed.notify_all()
|
|
107
|
+
if again:
|
|
108
|
+
continue
|
|
109
|
+
# without a connection, and while something waits to be settled, the box tries again by itself
|
|
110
|
+
pending = not self.connected or bool(self._data.outbox()) or bool(self._data.journal_lines(1))
|
|
111
|
+
self._wake.wait(self._box.retry_seconds if pending else IDLE_SECONDS)
|
|
112
|
+
|
|
113
|
+
def _alone(self, reason: str, error: bool = False, code: str = "") -> None:
|
|
114
|
+
self.connected = False
|
|
115
|
+
self._checked = False
|
|
116
|
+
self._unheard = False
|
|
117
|
+
self.alone = reason
|
|
118
|
+
self.alone_code = code
|
|
119
|
+
if reason != self._said: # said once, not with every attempt
|
|
120
|
+
self._logger.log(40 if error else 20, "working without the library: %s", reason)
|
|
121
|
+
self._said = reason
|
|
122
|
+
|
|
123
|
+
def _listen(self) -> None:
|
|
124
|
+
"""Keep a connection open on which the library announces changes (section 4.9)."""
|
|
125
|
+
while not self._stop.is_set():
|
|
126
|
+
client = self.client
|
|
127
|
+
if client is not None and self.connected and self._box.fit == FITS:
|
|
128
|
+
self._listening = client
|
|
129
|
+
with contextlib.suppress(Unreachable, WrongCertificate, HttpError):
|
|
130
|
+
for event, _ in client.events(f"{V}/events"):
|
|
131
|
+
if self._stop.is_set():
|
|
132
|
+
return
|
|
133
|
+
# "connected" comes first: what changed while nobody listened is caught up by a pass
|
|
134
|
+
self._logger.log(10 if event == "connected" else 20, "event of the library: %s", event)
|
|
135
|
+
if event == "versions":
|
|
136
|
+
self._checked = False
|
|
137
|
+
self._wake.set()
|
|
138
|
+
self._wake.set() # the connection broke: the pass finds out whether the library is gone
|
|
139
|
+
self._stop.wait(self._box.retry_seconds)
|
|
140
|
+
else:
|
|
141
|
+
self._stop.wait(0.2)
|
|
142
|
+
|
|
143
|
+
# --- identity and access (sections 4.7, 4.14) --------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def _read(self, name: str) -> str:
|
|
146
|
+
try:
|
|
147
|
+
with open(os.path.join(self._identity, name), encoding="utf-8") as infile:
|
|
148
|
+
return infile.read().strip()
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
def _connect(self) -> Client:
|
|
153
|
+
url = self._read("library")
|
|
154
|
+
if not url:
|
|
155
|
+
raise _Alone("no library is configured")
|
|
156
|
+
if not self._read("key"):
|
|
157
|
+
self._handshake(url)
|
|
158
|
+
headers = {
|
|
159
|
+
"Authorization": f"Box {self._data.box_id}:{self._read('key')}",
|
|
160
|
+
"X-Box-Version": self._box.version,
|
|
161
|
+
"X-Box-Interface": str(INTERFACE),
|
|
162
|
+
}
|
|
163
|
+
if self._box.page_port: # where the page of the library links to the page of this box
|
|
164
|
+
headers["X-Box-Page"] = str(self._box.page_port)
|
|
165
|
+
with open(os.path.join(self._identity, "library.crt"), encoding="utf-8") as infile:
|
|
166
|
+
return Client(url, certificate=infile.read(), headers=headers)
|
|
167
|
+
|
|
168
|
+
def _handshake(self, url: str) -> None:
|
|
169
|
+
"""Prove the bootstrap password to the library and get a key of its own; check and store its certificate."""
|
|
170
|
+
password = self._read("bootstrap")
|
|
171
|
+
if not password:
|
|
172
|
+
raise _Alone("this box has no key for the library and no bootstrap password")
|
|
173
|
+
box_id, nonce = self._data.box_id, handshake.new_nonce()
|
|
174
|
+
first = Client(url)
|
|
175
|
+
nonce_library = first.post("/api/sync/boxes", {"box": box_id, "nonce": nonce})["nonce"]
|
|
176
|
+
certificate = first.peer_certificate or ""
|
|
177
|
+
proof = handshake.proof(password, "box", box_id, nonce, nonce_library, certificate)
|
|
178
|
+
try:
|
|
179
|
+
answer = Client(url, certificate=certificate).post(
|
|
180
|
+
"/api/sync/boxes", {"box": box_id, "proof": proof, "name": socket.gethostname()}
|
|
181
|
+
)
|
|
182
|
+
except HttpError as error:
|
|
183
|
+
if error.status == 401:
|
|
184
|
+
raise WrongCertificate(
|
|
185
|
+
"the handshake was rejected: wrong bootstrap password, or not the library"
|
|
186
|
+
) from None
|
|
187
|
+
raise
|
|
188
|
+
expected = handshake.proof(password, "library", box_id, nonce, nonce_library, certificate)
|
|
189
|
+
if not handshake.same(str(answer.get("proof")), expected):
|
|
190
|
+
raise WrongCertificate("the handshake failed: the other side does not know the bootstrap password")
|
|
191
|
+
os.makedirs(self._identity, mode=0o700, exist_ok=True)
|
|
192
|
+
write_atomic(os.path.join(self._identity, "library.crt"), certificate)
|
|
193
|
+
key = os.path.join(self._identity, "key")
|
|
194
|
+
write_atomic(key, answer["key"] + "\n")
|
|
195
|
+
os.chmod(key, 0o600)
|
|
196
|
+
os.remove(os.path.join(self._identity, "bootstrap")) # this box does not need the password any more
|
|
197
|
+
self._logger.info(
|
|
198
|
+
"handshake with the library done: this box has its own key and the certificate of the library"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _ask_interface(self) -> list[int]:
|
|
202
|
+
"""Which versions of the interface the library serves. Repeats the handshake if the library refuses the key
|
|
203
|
+
or shows another certificate - which works only on a box that was given the bootstrap password again."""
|
|
204
|
+
try:
|
|
205
|
+
self.client = self._connect()
|
|
206
|
+
return list(self.client.get("/api/sync/interface")["serves"])
|
|
207
|
+
except (WrongCertificate, HttpError) as error:
|
|
208
|
+
if isinstance(error, HttpError) and error.status != 401:
|
|
209
|
+
raise
|
|
210
|
+
if not self._read("bootstrap"):
|
|
211
|
+
if isinstance(error, HttpError):
|
|
212
|
+
raise WrongCertificate("the library rejects the key of this box") from None
|
|
213
|
+
raise
|
|
214
|
+
self._handshake(self._read("library"))
|
|
215
|
+
self.client = self._connect()
|
|
216
|
+
return list(self.client.get("/api/sync/interface")["serves"])
|
|
217
|
+
|
|
218
|
+
# --- one pass ----------------------------------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _pass(self) -> bool:
|
|
221
|
+
"""One pass; returns whether another one should follow at once."""
|
|
222
|
+
serves = self._ask_interface()
|
|
223
|
+
client = self.client
|
|
224
|
+
assert client is not None # noqa: S101
|
|
225
|
+
if not self.connected:
|
|
226
|
+
self._logger.info("connected to the library")
|
|
227
|
+
self.connected = True
|
|
228
|
+
if max(serves) < INTERFACE:
|
|
229
|
+
# only a person can repair this: the box stops working (section 4.15)
|
|
230
|
+
self._box.set_fit(f"library-too-old: this box speaks version {INTERFACE}, the library serves {serves}")
|
|
231
|
+
return False
|
|
232
|
+
if min(serves) > INTERFACE:
|
|
233
|
+
# the box repairs this itself: it works alone and fetches the newest version
|
|
234
|
+
self._box.set_fit(f"box-too-old: this box speaks version {INTERFACE}, the library serves {serves}")
|
|
235
|
+
self._look_for_versions()
|
|
236
|
+
return False
|
|
237
|
+
self._box.set_fit(FITS)
|
|
238
|
+
self._look_for_versions()
|
|
239
|
+
self._box.discard_defective()
|
|
240
|
+
done = self._push(client)
|
|
241
|
+
again = self._pull(client)
|
|
242
|
+
if again is None:
|
|
243
|
+
return True
|
|
244
|
+
# an entry leaves the outbox only now: the copy has what the library made of it
|
|
245
|
+
self._data.confirm(done)
|
|
246
|
+
again = self._settle(client) or again
|
|
247
|
+
self._deliver_journal(client)
|
|
248
|
+
self._deliver_diagnostics(client)
|
|
249
|
+
self._report(client)
|
|
250
|
+
return again
|
|
251
|
+
|
|
252
|
+
def _look_for_versions(self) -> None:
|
|
253
|
+
if not self._checked:
|
|
254
|
+
self._checked = True
|
|
255
|
+
self._box.look_for_versions()
|
|
256
|
+
|
|
257
|
+
def _push(self, client: Client) -> set[int]:
|
|
258
|
+
"""Send the outbox in order; returns the numbers that the library answered (sections 4.4, 4.5)."""
|
|
259
|
+
done: set[int] = set()
|
|
260
|
+
outbox = self._data.outbox()
|
|
261
|
+
for start in range(0, len(outbox), BATCH):
|
|
262
|
+
entries = self._box.files.described(outbox[start : start + BATCH])
|
|
263
|
+
results = client.post(f"{V}/boxes/{self._data.box_id}/changes", {"entries": entries})["results"]
|
|
264
|
+
self._answered(entries, results, done)
|
|
265
|
+
return done
|
|
266
|
+
|
|
267
|
+
def _answered(self, entries: list[dict[str, Any]], results: list[dict[str, Any]], done: set[int]) -> None:
|
|
268
|
+
for entry, result in zip(entries, results, strict=True):
|
|
269
|
+
kind = result["result"]
|
|
270
|
+
if kind == "waiting":
|
|
271
|
+
continue # depends on an album whose files are not all in the library yet
|
|
272
|
+
done.add(entry["n"])
|
|
273
|
+
if kind == "applied" and entry["op"] == "create":
|
|
274
|
+
self._box.learn(entry["key"], result["id"], result.get("value"))
|
|
275
|
+
elif kind == "conflict" and entry["kind"] in COPY:
|
|
276
|
+
clock = entry["clock"]
|
|
277
|
+
self._logger.warning(
|
|
278
|
+
"conflict: this box changed %s, the library wins. The box wanted %s, the library has %s. Changed "
|
|
279
|
+
"by %s at %s (clock %s)",
|
|
280
|
+
result["what"], result["wanted"], result["library"], entry["by"], clock["time"],
|
|
281
|
+
"synchronized" if clock["synchronized"] else "not synchronized",
|
|
282
|
+
) # fmt: skip
|
|
283
|
+
self._data.journal("conflict", what=result["what"], wanted=result["wanted"], library=result["library"])
|
|
284
|
+
elif kind == "conflict":
|
|
285
|
+
self._logger.info(
|
|
286
|
+
"%s was changed in the library, its value %s stays", result["what"], result["library"]
|
|
287
|
+
)
|
|
288
|
+
elif kind == "rejected":
|
|
289
|
+
self._logger.error("the library rejected the change %s (%s): %s", entry["n"], result["reason"], entry)
|
|
290
|
+
self._data.journal("rejected", entry=entry, reason=result["reason"])
|
|
291
|
+
|
|
292
|
+
def _pull(self, client: Client) -> bool | None:
|
|
293
|
+
"""Compare the checksums of the copy with the library and fetch every file that differs, whole (4.3).
|
|
294
|
+
Returns whether another pass should follow, None if this one could not finish."""
|
|
295
|
+
for name, info in client.get(f"{V}/catalog")["files"].items():
|
|
296
|
+
if name not in CATALOG_FILES or self._data.checksum(name) == info["sha256"]:
|
|
297
|
+
continue
|
|
298
|
+
data = client.request("GET", f"{V}/catalog/{name}").body
|
|
299
|
+
if sha256_bytes(data) != info["sha256"]:
|
|
300
|
+
return None # the file changed in between: once more
|
|
301
|
+
for target in self._data.replace_copy(name, data).removed:
|
|
302
|
+
self._box.target_removed(target)
|
|
303
|
+
self._logger.info("catalog file %s fetched", name)
|
|
304
|
+
state = client.request("GET", f"{V}/boxes/{self._data.box_id}/state").body
|
|
305
|
+
again = False
|
|
306
|
+
if sha256_bytes(state) != self._data.checksum(STATE):
|
|
307
|
+
for kind, key, value, revision in self._data.replace_copy(STATE, state).lost:
|
|
308
|
+
self._logger.warning("the library lost the %s %s of this box: sent again", kind, key)
|
|
309
|
+
self._data.add(kind, key, value, base=revision)
|
|
310
|
+
again = True
|
|
311
|
+
return again
|
|
312
|
+
|
|
313
|
+
def _settle(self, client: Client) -> bool:
|
|
314
|
+
"""Ask for the final id of every provisional id that still occurs here, and send the files the library lacks
|
|
315
|
+
(sections 4.6, 4.8). The question for the id doubles as the question for completeness."""
|
|
316
|
+
again = False
|
|
317
|
+
for provisional in self._data.provisional_ids():
|
|
318
|
+
try:
|
|
319
|
+
answer = client.get(f"{V}/boxes/{self._data.box_id}/ids/target/{provisional}")
|
|
320
|
+
except HttpError as error:
|
|
321
|
+
if error.status == 404:
|
|
322
|
+
continue
|
|
323
|
+
raise
|
|
324
|
+
if "id" in answer:
|
|
325
|
+
self._box.learn(provisional, answer["id"], answer.get("value"))
|
|
326
|
+
again = True
|
|
327
|
+
elif answer.get("missing"):
|
|
328
|
+
self._box.files.send(client, provisional, answer["missing"])
|
|
329
|
+
again = True
|
|
330
|
+
again = self._box.files.balance(client) or again
|
|
331
|
+
return self._box.images.balance(client) or again
|
|
332
|
+
|
|
333
|
+
def statistics(self) -> dict[str, Any]:
|
|
334
|
+
"""What the library counted from the journal of this box (section 7): asked when the page of the box shows
|
|
335
|
+
it. The library keeps the journal, the box deletes what was confirmed."""
|
|
336
|
+
client = self.client
|
|
337
|
+
if client is None or not self.connected:
|
|
338
|
+
raise Unreachable("the library cannot be reached")
|
|
339
|
+
return dict(client.get(f"{V}/boxes/{self._data.box_id}/statistics"))
|
|
340
|
+
|
|
341
|
+
def _deliver_journal(self, client: Client) -> None:
|
|
342
|
+
"""Send the journal in batches; the library confirms up to which number it has the lines (section 7)."""
|
|
343
|
+
while lines := self._data.journal_lines():
|
|
344
|
+
confirmed = client.post(f"{V}/boxes/{self._data.box_id}/journal", {"lines": lines})["confirmed"]
|
|
345
|
+
self._data.journal_confirmed(confirmed)
|
|
346
|
+
if confirmed < lines[0]["n"] - 1:
|
|
347
|
+
# the library waits for lines this box does not have: they are lost (section 4.21)
|
|
348
|
+
self._data.journal_gap(confirmed)
|
|
349
|
+
continue
|
|
350
|
+
if confirmed < lines[-1]["n"]:
|
|
351
|
+
break
|
|
352
|
+
|
|
353
|
+
def _deliver_diagnostics(self, client: Client) -> None:
|
|
354
|
+
"""Send what the box collected of its log (section 4.22), in batches. A library before 0.41 does not know the
|
|
355
|
+
request: the records wait, bounded, until it does."""
|
|
356
|
+
collector = self._box.diagnostics
|
|
357
|
+
while collector is not None and not self._unheard:
|
|
358
|
+
records, dropped = collector.take()
|
|
359
|
+
if not records and not dropped:
|
|
360
|
+
return
|
|
361
|
+
envelope = {"boot": self._data.numbers["start"], "version": self._box.version, "records": records,
|
|
362
|
+
"dropped": dropped} # fmt: skip
|
|
363
|
+
try:
|
|
364
|
+
client.post(f"{V}/boxes/{self._data.box_id}/diagnostics", envelope)
|
|
365
|
+
except HttpError as error:
|
|
366
|
+
if error.status != 404:
|
|
367
|
+
raise
|
|
368
|
+
self._unheard = True
|
|
369
|
+
return
|
|
370
|
+
collector.delivered(records[-1]["seq"] if records else 0, dropped)
|
|
371
|
+
|
|
372
|
+
def _report(self, client: Client) -> None:
|
|
373
|
+
"""Tell the library what runs here, and what the box did to its versions since the last report."""
|
|
374
|
+
report = self._box.status_report()
|
|
375
|
+
if report == self._reported:
|
|
376
|
+
return
|
|
377
|
+
answer = client.put(f"{V}/boxes/{self._data.box_id}/status", report)
|
|
378
|
+
self._box.reported(answer)
|
|
379
|
+
self._reported = {**report, "history": [], "left": [], "defective": []}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The manifest of an album (docs/developer/library.md, section 4.8).
|
|
2
|
+
|
|
3
|
+
A manifest lists the files of the album folder with size and checksum, and the tracks in the order of the playlist.
|
|
4
|
+
Each side compares it with the files it has; a file is complete only if its checksum fits.
|
|
5
|
+
|
|
6
|
+
{"files": [{"name": "01.mp3", "size": 123, "sha256": "..."}, ...], "order": ["01.mp3", ...]}
|
|
7
|
+
|
|
8
|
+
Names are relative to the album folder, with ``/`` between folders. Files that are not in ``order`` - the playlist
|
|
9
|
+
itself, an image - are fetched before the tracks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from tinyjukebox.data.target import read_playlist
|
|
17
|
+
from tinyjukebox.sync.util import sha256_file
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"checksum",
|
|
21
|
+
"create",
|
|
22
|
+
"extras",
|
|
23
|
+
"identity",
|
|
24
|
+
"inside",
|
|
25
|
+
"missing",
|
|
26
|
+
"path",
|
|
27
|
+
"skipped",
|
|
28
|
+
"valid",
|
|
29
|
+
"valid_folder",
|
|
30
|
+
"verified",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
SKIPPED_SUFFIXES = (".bak", ".part", ".tmp")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def skipped(name: str) -> bool:
|
|
37
|
+
"""Files and folders that are no part of an album: hidden ones, leftovers, and ``@eaDir`` of the NAS."""
|
|
38
|
+
return name.startswith((".", "@")) or name.endswith(SKIPPED_SUFFIXES)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def path(folder: str, name: str) -> str:
|
|
42
|
+
"""The file ``name`` of a manifest in ``folder``."""
|
|
43
|
+
return os.path.join(folder, *name.split("/"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create(folder: str, playlist: str) -> dict[str, Any]:
|
|
47
|
+
"""The manifest of the album in ``folder``. :raises OSError: if the folder or its playlist cannot be read."""
|
|
48
|
+
names = []
|
|
49
|
+
for root, folders, files in os.walk(folder):
|
|
50
|
+
folders[:] = sorted(name for name in folders if not skipped(name))
|
|
51
|
+
relative = os.path.relpath(root, folder)
|
|
52
|
+
for name in sorted(files):
|
|
53
|
+
if not skipped(name):
|
|
54
|
+
names.append(name if relative == "." else "/".join([*relative.split(os.sep), name]))
|
|
55
|
+
files_ = [
|
|
56
|
+
{"name": name, "size": os.path.getsize(path(folder, name)), "sha256": sha256_file(path(folder, name))}
|
|
57
|
+
for name in names
|
|
58
|
+
]
|
|
59
|
+
tracks = [entry.replace(os.sep, "/") for entry in read_playlist(os.path.join(folder, playlist), playlist)]
|
|
60
|
+
return {"files": files_, "order": [name for name in dict.fromkeys(tracks) if name in names]}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _valid_name(name: Any) -> bool:
|
|
64
|
+
return (
|
|
65
|
+
isinstance(name, str)
|
|
66
|
+
and bool(name)
|
|
67
|
+
and not os.path.isabs(name)
|
|
68
|
+
and "\\" not in name
|
|
69
|
+
and all(part and part != ".." and not skipped(part) for part in name.split("/"))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def valid_folder(name: Any) -> bool:
|
|
74
|
+
"""Whether ``name`` can be the folder of an album (``album-folder``): relative to the albums folder, written the
|
|
75
|
+
way it is stored (``a/b``, no ``./`` or trailing ``/``), and below it - no ``..``, no hidden folder like the ones
|
|
76
|
+
of the uploads and of the albums on their way, no ``@eaDir`` of the NAS. What a box or the library delivers is
|
|
77
|
+
never used as a path before it passed this."""
|
|
78
|
+
return (
|
|
79
|
+
isinstance(name, str)
|
|
80
|
+
and 0 < len(name) <= 1000
|
|
81
|
+
and not os.path.isabs(name)
|
|
82
|
+
and "\\" not in name
|
|
83
|
+
and "\0" not in name
|
|
84
|
+
and os.path.normpath(name) == name
|
|
85
|
+
and all(part and part not in (".", "..") and not part.startswith((".", "@")) for part in name.split("/"))
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def inside(root: str, folder: str) -> bool:
|
|
90
|
+
"""Whether ``folder`` lies below ``root`` once symbolic links are resolved - and is not ``root`` itself."""
|
|
91
|
+
real_root, real = os.path.realpath(root), os.path.realpath(folder)
|
|
92
|
+
return real != real_root and os.path.commonpath([real_root, real]) == real_root
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def valid(manifest: Any) -> bool:
|
|
96
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get("files"), list) or not manifest["files"]:
|
|
97
|
+
return False
|
|
98
|
+
names = []
|
|
99
|
+
for entry in manifest["files"]:
|
|
100
|
+
if (
|
|
101
|
+
not isinstance(entry, dict)
|
|
102
|
+
or not _valid_name(entry.get("name"))
|
|
103
|
+
or not isinstance(entry.get("size"), int)
|
|
104
|
+
or isinstance(entry.get("size"), bool)
|
|
105
|
+
or not isinstance(entry.get("sha256"), str)
|
|
106
|
+
):
|
|
107
|
+
return False
|
|
108
|
+
names.append(entry["name"])
|
|
109
|
+
order = manifest.get("order")
|
|
110
|
+
return (
|
|
111
|
+
len(set(names)) == len(names)
|
|
112
|
+
and isinstance(order, list)
|
|
113
|
+
and len(set(order)) == len(order)
|
|
114
|
+
and all(name in names for name in order)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def identity(manifest: dict[str, Any]) -> str:
|
|
119
|
+
"""Two albums with the same identity have the same tracks: identical files, whatever their names and whatever
|
|
120
|
+
the playlist looks like (two rips of one record have not)."""
|
|
121
|
+
played = [entry for entry in manifest["files"] if entry["name"] in manifest["order"]] or manifest["files"]
|
|
122
|
+
checksums = sorted(entry["sha256"] for entry in played)
|
|
123
|
+
return hashlib.sha256("\n".join(checksums).encode()).hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def checksum(manifest: dict[str, Any]) -> str:
|
|
127
|
+
"""Changes whenever a file of the album or the order of its tracks changes."""
|
|
128
|
+
lines = sorted(f"{entry['sha256']} {entry['name']}" for entry in manifest["files"])
|
|
129
|
+
return hashlib.sha256("\n".join([*lines, "", *manifest["order"]]).encode()).hexdigest()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def extras(manifest: dict[str, Any]) -> list[str]:
|
|
133
|
+
"""The files that are not played: the playlist, an image. They are small and needed before the first track."""
|
|
134
|
+
return [entry["name"] for entry in manifest["files"] if entry["name"] not in manifest["order"]]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def verified(folder: str, entry: dict[str, Any]) -> bool:
|
|
138
|
+
"""Whether the file of ``entry`` is in ``folder`` with the right size and checksum."""
|
|
139
|
+
filename = path(folder, entry["name"])
|
|
140
|
+
return (
|
|
141
|
+
os.path.isfile(filename)
|
|
142
|
+
and os.path.getsize(filename) == entry["size"]
|
|
143
|
+
and sha256_file(filename) == entry["sha256"]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def missing(folder: str, manifest: dict[str, Any], thorough: bool = False) -> list[str]:
|
|
148
|
+
"""Names of the files of the manifest that ``folder`` lacks: first the files that are not played, then the
|
|
149
|
+
tracks in the order of the playlist.
|
|
150
|
+
|
|
151
|
+
Files are renamed into the folder only after their checksum was verified, so by default name and size are enough;
|
|
152
|
+
``thorough`` computes the checksums again.
|
|
153
|
+
"""
|
|
154
|
+
entries = {entry["name"]: entry for entry in manifest["files"]}
|
|
155
|
+
result = []
|
|
156
|
+
for name in [*extras(manifest), *manifest["order"]]:
|
|
157
|
+
entry = entries[name]
|
|
158
|
+
filename = path(folder, name)
|
|
159
|
+
present = os.path.isfile(filename) and os.path.getsize(filename) == entry["size"]
|
|
160
|
+
if not present or (thorough and sha256_file(filename) != entry["sha256"]):
|
|
161
|
+
result.append(name)
|
|
162
|
+
return result
|
tinyjukebox/sync/util.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Helpers of box and library: files written in one step, checksums, versions, time."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import ctypes
|
|
5
|
+
import datetime
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import ssl
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"clock_synchronized",
|
|
17
|
+
"public_key_pin",
|
|
18
|
+
"read_json",
|
|
19
|
+
"sha256_bytes",
|
|
20
|
+
"sha256_file",
|
|
21
|
+
"timestamp",
|
|
22
|
+
"version_key",
|
|
23
|
+
"write_atomic",
|
|
24
|
+
"write_json",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_clock: tuple[float, bool] | None = None # when the clock was asked last, and the answer
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha256_bytes(data: bytes) -> str:
|
|
31
|
+
return hashlib.sha256(data).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _der_element(data: bytes, at: int) -> tuple[int, int]:
|
|
35
|
+
"""Where the element of DER at ``at`` ends, and where its content begins."""
|
|
36
|
+
length, start = data[at + 1], at + 2
|
|
37
|
+
if length & 0x80:
|
|
38
|
+
count = length & 0x7F
|
|
39
|
+
length, start = int.from_bytes(data[start : start + count], "big"), start + count
|
|
40
|
+
return start + length, start
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def public_key_pin(certificate: str) -> str:
|
|
44
|
+
"""The pin of the public key of a certificate (PEM), as ``curl --pinnedpubkey`` takes it: ``sha256//`` and the
|
|
45
|
+
SHA-256 of the SubjectPublicKeyInfo in Base64. Read from the DER of the certificate: tbsCertificate is its first
|
|
46
|
+
element, and the public key the seventh of that - after version, serial number, signature, issuer, validity and
|
|
47
|
+
subject (RFC 5280); the version is the only one that may be missing."""
|
|
48
|
+
der = ssl.PEM_cert_to_DER_cert(certificate)
|
|
49
|
+
_, tbs = _der_element(der, 0) # the content of Certificate: tbsCertificate first
|
|
50
|
+
_, at = _der_element(der, tbs)
|
|
51
|
+
fields = 6 if der[at] == 0xA0 else 5 # [0] version is there
|
|
52
|
+
for _ in range(fields):
|
|
53
|
+
at, _ = _der_element(der, at)
|
|
54
|
+
end, _ = _der_element(der, at)
|
|
55
|
+
return "sha256//" + base64.b64encode(hashlib.sha256(der[at:end]).digest()).decode()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sha256_file(filename: str) -> str:
|
|
59
|
+
digest = hashlib.sha256()
|
|
60
|
+
with open(filename, "rb") as infile:
|
|
61
|
+
while chunk := infile.read(1 << 20):
|
|
62
|
+
digest.update(chunk)
|
|
63
|
+
return digest.hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_atomic(filename: str, data: bytes | str) -> None:
|
|
67
|
+
"""Write under a temporary name and rename: a power cut leaves the old or the new file, never half of one."""
|
|
68
|
+
os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
|
|
69
|
+
temporary = f"{filename}.tmp"
|
|
70
|
+
with open(temporary, "wb") as outfile:
|
|
71
|
+
outfile.write(data.encode("utf-8") if isinstance(data, str) else data)
|
|
72
|
+
outfile.flush()
|
|
73
|
+
os.fsync(outfile.fileno())
|
|
74
|
+
os.replace(temporary, filename)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_json(filename: str, default: Any = None) -> Any:
|
|
78
|
+
try:
|
|
79
|
+
with open(filename, encoding="utf-8") as infile:
|
|
80
|
+
return json.load(infile)
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
return default
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_json(filename: str, value: Any) -> None:
|
|
86
|
+
write_atomic(filename, json.dumps(value, indent=1, sort_keys=True, ensure_ascii=False) + "\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def version_key(version: str) -> tuple[int, ...]:
|
|
90
|
+
"""Sort key of a version like ``0.8.0``."""
|
|
91
|
+
return tuple(int(part) for part in version.split("."))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def timestamp() -> str:
|
|
95
|
+
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
TIMESYNC = "/run/systemd/timesync/synchronized" # systemd-timesyncd creates it when it has set the clock
|
|
99
|
+
MAX_ERROR = 16_000_000
|
|
100
|
+
"""Microseconds: a kernel that reports a larger maximum error of its clock has not been set from the network - the rule
|
|
101
|
+
of systemd (NTPSynchronized), also for chrony, ntpd, and in a container, which has the clock of its host."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _kernel_max_error() -> int | None:
|
|
105
|
+
"""The maximum error of the clock in microseconds as the kernel knows it (adjtimex without a change), None if it
|
|
106
|
+
cannot be asked - only on Linux with 64-bit ``long``, where ``maxerror`` is the fourth field of ``struct
|
|
107
|
+
timex``."""
|
|
108
|
+
if sys.platform != "linux" or ctypes.sizeof(ctypes.c_long) != 8:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
timex = ctypes.create_string_buffer(512) # struct timex has 208 bytes; modes 0: nothing is changed
|
|
112
|
+
if ctypes.CDLL(None, use_errno=True).adjtimex(timex) < 0:
|
|
113
|
+
return None
|
|
114
|
+
except (OSError, AttributeError):
|
|
115
|
+
return None
|
|
116
|
+
return int.from_bytes(timex.raw[24:32], sys.byteorder, signed=True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def clock_synchronized() -> bool:
|
|
120
|
+
"""Whether the clock of this computer was set from the network. The Pi has no real-time clock: without WLAN its
|
|
121
|
+
time is wrong, and order is never decided by it."""
|
|
122
|
+
global _clock # noqa: PLW0603 - asked once a minute at most
|
|
123
|
+
if os.path.exists(TIMESYNC):
|
|
124
|
+
return True
|
|
125
|
+
max_error = _kernel_max_error()
|
|
126
|
+
if max_error is not None:
|
|
127
|
+
return 0 <= max_error < MAX_ERROR
|
|
128
|
+
if _clock is None or time.monotonic() - _clock[0] > 60:
|
|
129
|
+
try:
|
|
130
|
+
answer = subprocess.run(
|
|
131
|
+
["timedatectl", "show", "--property=NTPSynchronized", "--value"],
|
|
132
|
+
capture_output=True, text=True, timeout=5, check=False,
|
|
133
|
+
).stdout.strip() # fmt: skip
|
|
134
|
+
except (OSError, subprocess.SubprocessError):
|
|
135
|
+
answer = ""
|
|
136
|
+
_clock = (time.monotonic(), answer == "yes")
|
|
137
|
+
return _clock[1]
|