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
tinyjukebox/restore.py ADDED
@@ -0,0 +1,391 @@
1
+ """Putting a backup back (docs/developer/operations.md, section 10.3).
2
+
3
+ A restore replaces the data of a box or of the library by the data of a backup, in two steps:
4
+
5
+ 1. :meth:`Restore.stage` reads the archive to its end and unpacks it into new folders beside the current ones
6
+ (``json.new``, ``images.new``, ``albums.new``, ``data.new``). A backup of the other kind, of a newer format, one that
7
+ was cut off, or one with the albums for which the space is missing is refused; what was unpacked is removed again.
8
+ Nothing of the current data is touched, and the program goes on as before.
9
+ 2. :meth:`Restore.apply`, after the program has stopped: every current folder becomes ``….before`` and the new folder
10
+ takes its place. The program starts again with the restored data and removes the ``….before`` folders
11
+ (:meth:`Restore.clean`). A swap that a power cut interrupted is finished at the next start (:meth:`Restore.finish`).
12
+
13
+ Nothing secret is in a backup, so nothing secret is replaced: what a folder keeps (:attr:`Place.keep`) is carried over
14
+ into the new folder. A backup without the albums leaves the albums folder as it is; an album that the restored data
15
+ names and whose folder is missing is logged and named on the page.
16
+ """
17
+
18
+ import contextlib
19
+ import errno
20
+ import json
21
+ import logging
22
+ import os
23
+ import shutil
24
+ import tarfile
25
+ import threading
26
+ from collections.abc import Callable, Sequence
27
+ from dataclasses import dataclass
28
+ from typing import IO, Any
29
+
30
+ from tinyjukebox.backup import FIRST, FORMAT, JOURNAL, LAST, Incomplete
31
+ from tinyjukebox.core.logging import get_child
32
+ from tinyjukebox.errors import BadRequest, Conflict, InsufficientStorage
33
+ from tinyjukebox.sync.util import read_json, timestamp, write_json
34
+
35
+ __all__ = ["BEFORE", "NEW", "RESTART", "STATE", "Place", "Restore", "missing_albums"]
36
+
37
+ RESTART = 75
38
+ """The exit status of a box that stops to start again (EX_TEMPFAIL): systemd starts it again, as after an update."""
39
+ NEW = ".new"
40
+ BEFORE = ".before"
41
+ STATE = "restore.json" # in the state folder: a swap that is under way, then what the last restore did
42
+ KEEP_FREE = 100 * 2**20 # bytes that stay free beside the albums of a backup
43
+
44
+
45
+ @dataclass
46
+ class Place:
47
+ """Where a folder of the archive is put back. ``keep``: names in the current folder that a restore does not
48
+ replace; they are carried into the new folder."""
49
+
50
+ name: str # the folder in the archive: json, images, albums, data
51
+ path: str
52
+ keep: Sequence[str] = ()
53
+
54
+
55
+ def missing_albums(targets: dict[str, Any], albums_folder: str) -> list[dict[str, str]]:
56
+ """The albums of ``targets`` (as in ``targets.json``) whose folder is not in ``albums_folder``."""
57
+ missing = []
58
+ for key, value in sorted(targets.items(), key=lambda item: (len(item[0]), item[0])):
59
+ if isinstance(value, dict) and value.get("target-type") == "album":
60
+ folder = str(value.get("album-folder") or "")
61
+ if not os.path.isdir(os.path.join(albums_folder, folder)):
62
+ title = value.get("title") or os.path.basename(os.path.normpath(folder))
63
+ missing.append({"id": str(key), "title": str(title), "folder": folder})
64
+ return missing
65
+
66
+
67
+ def _inside(child: str, parent: str) -> bool:
68
+ child, parent = os.path.abspath(child), os.path.abspath(parent)
69
+ return child != parent and os.path.commonpath([child, parent]) == parent
70
+
71
+
72
+ class _Limited:
73
+ """The first ``size`` bytes of a stream - the body of a request."""
74
+
75
+ def __init__(self, stream: IO[bytes], size: int) -> None:
76
+ self._stream = stream
77
+ self._left = size
78
+
79
+ def read(self, size: int = -1) -> bytes:
80
+ if self._left <= 0:
81
+ return b""
82
+ data = self._stream.read(self._left if size < 0 else min(size, self._left))
83
+ self._left -= len(data)
84
+ if not data:
85
+ raise Incomplete("the request ended early")
86
+ return data
87
+
88
+
89
+ class Restore:
90
+ def __init__(
91
+ self,
92
+ kind: str,
93
+ places: Sequence[Place],
94
+ state_folder: str,
95
+ logger: logging.Logger,
96
+ journal: Callable[[list[dict[str, Any]]], None] | None = None,
97
+ missing: Callable[[], list[dict[str, str]] | None] | None = None,
98
+ ) -> None:
99
+ """``kind``: ``box`` or ``library`` - only a backup of this kind is put back; ``places``: where the folders of
100
+ the archive go, the albums under the name ``albums``; ``state_folder``: a folder that a restore does not
101
+ replace, for :data:`STATE`. ``journal(lines)`` puts the lines of the journal of a box without a library back,
102
+ ``missing()`` names the albums of the restored data whose folder is missing (None: not asked)."""
103
+ self.kind = kind
104
+ self._places = {place.name: place for place in places}
105
+ self._state_file = os.path.join(state_folder, STATE)
106
+ self._logger = get_child(logger, type(self).__name__)
107
+ self._journal = journal
108
+ self._missing = missing
109
+ self._lock = threading.Lock()
110
+ self.state = "idle" # staging, staged, confirmed
111
+ self.about: dict[str, Any] | None = None
112
+ self.error: str | None = None
113
+ self._lines: list[dict[str, Any]] | None = None
114
+ self.on_confirm: Callable[[], None] | None = None # stops the program, which then calls apply()
115
+
116
+ # --- where things go ---------------------------------------------------------------------------------------------
117
+
118
+ def _outer(self, place: Place) -> Place | None:
119
+ """The place whose folder holds the folder of ``place`` - the images folder of a box lies in its json folder."""
120
+ return next((other for other in self._places.values() if _inside(place.path, other.path)), None)
121
+
122
+ def _staged(self, place: Place) -> str:
123
+ """Where the folder of ``place`` is unpacked: beside the current folder, or inside the new folder that holds
124
+ it."""
125
+ outer = self._outer(place)
126
+ if outer is None:
127
+ return place.path + NEW
128
+ return os.path.join(outer.path + NEW, os.path.relpath(place.path, outer.path))
129
+
130
+ def _swapped(self) -> list[Place]:
131
+ """The places whose folders are swapped: those that are not inside another one and were unpacked."""
132
+ return [p for p in self._places.values() if self._outer(p) is None and os.path.isdir(p.path + NEW)]
133
+
134
+ # --- what the page shows -----------------------------------------------------------------------------------------
135
+
136
+ def last(self) -> dict[str, Any] | None:
137
+ """What the last restore did: when, which backup, and the albums whose folder is missing."""
138
+ state = read_json(self._state_file, None)
139
+ return state if isinstance(state, dict) and state.get("done") else None
140
+
141
+ def status(self) -> dict[str, Any]:
142
+ with self._lock:
143
+ return {"state": self.state, "about": self.about, "error": self.error, "last": self.last()}
144
+
145
+ @property
146
+ def confirmed(self) -> bool:
147
+ return self.state == "confirmed"
148
+
149
+ # --- 1. unpacking ------------------------------------------------------------------------------------------------
150
+
151
+ def stage(self, source: str | IO[bytes], size: int | None = None) -> dict[str, Any]:
152
+ """Read a backup to its end and unpack it beside the current data: a file, or a stream of ``size`` bytes
153
+ (the body of an upload). Returns what the backup says about itself. A backup that was unpacked before and
154
+ not put back is discarded.
155
+
156
+ :raises Incomplete: no backup, or one that was cut off;
157
+ :raises BadRequest: a backup of the other kind or of a newer format;
158
+ :raises InsufficientStorage: a backup with the albums for which the space is missing;
159
+ :raises Conflict: while a backup is being unpacked or put back.
160
+ """
161
+ self._begin_staging()
162
+ return self._stage(source, size)
163
+
164
+ def stage_in_background(self, filename: str) -> None:
165
+ """:meth:`stage` for a backup in a folder of this computer, which may take a while: :meth:`status` says
166
+ when it is unpacked, or why not."""
167
+ self._begin_staging()
168
+
169
+ def run() -> None:
170
+ with contextlib.suppress(Exception): # status() tells what went wrong
171
+ self._stage(filename, None)
172
+
173
+ threading.Thread(target=run, name="Restore.stage", daemon=True).start()
174
+
175
+ def _begin_staging(self) -> None:
176
+ with self._lock:
177
+ if self.state in ("staging", "confirmed"):
178
+ raise Conflict("a backup is being put back already.", code="restore.busy")
179
+ self.state, self.about, self.error, self._lines = "staging", None, None, None
180
+
181
+ def _stage(self, source: str | IO[bytes], size: int | None) -> dict[str, Any]:
182
+ try:
183
+ self._remove_new()
184
+ if isinstance(source, str):
185
+ about = self._unpack(source, os.path.getsize(source))
186
+ else:
187
+ about = self._unpack(_Limited(source, size or 0), size)
188
+ except BaseException as error:
189
+ self._remove_new()
190
+ with self._lock:
191
+ self.state, self.error = "idle", str(error) or type(error).__name__
192
+ raise
193
+ with self._lock:
194
+ self.state, self.about = "staged", about
195
+ self._logger.info(
196
+ "backup of %s unpacked (%s, %d files): it is put back when this is confirmed",
197
+ about.get("created"), "with the albums" if about.get("albums") else "without the albums",
198
+ about.get("files", 0),
199
+ ) # fmt: skip
200
+ return about
201
+
202
+ def _check(self, about: dict[str, Any], size: int | None) -> None:
203
+ if about.get("kind") != self.kind:
204
+ raise BadRequest(
205
+ f"this is a backup of a {about.get('kind')}, not of a {self.kind}.",
206
+ code="restore.kind", kind=str(about.get("kind")),
207
+ ) # fmt: skip
208
+ if not isinstance(about.get("format"), int) or about["format"] > FORMAT:
209
+ raise BadRequest(
210
+ "this backup was made by a newer version, which this one cannot read.",
211
+ code="restore.format", version=str(about.get("version")),
212
+ ) # fmt: skip
213
+ albums = self._places.get("albums")
214
+ if about.get("albums") and albums is not None and size:
215
+ parent = os.path.dirname(os.path.abspath(albums.path))
216
+ free = shutil.disk_usage(parent).free
217
+ if free < size + KEEP_FREE:
218
+ missing = size + KEEP_FREE - free
219
+ raise InsufficientStorage(
220
+ f"for a moment the albums need their space twice: {missing // 2**20} MB are missing.",
221
+ code="restore.space", missing=missing,
222
+ ) # fmt: skip
223
+
224
+ def _unpack(self, source: Any, size: int | None) -> dict[str, Any]:
225
+ about: dict[str, Any] | None = None
226
+ result: dict[str, Any] | None = None
227
+ kwargs: dict[str, Any] = {"name": source} if isinstance(source, str) else {"fileobj": source}
228
+ try:
229
+ with tarfile.open(mode="r|*", **kwargs) as archive:
230
+ for index, member in enumerate(archive):
231
+ if index == 0:
232
+ about = self._begin(archive, member, size)
233
+ else:
234
+ result = self._member(archive, member, bool(about and about.get("albums")))
235
+ except Incomplete:
236
+ raise
237
+ except OSError as error:
238
+ if error.errno == errno.ENOSPC:
239
+ raise InsufficientStorage("the disk is full.", code="restore.full") from None
240
+ if error.errno is None: # not the disk: what was read is no gzip, for example
241
+ raise Incomplete(f"cannot be read to its end: {error}") from None
242
+ raise
243
+ except (tarfile.TarError, EOFError, ValueError) as error:
244
+ raise Incomplete(f"cannot be read to its end: {error}") from None
245
+ if about is None:
246
+ raise Incomplete(f"no backup: it does not begin with {FIRST}")
247
+ if result is None:
248
+ raise Incomplete(f"cut off: it does not end with {LAST}")
249
+ return {**about, **result}
250
+
251
+ def _begin(self, archive: tarfile.TarFile, member: tarfile.TarInfo, size: int | None) -> dict[str, Any]:
252
+ """The first file of a backup says what it is a backup of: checked before anything is unpacked."""
253
+ if member.name != FIRST:
254
+ raise Incomplete(f"no backup: it does not begin with {FIRST}")
255
+ about: dict[str, Any] = json.load(archive.extractfile(member)) # type: ignore[arg-type]
256
+ self._check(about, size)
257
+ for place in self._places.values():
258
+ if place.name != "albums" or about.get("albums"):
259
+ os.makedirs(self._staged(place), exist_ok=True)
260
+ return about
261
+
262
+ def _member(self, archive: tarfile.TarFile, member: tarfile.TarInfo, albums: bool) -> dict[str, Any] | None:
263
+ """A file of the archive after the first one; returns what ``complete.json`` says if it is that file - which
264
+ counts only as the last one."""
265
+ if member.name == LAST:
266
+ result: dict[str, Any] = json.load(archive.extractfile(member)) # type: ignore[arg-type]
267
+ return result
268
+ if member.name == JOURNAL and member.isfile():
269
+ content = archive.extractfile(member).read().decode("utf-8") # type: ignore[union-attr]
270
+ self._lines = [json.loads(line) for line in content.splitlines() if line.strip()]
271
+ else:
272
+ self._extract(archive, member, albums)
273
+ return None
274
+
275
+ def _extract(self, archive: tarfile.TarFile, member: tarfile.TarInfo, albums: bool) -> None:
276
+ """One file of the archive into the new folder of its place. Only regular files below a known folder; a name
277
+ that would lead out of it is no part of a backup."""
278
+ top, _, inside = member.name.partition("/")
279
+ place = self._places.get(top)
280
+ if place is None or not inside or not member.isfile() or (top == "albums" and not albums):
281
+ return
282
+ parts = inside.split("/")
283
+ if any(part in ("", ".", "..") for part in parts) or os.path.isabs(inside):
284
+ raise Incomplete(f"no backup: the name {member.name!r} leads out of its folder")
285
+ target = os.path.join(self._staged(place), *parts)
286
+ os.makedirs(os.path.dirname(target), exist_ok=True)
287
+ infile = archive.extractfile(member)
288
+ if infile is None:
289
+ return
290
+ with open(target, "wb") as outfile:
291
+ shutil.copyfileobj(infile, outfile, 1 << 20)
292
+ os.utime(target, (member.mtime, member.mtime))
293
+
294
+ def _remove_new(self) -> None:
295
+ for place in self._places.values():
296
+ if self._outer(place) is None:
297
+ shutil.rmtree(place.path + NEW, ignore_errors=True)
298
+
299
+ def discard(self) -> None:
300
+ """Do not put the backup that was unpacked back."""
301
+ with self._lock:
302
+ if self.state in ("staging", "confirmed"):
303
+ raise Conflict("a backup is being put back.", code="restore.busy")
304
+ self.state, self.about, self._lines = "idle", None, None
305
+ self._remove_new()
306
+
307
+ def confirm(self) -> None:
308
+ """Put the backup that was unpacked back: the program stops (``on_confirm``) and calls :meth:`apply`."""
309
+ with self._lock:
310
+ if self.state != "staged":
311
+ raise Conflict("no backup is waiting to be put back.", code="restore.nothing")
312
+ self.state = "confirmed"
313
+ self._logger.warning(
314
+ "the backup of %s is put back: the program starts again", (self.about or {}).get("created")
315
+ )
316
+ if self.on_confirm is not None:
317
+ self.on_confirm()
318
+
319
+ # --- 2. swapping -------------------------------------------------------------------------------------------------
320
+
321
+ def apply(self) -> dict[str, Any]:
322
+ """Swap the folders - only while the program does not use them, i.e. after it has stopped. Returns what
323
+ :meth:`last` says from now on."""
324
+ with self._lock:
325
+ if self.state != "confirmed":
326
+ raise Conflict("no backup is confirmed to be put back.", code="restore.nothing")
327
+ about = dict(self.about or {})
328
+ places = self._swapped()
329
+ write_json(self._state_file, {"swapping": [place.name for place in places], "about": about})
330
+ self._swap(places)
331
+ if self._lines is not None and self._journal is not None:
332
+ self._journal(self._lines)
333
+ result = self._done(about)
334
+ with self._lock:
335
+ self.state, self.about, self._lines = "idle", None, None
336
+ return result
337
+
338
+ def _swap(self, places: Sequence[Place]) -> None:
339
+ for place in places:
340
+ new, before = place.path + NEW, place.path + BEFORE
341
+ for name in place.keep: # what a restore does not replace
342
+ kept = os.path.join(place.path, name)
343
+ if os.path.lexists(kept):
344
+ if os.path.isdir(os.path.join(new, name)):
345
+ shutil.rmtree(os.path.join(new, name))
346
+ os.replace(kept, os.path.join(new, name))
347
+ if os.path.lexists(place.path):
348
+ if os.path.lexists(before):
349
+ shutil.rmtree(before)
350
+ os.replace(place.path, before)
351
+ os.replace(new, place.path)
352
+ self._logger.info("%s: the data of the backup is in place, the one of before in %s", place.path, before)
353
+
354
+ def _done(self, about: dict[str, Any]) -> dict[str, Any]:
355
+ missing = self._missing() if self._missing is not None else None
356
+ for album in missing or []:
357
+ self._logger.error(
358
+ "restore: album '%s' (%s) is in the data, but its folder '%s' is missing.",
359
+ album["title"], album["id"], album["folder"],
360
+ ) # fmt: skip
361
+ keys = ("created", "version", "albums", "files", "bytes")
362
+ result = {"done": timestamp(), "backup": {key: about.get(key) for key in keys}, "missing": missing}
363
+ write_json(self._state_file, result)
364
+ return result
365
+
366
+ def finish(self) -> None:
367
+ """At the start, before the data is read: a swap that was interrupted - the power was cut - is finished; new
368
+ folders that were unpacked and never put back are removed."""
369
+ state = read_json(self._state_file, None)
370
+ if isinstance(state, dict) and "swapping" in state:
371
+ places = [self._places[name] for name in state["swapping"] if name in self._places]
372
+ self._logger.warning("the restore was interrupted while the folders were swapped: it is finished now")
373
+ self._swap([place for place in places if os.path.isdir(place.path + NEW)])
374
+ self._done(state.get("about") or {})
375
+ self._remove_new()
376
+
377
+ def clean(self) -> None:
378
+ """Remove the data of before, once the program runs with the restored data - in the background: the albums
379
+ of before may take a while."""
380
+ before = [place.path + BEFORE for place in self._places.values() if self._outer(place) is None]
381
+ before = [folder for folder in before if os.path.lexists(folder)]
382
+ if not before:
383
+ return
384
+
385
+ def remove() -> None:
386
+ for folder in before:
387
+ with contextlib.suppress(OSError):
388
+ shutil.rmtree(folder)
389
+ self._logger.info("%s removed: the restored data runs", folder)
390
+
391
+ threading.Thread(target=remove, name="Restore.clean", daemon=True).start()
tinyjukebox/rgbled.py ADDED
@@ -0,0 +1,158 @@
1
+ """RGB led of the box - colors and blinking are executed by the rgbled driver."""
2
+
3
+ import logging
4
+ import threading
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from tinyjukebox.core.logging import get_child
9
+ from tinyjukebox.core.pubsub import PubSubClient
10
+ from tinyjukebox.messages import LED, Color, LedPattern
11
+
12
+ __all__ = ["ALARM", "WAITING", "Color", "RGBLed"]
13
+
14
+ WAITING = (Color.GREEN, Color.AQUA, Color.BLUE, Color.AQUA)
15
+ """The playback is halted until a file has arrived from the library, or until the sound of a radio station is heard:
16
+ the colors of "waiting for a tag", "starting" and "playing" flow back and forth - with every step one of the three
17
+ lights of the led changes."""
18
+
19
+ ALARM = (Color.RED, Color.WHITE, Color.MAGENTA, Color.YELLOW)
20
+ """The library is too old for this box, which plays nothing until a person repairs that: meant to be unpleasant -
21
+ with every step two of the three lights change. Not faster than three changes per second: no strobe in a child's
22
+ room."""
23
+
24
+ SEQUENCE_INTERVAL = 0.4
25
+
26
+
27
+ class RGBLed:
28
+ """Publishes the :class:`~tinyjukebox.messages.LedPattern` for the rgbled driver to
29
+ :data:`~tinyjukebox.messages.LED`.
30
+
31
+ :meth:`get_state` describes the shown pattern for the web page:
32
+
33
+ * static color: ``{"color": "MAGENTA"}``
34
+ * alternating colors: ``{"color_a": "MAGENTA", "color_b": "AQUA", "delay": 1}``
35
+
36
+ An *overlay* (:meth:`show_overlay`, e.g. the search for a bluetooth headset) is shown instead of the pattern of
37
+ the state machines until it expires or is cleared; the state machines keep setting their patterns meanwhile -
38
+ afterwards the led shows the last one again.
39
+
40
+ The lock keeps the order of state and published patterns for concurrent callers (state machine threads).
41
+ After :meth:`stop` nothing changes the led any more.
42
+ """
43
+
44
+ def __init__(self, logger: logging.Logger, pubsub_client: PubSubClient) -> None:
45
+ self._logger = get_child(logger, type(self).__name__)
46
+ self._pubsub_client = pubsub_client
47
+ self._lock = threading.Lock()
48
+ self._base: tuple[LedPattern, dict[str, Any]] | None = None
49
+ self._overlay: tuple[LedPattern, dict[str, Any]] | None = None
50
+ self._overlay_timer: threading.Timer | None = None
51
+ self._stopped = False
52
+
53
+ def set_color(self, color: Color) -> None:
54
+ self._set_base(*_static(color))
55
+
56
+ def set_blink_color(self, color: Color, interval: float) -> None:
57
+ self.set_alternate_colors(color, Color.BLACK, interval)
58
+
59
+ def set_alternate_colors(self, color_a: Color, color_b: Color, interval: float) -> None:
60
+ self._set_base(*_alternate(color_a, color_b, interval))
61
+
62
+ def set_sequence(self, colors: Sequence[Color], interval: float = SEQUENCE_INTERVAL) -> None:
63
+ """Show ``colors`` in turn, ``interval`` seconds each."""
64
+ self._set_base(*_sequence(colors, interval))
65
+
66
+ def show_overlay_sequence(self, colors: Sequence[Color], interval: float = SEQUENCE_INTERVAL) -> None:
67
+ """Show ``colors`` in turn instead of the pattern of the state machines, until :meth:`clear_overlay`."""
68
+ pattern = _sequence(colors, interval)
69
+ with self._lock:
70
+ if self._stopped:
71
+ return
72
+ self._cancel_timer()
73
+ self._overlay = pattern
74
+ self._publish(*pattern)
75
+
76
+ def show_overlay(
77
+ self, color_a: Color, color_b: Color | None = None, interval: float = 1, duration: float | None = None
78
+ ) -> None:
79
+ """Show ``color_a`` (alternating with ``color_b`` every ``interval`` seconds) instead of the pattern of the
80
+ state machines - for ``duration`` seconds or until :meth:`clear_overlay` (``None``). Replaces an overlay."""
81
+ pattern = _static(color_a) if color_b is None else _alternate(color_a, color_b, interval)
82
+ with self._lock:
83
+ if self._stopped: # its timer would publish after the pubsub client has been disconnected
84
+ return
85
+ self._cancel_timer()
86
+ self._overlay = pattern
87
+ if duration is not None:
88
+ timer = threading.Timer(duration, self._expire, args=(pattern,))
89
+ timer.name = "RGBLed.overlay"
90
+ timer.daemon = True
91
+ self._overlay_timer = timer
92
+ timer.start()
93
+ self._publish(*pattern)
94
+
95
+ def clear_overlay(self) -> None:
96
+ """End the overlay - the led shows the last pattern of the state machines again."""
97
+ with self._lock:
98
+ self._end_overlay()
99
+
100
+ def stop(self, color: Color) -> None:
101
+ """The box stops: the led shows ``color`` - also instead of an overlay - and nothing changes it any more."""
102
+ with self._lock:
103
+ self._cancel_timer()
104
+ self._overlay = None
105
+ self._base = _static(color)
106
+ if not self._stopped:
107
+ self._stopped = True
108
+ self._publish(*self._base)
109
+
110
+ def get_state(self) -> dict[str, Any] | None:
111
+ """The pattern that is shown - e.g. to show the led on the web page."""
112
+ shown = self._overlay or self._base
113
+ return shown[1] if shown is not None else None
114
+
115
+ def _expire(self, pattern: tuple[LedPattern, dict[str, Any]]) -> None:
116
+ with self._lock:
117
+ if self._overlay is pattern: # not replaced meanwhile
118
+ self._overlay_timer = None
119
+ self._end_overlay()
120
+
121
+ def _end_overlay(self) -> None:
122
+ self._cancel_timer()
123
+ if self._overlay is None:
124
+ return
125
+ self._overlay = None
126
+ if self._base is not None:
127
+ self._publish(*self._base)
128
+
129
+ def _cancel_timer(self) -> None:
130
+ if self._overlay_timer is not None:
131
+ self._overlay_timer.cancel()
132
+ self._overlay_timer = None
133
+
134
+ def _set_base(self, pattern: LedPattern, state: dict[str, Any]) -> None:
135
+ with self._lock:
136
+ if self._stopped:
137
+ return
138
+ self._base = (pattern, state)
139
+ if self._overlay is None:
140
+ self._publish(pattern, state)
141
+
142
+ def _publish(self, pattern: LedPattern, state: dict[str, Any]) -> None:
143
+ self._logger.debug("_publish - %s", state)
144
+ self._pubsub_client.publish(LED, pattern)
145
+
146
+
147
+ def _static(color: Color) -> tuple[LedPattern, dict[str, Any]]:
148
+ return LedPattern.static(color), {"color": color.name}
149
+
150
+
151
+ def _alternate(color_a: Color, color_b: Color, interval: float) -> tuple[LedPattern, dict[str, Any]]:
152
+ pattern = LedPattern.blink(color_a, color_b, interval, interval)
153
+ return pattern, {"color_a": color_a.name, "color_b": color_b.name, "delay": interval}
154
+
155
+
156
+ def _sequence(colors: Sequence[Color], interval: float) -> tuple[LedPattern, dict[str, Any]]:
157
+ pattern = LedPattern(tuple(color.value for color in colors), (interval,) * len(colors))
158
+ return pattern, {"colors": [color.name for color in colors], "delay": interval}
@@ -0,0 +1 @@
1
+ """JSON schemas of the config section and of the json data files."""
@@ -0,0 +1,27 @@
1
+ """Schema of the activities json file."""
2
+
3
+ from typing import Any
4
+
5
+ __all__ = ["schema"]
6
+
7
+ schema: dict[str, Any] = {
8
+ "$schema": "http://json-schema.org/draft-07/schema#",
9
+ "type": "object",
10
+ "additionalProperties": {
11
+ "type": "object",
12
+ "required": ["tag-uid", "target-id", "track-id", "track-time", "target-type", "album-finished"],
13
+ "additionalProperties": False,
14
+ "properties": {
15
+ "tag-uid": {"description": "uid of the rfid tag - same as the key of this entry", "type": "string"},
16
+ "target-id": {
17
+ "description": "id of the album or entry of Spotify - key in the targets json file",
18
+ "type": ["string", "integer"],
19
+ },
20
+ "track-id": {"description": "index of the current track in the playlist", "type": "integer"},
21
+ "track-uri": {"description": "address of the current track of an entry of Spotify", "type": "string"},
22
+ "track-time": {"description": "position in seconds in the current track", "type": "number"},
23
+ "target-type": {"description": "type of the target", "type": "string", "enum": ["album", "spotify"]},
24
+ "album-finished": {"description": "album has been played to its end the last time", "type": "boolean"},
25
+ },
26
+ },
27
+ }
@@ -0,0 +1,54 @@
1
+ """Schema of the tags json file."""
2
+
3
+ from typing import Any
4
+
5
+ __all__ = ["schema"]
6
+
7
+ schema: dict[str, Any] = {
8
+ "$schema": "http://json-schema.org/draft-07/schema#",
9
+ "type": "object",
10
+ "additionalProperties": {
11
+ "type": "object",
12
+ "required": ["created", "tag", "target"],
13
+ "additionalProperties": False,
14
+ "properties": {
15
+ "created": {"description": "timestamp when this entry has been added", "type": "string"},
16
+ "tag": {
17
+ "type": "object",
18
+ "required": ["uid"],
19
+ "additionalProperties": False,
20
+ "properties": {
21
+ "uid": {"description": "uid of the rfid tag - same as the key of this entry", "type": "string"},
22
+ "name": {"description": "name of the tag", "type": "string"},
23
+ },
24
+ },
25
+ "target": {
26
+ "oneOf": [
27
+ {
28
+ "description": "album, radio station or entry of Spotify (id -1: not linked yet)",
29
+ "type": "object",
30
+ "additionalProperties": False,
31
+ "properties": {
32
+ "type": {
33
+ "description": "target type",
34
+ "type": "string",
35
+ "enum": ["album", "radio", "spotify"],
36
+ },
37
+ "id": {
38
+ "description": "id of the target - key in the targets json file",
39
+ "type": ["string", "integer"],
40
+ },
41
+ },
42
+ "required": ["type", "id"],
43
+ },
44
+ {
45
+ "description": "not linked yet",
46
+ "type": "object",
47
+ "additionalProperties": False,
48
+ "properties": {"type": {"description": "target type", "type": "string", "enum": [""]}},
49
+ },
50
+ ]
51
+ },
52
+ },
53
+ },
54
+ }