tinyjukebox 0.47.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
tinyjukebox/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
|
+
}
|