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.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. 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
+ }