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,348 @@
|
|
|
1
|
+
"""The query for releases (docs/developer/library.md, section 8): the library asks the git server and
|
|
2
|
+
fetches the slot for the boxes.
|
|
3
|
+
|
|
4
|
+
GitHub cannot reach the home network, so the library asks. The interval follows the activity: often after a release was
|
|
5
|
+
sighted or a check was triggered by hand, then less and less often until the quiet interval is reached again.
|
|
6
|
+
|
|
7
|
+
The interface of Gitea is similar to the one of GitHub, not equal. The differences are in ``_Remote``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from tinyjukebox import __version__
|
|
21
|
+
from tinyjukebox.library.store import Store, Unknown
|
|
22
|
+
from tinyjukebox.sync.client import Unreachable
|
|
23
|
+
from tinyjukebox.sync.util import read_json, sha256_file, version_key, write_json
|
|
24
|
+
|
|
25
|
+
__all__ = ["DEFAULTS", "INSTALLER", "Releases", "app_name", "installer_name", "interval"]
|
|
26
|
+
|
|
27
|
+
INSTALLER = "install.sh"
|
|
28
|
+
"""The installer of a box as a file of a release (docs/developer/deployment.md, section 5)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def installer_name(version: str) -> str:
|
|
32
|
+
"""The installer of a version in the archive of the library and in the package."""
|
|
33
|
+
return f"tinyjukebox-install-{version}.sh"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def app_name(version: str) -> str:
|
|
37
|
+
"""The app for phones of a version: its name in a release, in the archive of the library and in the package."""
|
|
38
|
+
return f"tinyjukebox-app-{version}.apk"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
DEFAULTS: dict[str, Any] = {
|
|
42
|
+
"kind": "github", # or "gitea"
|
|
43
|
+
"api": "https://api.github.com",
|
|
44
|
+
"repository": "",
|
|
45
|
+
"token": "", # empty: a public repository, asked without credentials. None: a token is needed and missing
|
|
46
|
+
"quiet": 6 * 3600, # seconds between two checks when nothing happens
|
|
47
|
+
"busy": 600, # ... after a release was sighted or a check was triggered by hand
|
|
48
|
+
"busy-for": 6 * 3600, # how long the short interval lasts
|
|
49
|
+
"back-after": 14 * 24 * 3600, # after this time the interval is the quiet one again
|
|
50
|
+
"keep-versions": 5, # versions the archive keeps at most; what a box needs stays in any case
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def interval(config: dict[str, Any], sighted: float | None, now: float) -> float:
|
|
55
|
+
"""Seconds until the next check, ``sighted`` being the time of the last sighting or check by hand."""
|
|
56
|
+
quiet, busy = float(config["quiet"]), float(config["busy"])
|
|
57
|
+
if sighted is None:
|
|
58
|
+
return quiet
|
|
59
|
+
age = now - sighted
|
|
60
|
+
if age < config["busy-for"]:
|
|
61
|
+
return busy
|
|
62
|
+
if age >= config["back-after"]:
|
|
63
|
+
return quiet
|
|
64
|
+
# longer step by step: the same factor for every equal span of time
|
|
65
|
+
span = float(config["back-after"] - config["busy-for"])
|
|
66
|
+
return float(busy * (quiet / busy) ** ((age - config["busy-for"]) / span))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _NoRedirectedSecret(urllib.request.HTTPRedirectHandler):
|
|
70
|
+
"""A file of a release is redirected to a storage service, which must not get the token of the git server."""
|
|
71
|
+
|
|
72
|
+
def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any:
|
|
73
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
74
|
+
if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc:
|
|
75
|
+
new.headers.pop("Authorization", None)
|
|
76
|
+
new.unredirected_hdrs.pop("Authorization", None)
|
|
77
|
+
return new
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Remote:
|
|
81
|
+
"""The one narrow place that knows the git server, with its two variants."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
84
|
+
self._github = config["kind"] == "github"
|
|
85
|
+
self._repository = config["repository"]
|
|
86
|
+
self._base = f"{config['api'].rstrip('/')}/repos/{config['repository']}"
|
|
87
|
+
# a public repository is asked without credentials
|
|
88
|
+
self._credentials = (
|
|
89
|
+
{"Authorization": f"{'Bearer' if self._github else 'token'} {config['token']}"} if config["token"] else {}
|
|
90
|
+
)
|
|
91
|
+
self._opener = urllib.request.build_opener(_NoRedirectedSecret)
|
|
92
|
+
self.remaining: int | None = None # requests the git server still allows in this hour, if it says so
|
|
93
|
+
|
|
94
|
+
def _open(self, url: str, headers: dict[str, str]) -> Any:
|
|
95
|
+
if not url.startswith(("https://", "http://")):
|
|
96
|
+
raise Unreachable(f"{url}: not an address of a git server")
|
|
97
|
+
request = urllib.request.Request(url, headers={**self._credentials, **headers}) # noqa: S310
|
|
98
|
+
try:
|
|
99
|
+
return self._opener.open(request, timeout=20)
|
|
100
|
+
except urllib.error.HTTPError:
|
|
101
|
+
raise
|
|
102
|
+
except (OSError, urllib.error.URLError) as error:
|
|
103
|
+
raise Unreachable(f"{url}: {error}") from None
|
|
104
|
+
|
|
105
|
+
def release(self, which: str, etag: str | None) -> tuple[dict[str, Any] | None, str | None]:
|
|
106
|
+
"""The release ``which`` ("latest" or "tags/vX.Y.Z") and its ETag; (None, etag) if unchanged or not there.
|
|
107
|
+
|
|
108
|
+
GitHub answers a conditional request with "unchanged", which costs nothing of the request limit. Gitea has no
|
|
109
|
+
such answer; the caller compares the id of the release instead.
|
|
110
|
+
"""
|
|
111
|
+
headers = {"Accept": "application/json"}
|
|
112
|
+
if self._github and etag:
|
|
113
|
+
headers["If-None-Match"] = etag
|
|
114
|
+
try:
|
|
115
|
+
with self._open(f"{self._base}/releases/{which}", headers) as answer:
|
|
116
|
+
self._note_limit(answer.headers)
|
|
117
|
+
return json.loads(answer.read()), answer.headers.get("ETag")
|
|
118
|
+
except urllib.error.HTTPError as error:
|
|
119
|
+
self._note_limit(error.headers)
|
|
120
|
+
if error.code in (304, 404):
|
|
121
|
+
return None, etag if error.code == 304 else None
|
|
122
|
+
raise Unreachable(f"the git server answered {error.code}") from None
|
|
123
|
+
|
|
124
|
+
def check_access(self) -> None:
|
|
125
|
+
"""Raise ``Unreachable`` if the token cannot read the releases of the repository.
|
|
126
|
+
|
|
127
|
+
``release`` cannot tell: a git server answers "not there" to a token without access, as it does for a
|
|
128
|
+
repository without a release.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
with self._open(f"{self._base}/releases?per_page=1", {"Accept": "application/json"}) as answer:
|
|
132
|
+
self._note_limit(answer.headers)
|
|
133
|
+
except urllib.error.HTTPError as error:
|
|
134
|
+
self._note_limit(error.headers)
|
|
135
|
+
if error.code == 401:
|
|
136
|
+
raise Unreachable("the git server does not know this token") from None
|
|
137
|
+
if error.code == 404:
|
|
138
|
+
raise Unreachable(f"this token may not read the releases of {self._repository}") from None
|
|
139
|
+
raise Unreachable(f"the git server answered {error.code}") from None
|
|
140
|
+
|
|
141
|
+
def _note_limit(self, headers: Any) -> None:
|
|
142
|
+
if headers is not None and headers.get("X-RateLimit-Remaining", "").isdigit():
|
|
143
|
+
self.remaining = int(headers["X-RateLimit-Remaining"])
|
|
144
|
+
|
|
145
|
+
def download(self, asset: dict[str, Any], filename: str) -> None:
|
|
146
|
+
# GitHub: the file of a private repository is fetched through the API. Gitea: through its download address
|
|
147
|
+
url = asset["url"] if self._github else asset["browser_download_url"]
|
|
148
|
+
try:
|
|
149
|
+
with self._open(url, {"Accept": "application/octet-stream"}) as answer, open(filename, "wb") as outfile:
|
|
150
|
+
while chunk := answer.read(1 << 16):
|
|
151
|
+
outfile.write(chunk)
|
|
152
|
+
except urllib.error.HTTPError as error:
|
|
153
|
+
raise Unreachable(f"{asset['name']}: the git server answered {error.code}") from None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Releases:
|
|
157
|
+
def __init__(
|
|
158
|
+
self, store: Store, config: dict[str, Any], platform: str, folder: str, logger: logging.Logger
|
|
159
|
+
) -> None:
|
|
160
|
+
self._store = store
|
|
161
|
+
self._config = {**DEFAULTS, **config}
|
|
162
|
+
self._platform = platform
|
|
163
|
+
self._folder = folder
|
|
164
|
+
self._logger = logger
|
|
165
|
+
self._remote = _Remote(self._config)
|
|
166
|
+
self._lock = threading.Lock()
|
|
167
|
+
self._wake = threading.Event()
|
|
168
|
+
self._state: dict[str, Any] = read_json(
|
|
169
|
+
os.path.join(folder, "releases.json"),
|
|
170
|
+
{"etag": None, "release": None, "sighted": None, "rejected": [], "checks": []},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def state(self) -> dict[str, Any]:
|
|
174
|
+
return {
|
|
175
|
+
**self._state,
|
|
176
|
+
"interval": interval(self._config, self._state["sighted"], time.time()),
|
|
177
|
+
"repository": self._config["repository"],
|
|
178
|
+
"requests-left": self._remote.remaining,
|
|
179
|
+
"keep": self._config["keep-versions"],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def start(self) -> None:
|
|
183
|
+
if self._config["repository"]:
|
|
184
|
+
threading.Thread(target=self._run, name="releases", daemon=True).start()
|
|
185
|
+
|
|
186
|
+
def _run(self) -> None:
|
|
187
|
+
self._check_and_go_on()
|
|
188
|
+
while True:
|
|
189
|
+
# a check by hand wakes this thread only so that it waits for the shortened interval from now on
|
|
190
|
+
by_hand = self._wake.wait(interval(self._config, self._state["sighted"], time.time()))
|
|
191
|
+
self._wake.clear()
|
|
192
|
+
if not by_hand:
|
|
193
|
+
self._check_and_go_on()
|
|
194
|
+
|
|
195
|
+
def _check_and_go_on(self) -> None:
|
|
196
|
+
"""A check of this thread: whatever goes wrong, it asks again at the next time - without this, the library
|
|
197
|
+
would look for no release until it is started again."""
|
|
198
|
+
try:
|
|
199
|
+
self.check()
|
|
200
|
+
except Exception:
|
|
201
|
+
self._logger.exception("looking for a release failed")
|
|
202
|
+
|
|
203
|
+
def set_token(self, token: str) -> None:
|
|
204
|
+
"""Ask with this token from now on, if it can read the releases. Raises ``Unreachable`` if it cannot."""
|
|
205
|
+
if not self._config["repository"]:
|
|
206
|
+
raise Unreachable("no repository with releases is configured")
|
|
207
|
+
remote = _Remote({**self._config, "token": token})
|
|
208
|
+
remote.check_access()
|
|
209
|
+
with self._lock:
|
|
210
|
+
self._config["token"] = token
|
|
211
|
+
self._remote = remote
|
|
212
|
+
self._state["etag"] = None # belongs to the answers for the token before
|
|
213
|
+
|
|
214
|
+
def set_keep(self, versions: int) -> None:
|
|
215
|
+
"""Keep at most this number of versions in the archive, from now on."""
|
|
216
|
+
with self._lock:
|
|
217
|
+
self._config["keep-versions"] = versions
|
|
218
|
+
self.prune()
|
|
219
|
+
|
|
220
|
+
def check(self, by_hand: bool = False) -> dict[str, Any]:
|
|
221
|
+
"""Ask for the newest release now, and fetch it if it is new."""
|
|
222
|
+
if not self._config["repository"]:
|
|
223
|
+
return {"error": "no repository with releases is configured", "code": "releases.no_repository"}
|
|
224
|
+
if self._config["token"] is None:
|
|
225
|
+
return {
|
|
226
|
+
"error": 'the token for the releases is missing: it is set on the page, tab "Versions"',
|
|
227
|
+
"code": "releases.token_missing",
|
|
228
|
+
}
|
|
229
|
+
with self._lock:
|
|
230
|
+
now = time.time()
|
|
231
|
+
self._state["checks"] = [*self._state["checks"][-49:], now]
|
|
232
|
+
if by_hand:
|
|
233
|
+
self._state["sighted"] = now # shortens the interval like a sighting
|
|
234
|
+
self._wake.set()
|
|
235
|
+
try:
|
|
236
|
+
result = self._check(by_hand)
|
|
237
|
+
except Unreachable as error:
|
|
238
|
+
self._logger.warning("the git server cannot be reached, nothing changes: %s", error)
|
|
239
|
+
result = {"error": str(error)}
|
|
240
|
+
except (ValueError, KeyError, TypeError, OSError) as error:
|
|
241
|
+
# an answer that is no release as described (no JSON, a field missing, a file that is no
|
|
242
|
+
# description) or a disk that is full: nothing changes, it is asked again later
|
|
243
|
+
self._logger.warning("the release cannot be taken over, nothing changes: %r", error)
|
|
244
|
+
result = {"error": f"the release cannot be taken over: {error!r}"}
|
|
245
|
+
write_json(os.path.join(self._folder, "releases.json"), self._state)
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
def _check(self, by_hand: bool) -> dict[str, Any]:
|
|
249
|
+
release, etag = self._remote.release("latest", self._state["etag"])
|
|
250
|
+
if release is None or (release["id"] == self._state["release"]):
|
|
251
|
+
self._state["etag"] = etag
|
|
252
|
+
return {"unchanged": True}
|
|
253
|
+
if release["id"] in self._state["rejected"] and not by_hand:
|
|
254
|
+
return {"unchanged": True}
|
|
255
|
+
version = release["tag_name"].removeprefix("v")
|
|
256
|
+
self._logger.info("release %s sighted", release["tag_name"])
|
|
257
|
+
self._state["sighted"] = time.time()
|
|
258
|
+
if not self._fetch(release, version):
|
|
259
|
+
return {"rejected": version}
|
|
260
|
+
self._state["release"] = release["id"]
|
|
261
|
+
self._state["etag"] = etag
|
|
262
|
+
return {"found": version}
|
|
263
|
+
|
|
264
|
+
def _fetch(self, release: dict[str, Any], version: str) -> bool:
|
|
265
|
+
"""Download the slot of a release and put it into the archive, if its checksum fits."""
|
|
266
|
+
base = f"tinyjukebox-box-{version}-{self._platform}"
|
|
267
|
+
assets = {asset["name"]: asset for asset in release["assets"]}
|
|
268
|
+
names = {"description": f"{base}.json", "archive": f"{base}.tar.gz", "signature": f"{base}.tar.gz.sig"}
|
|
269
|
+
if any(name not in assets for name in names.values()):
|
|
270
|
+
self._logger.warning("release %s has no slot for %s (yet)", release["tag_name"], self._platform)
|
|
271
|
+
return False
|
|
272
|
+
# downloaded next to the archive under a temporary name; in the archive only with a verified checksum
|
|
273
|
+
folder = os.path.join(self._folder, "archive")
|
|
274
|
+
parts = {kind: os.path.join(folder, f".{name}.part") for kind, name in names.items()}
|
|
275
|
+
for kind, name in names.items():
|
|
276
|
+
self._remote.download(assets[name], parts[kind])
|
|
277
|
+
description = read_json(parts["description"])
|
|
278
|
+
archive = parts["archive"]
|
|
279
|
+
if os.path.getsize(archive) != description.get("size") or sha256_file(archive) != description.get("sha256"):
|
|
280
|
+
self._logger.error("release %s: the slot has a wrong checksum, rejected", release["tag_name"])
|
|
281
|
+
self._state["rejected"] = [*self._state["rejected"][-9:], release["id"]]
|
|
282
|
+
self._store.journal.record("release", None, {"version": version, "rejected": "wrong checksum"})
|
|
283
|
+
for filename in parts.values():
|
|
284
|
+
os.remove(filename)
|
|
285
|
+
return False
|
|
286
|
+
files = {kind: os.path.join(folder, name) for kind, name in names.items()}
|
|
287
|
+
for kind in names:
|
|
288
|
+
os.replace(parts[kind], files[kind])
|
|
289
|
+
# the installer of a box (install.sh, from 0.38.0 on): the library hands it out with the slot of the same
|
|
290
|
+
# version, so that a new box joins it without a PC. Its name is the same in every release
|
|
291
|
+
if INSTALLER in assets:
|
|
292
|
+
files["installer"] = os.path.join(folder, installer_name(version))
|
|
293
|
+
self._remote.download(assets[INSTALLER], f"{files['installer']}.part")
|
|
294
|
+
os.replace(f"{files['installer']}.part", files["installer"])
|
|
295
|
+
# the app for phones (from 0.42.0 on): handed out with the installer, so that app and library fit
|
|
296
|
+
if app_name(version) in assets:
|
|
297
|
+
files["app"] = os.path.join(folder, app_name(version))
|
|
298
|
+
self._remote.download(assets[app_name(version)], f"{files['app']}.part")
|
|
299
|
+
os.replace(f"{files['app']}.part", files["app"])
|
|
300
|
+
self._store.add_version(description, files, "release")
|
|
301
|
+
self.prune(version)
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
def restore(self, version: str) -> None:
|
|
305
|
+
"""Fetch a version again whose files were removed from the archive - one at a time: two boxes that ask for the
|
|
306
|
+
same version at once would write the same files."""
|
|
307
|
+
with self._lock:
|
|
308
|
+
if self._store.versions().get(version, {}).get("files"):
|
|
309
|
+
return # fetched meanwhile
|
|
310
|
+
release, _ = self._remote.release(f"tags/v{version}", None)
|
|
311
|
+
if release is None or not self._fetch(release, version):
|
|
312
|
+
raise Unreachable(f"version {version} cannot be fetched again")
|
|
313
|
+
|
|
314
|
+
def prune(self, fetched: str | None = None) -> None:
|
|
315
|
+
"""Keep the archive within its limits: a number of versions and a size, whichever is reached first.
|
|
316
|
+
|
|
317
|
+
Never removed: versions that run on a box or lie in one of its slots, the version of the library itself, the
|
|
318
|
+
newest version in the state ``use``, and ``fetched``, the version that was fetched just now - a box is waiting
|
|
319
|
+
for it. They count for the limits all the same. Versions in the state ``dontuse`` go first, then the oldest.
|
|
320
|
+
"""
|
|
321
|
+
keep, limit = self._config.get("keep-versions"), self._config.get("keep-bytes")
|
|
322
|
+
versions = {version: entry for version, entry in self._store.versions().items() if entry.get("files")}
|
|
323
|
+
needed = {
|
|
324
|
+
slot.get("version") for box in self._store.boxes() for slot in box["status"].get("slots", {}).values()
|
|
325
|
+
} | {__version__, fetched}
|
|
326
|
+
usable = [version for version, entry in versions.items() if entry["state"] == "use"]
|
|
327
|
+
if usable:
|
|
328
|
+
needed.add(max(usable, key=version_key))
|
|
329
|
+
candidates = sorted(
|
|
330
|
+
(version for version in versions if version not in needed),
|
|
331
|
+
key=lambda version: (versions[version]["state"] != "dontuse", version_key(version)),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def size(version: str) -> int:
|
|
335
|
+
try:
|
|
336
|
+
return os.path.getsize(self._store.version_file(version, "archive"))
|
|
337
|
+
except (OSError, Unknown): # removed by hand: it takes no space
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
total = sum(size(version) for version in versions)
|
|
341
|
+
count = len(versions)
|
|
342
|
+
for version in candidates:
|
|
343
|
+
if (keep is None or count <= keep) and (limit is None or total <= limit):
|
|
344
|
+
break
|
|
345
|
+
total -= size(version)
|
|
346
|
+
count -= 1
|
|
347
|
+
self._store.drop_version_files(version)
|
|
348
|
+
self._logger.info("version %s removed from the archive", version)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Schema of the config file of the library: the sections ``logger`` and ``library``."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tinyjukebox.core.schema.logger import get_schema as get_logger_schema
|
|
6
|
+
from tinyjukebox.schema.tinyjukebox import get_schema as get_box_schema
|
|
7
|
+
|
|
8
|
+
__all__ = ["get_schema"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _string(description: str) -> dict[str, str]:
|
|
12
|
+
return {"description": description, "type": "string"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _seconds(description: str) -> dict[str, Any]:
|
|
16
|
+
return {"description": description, "type": "number", "exclusiveMinimum": 0}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_schema() -> dict[str, Any]:
|
|
20
|
+
logger_key, logger_schema = get_logger_schema()
|
|
21
|
+
rest_api = get_box_schema()["tinyjukebox"]["properties"]["rest-api"]
|
|
22
|
+
library = {
|
|
23
|
+
"description": "the library",
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"data-folder": _string(
|
|
27
|
+
"catalog, boxes, id table, journal and the archive of box versions - a folder that an update of the "
|
|
28
|
+
"library keeps"
|
|
29
|
+
),
|
|
30
|
+
"albums-folder": _string("all albums - album folders in the catalog are relative to it"),
|
|
31
|
+
"images-folder": _string("the covers of the radio stations"),
|
|
32
|
+
"backups-folder": _string(
|
|
33
|
+
"the backups the library makes by itself, until another folder is set on its page (default: "
|
|
34
|
+
"'backups' beside the albums folder)"
|
|
35
|
+
),
|
|
36
|
+
"json-folder": _string(
|
|
37
|
+
"the json files the library works with, generated from the catalog (default: 'json' in the data folder)"
|
|
38
|
+
),
|
|
39
|
+
"playlist-filename": _string("name of the m3u playlist in each album folder (default: playlist.m3u)"),
|
|
40
|
+
"bootstrap-password": _string(
|
|
41
|
+
"the password a new box proves at its first contact - taken over at the first start and whenever it "
|
|
42
|
+
"changes here; in between it can be changed on the page of the library"
|
|
43
|
+
),
|
|
44
|
+
"platform": _string("platform of the boxes whose versions the library fetches, e.g. aarch64-debian-trixie"),
|
|
45
|
+
"slots-folder": _string("folder with the slot that the package of the library brings along (optional)"),
|
|
46
|
+
"rest-api": {**rest_api, "description": "web page and interface for the boxes (as the box: rest-api)"},
|
|
47
|
+
"releases": {
|
|
48
|
+
"description": "where the library asks for new versions of the box software (optional)",
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"kind": {
|
|
52
|
+
"description": "git server (default: github)",
|
|
53
|
+
"type": "string",
|
|
54
|
+
"enum": ["github", "gitea"],
|
|
55
|
+
},
|
|
56
|
+
"api": _string("address of its interface (default: https://api.github.com)"),
|
|
57
|
+
"repository": _string("owner/name - without it the library does not ask"),
|
|
58
|
+
"token-file": _string("file with a token that may read the releases of the repository"),
|
|
59
|
+
"quiet": _seconds("seconds between two checks when nothing happens (default: 6 hours)"),
|
|
60
|
+
"busy": _seconds("... after a release was sighted or a check by hand (default: 10 minutes)"),
|
|
61
|
+
"busy-for": _seconds("how long the short interval lasts (default: 6 hours)"),
|
|
62
|
+
"back-after": _seconds("after this time the interval is the quiet one again (default: 2 weeks)"),
|
|
63
|
+
"keep-versions": {
|
|
64
|
+
"description": "versions the archive keeps at most, if the page sets no number (default: 5)",
|
|
65
|
+
"type": "integer",
|
|
66
|
+
"minimum": 1,
|
|
67
|
+
},
|
|
68
|
+
"keep-bytes": {
|
|
69
|
+
"description": "disk space the archive uses at most (default: no limit)",
|
|
70
|
+
"type": "integer",
|
|
71
|
+
"minimum": 1,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"additionalProperties": False,
|
|
75
|
+
},
|
|
76
|
+
"diagnostics": {
|
|
77
|
+
"description": "what the library keeps of the logs of the boxes (optional)",
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"days": {"description": "days a record is kept (default: 30)", "type": "integer", "minimum": 1},
|
|
81
|
+
"records-per-box": {
|
|
82
|
+
"description": "records kept per box at most - the oldest go first (default: 20000)",
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"minimum": 100,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"additionalProperties": False,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
"required": ["data-folder", "albums-folder", "images-folder"],
|
|
91
|
+
"additionalProperties": False,
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {logger_key: logger_schema, "library": library},
|
|
97
|
+
"required": [logger_key, "library"],
|
|
98
|
+
}
|