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/backup.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""A backup of the data of a box or of the library: one tar archive (docs/developer/operations.md, section 10).
|
|
2
|
+
|
|
3
|
+
The archive begins with ``backup.json``, which says what it is a backup of, and ends with ``complete.json``: an
|
|
4
|
+
archive without it was cut off. In between lie the data files, each below the name of the folder it belongs to
|
|
5
|
+
(``json``, ``data``, ``images``, ``albums``) - restoring is unpacking into these folders. With the albums the archive is
|
|
6
|
+
not compressed, because audio files do not get smaller; without them it is gzip.
|
|
7
|
+
|
|
8
|
+
Nothing secret is part of a backup: no certificate, no user of the page, no password and no token.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tarfile
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Iterator, Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import IO, Any, Protocol
|
|
21
|
+
|
|
22
|
+
from tinyjukebox import __version__
|
|
23
|
+
|
|
24
|
+
__all__ = ["FIRST", "FORMAT", "JOURNAL", "LAST", "NAME", "PART", "Backup", "Folder", "Incomplete", "verify"]
|
|
25
|
+
|
|
26
|
+
FORMAT = 1
|
|
27
|
+
FIRST = "backup.json"
|
|
28
|
+
LAST = "complete.json"
|
|
29
|
+
JOURNAL = "json/journal.jsonl"
|
|
30
|
+
"""In the backup of a box that works without a library: the lines of its journal, one JSON object per line. The
|
|
31
|
+
statistics of such a box are counted from them."""
|
|
32
|
+
NAME = re.compile(r"tinyjukebox-(box|library)-(\d{8})-(\d{6})(-albums\.tar|\.tar\.gz)")
|
|
33
|
+
"""The file names of backups; they sort by the time the backup was made."""
|
|
34
|
+
PART = ".part" # a backup that is being written
|
|
35
|
+
UNFINISHED = (".tmp", PART) # files that are being written
|
|
36
|
+
NO_DATA = (".", "@") # how the names of hidden files and of the folders of a NAS (@eaDir) begin
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Incomplete(ValueError): # noqa: N818
|
|
40
|
+
"""The file is no backup, or one that was cut off."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Out(Protocol):
|
|
44
|
+
def write(self, data: bytes, /) -> Any: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Folder:
|
|
49
|
+
"""A folder whose files go into the archive below ``name``, read while the archive is written. ``skip``: names
|
|
50
|
+
directly in the folder that are left out. Always left out: hidden files and folders, the folders ``@eaDir`` that a
|
|
51
|
+
NAS puts everywhere, and files that are being written."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
path: str
|
|
55
|
+
skip: Sequence[str] = ()
|
|
56
|
+
|
|
57
|
+
def files(self) -> Iterator[tuple[str, str]]:
|
|
58
|
+
"""(name in the archive, file) in the order of the names."""
|
|
59
|
+
for root, folders, names in os.walk(self.path):
|
|
60
|
+
top = os.path.abspath(root) == os.path.abspath(self.path)
|
|
61
|
+
folders[:] = sorted(f for f in folders if not f.startswith(NO_DATA) and not (top and f in self.skip))
|
|
62
|
+
for name in sorted(names):
|
|
63
|
+
if name.startswith(NO_DATA) or name.endswith(UNFINISHED) or (top and name in self.skip):
|
|
64
|
+
continue
|
|
65
|
+
filename = os.path.join(root, name)
|
|
66
|
+
inside = os.path.relpath(filename, self.path).replace(os.sep, "/")
|
|
67
|
+
yield f"{self.name}/{inside}", filename
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Backup:
|
|
72
|
+
"""What a backup is made of. ``data`` was taken at one moment and is kept in memory; the files of ``folders`` are
|
|
73
|
+
read while the archive is written."""
|
|
74
|
+
|
|
75
|
+
kind: str # "box" or "library"
|
|
76
|
+
albums: bool # with the albums, i.e. the audio files
|
|
77
|
+
data: list[tuple[str, bytes]] = field(default_factory=list) # (name in the archive, content)
|
|
78
|
+
folders: list[Folder] = field(default_factory=list)
|
|
79
|
+
created: float = field(default_factory=time.time)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def filename(self) -> str:
|
|
83
|
+
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(self.created))
|
|
84
|
+
return f"tinyjukebox-{self.kind}-{stamp}{'-albums.tar' if self.albums else '.tar.gz'}"
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def content_type(self) -> str:
|
|
88
|
+
return "application/x-tar" if self.albums else "application/gzip"
|
|
89
|
+
|
|
90
|
+
def write(self, out: _Out) -> dict[str, Any]:
|
|
91
|
+
"""Write the archive to ``out``; returns what ``complete.json`` says. A file that disappears meanwhile is
|
|
92
|
+
left out and named there; any other failure leaves an archive without ``complete.json``."""
|
|
93
|
+
result: dict[str, Any] = {"files": 0, "bytes": 0, "skipped": []}
|
|
94
|
+
|
|
95
|
+
def add(archive: tarfile.TarFile, name: str, content: bytes) -> None:
|
|
96
|
+
info = tarfile.TarInfo(name)
|
|
97
|
+
info.size = len(content)
|
|
98
|
+
info.mtime = int(self.created)
|
|
99
|
+
info.mode = 0o644
|
|
100
|
+
archive.addfile(info, io.BytesIO(content))
|
|
101
|
+
|
|
102
|
+
about = {
|
|
103
|
+
"format": FORMAT,
|
|
104
|
+
"kind": self.kind,
|
|
105
|
+
"version": __version__,
|
|
106
|
+
"created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.created)),
|
|
107
|
+
"albums": self.albums,
|
|
108
|
+
}
|
|
109
|
+
# "w|": a stream that is only written forward - the answer to a request cannot be rewound
|
|
110
|
+
with tarfile.open(fileobj=out, mode="w|" if self.albums else "w|gz") as archive: # type: ignore[call-overload]
|
|
111
|
+
add(archive, FIRST, _json(about))
|
|
112
|
+
for name, content in self.data:
|
|
113
|
+
add(archive, name, content)
|
|
114
|
+
result["files"] += 1
|
|
115
|
+
result["bytes"] += len(content)
|
|
116
|
+
for folder in self.folders:
|
|
117
|
+
for name, filename in folder.files():
|
|
118
|
+
try:
|
|
119
|
+
infile = open(filename, "rb") # noqa: SIM115 - closed below
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
result["skipped"].append(name)
|
|
122
|
+
continue
|
|
123
|
+
with infile:
|
|
124
|
+
info = archive.gettarinfo(arcname=name, fileobj=infile)
|
|
125
|
+
info.uid = info.gid = 0
|
|
126
|
+
info.uname = info.gname = ""
|
|
127
|
+
archive.addfile(info, infile)
|
|
128
|
+
result["files"] += 1
|
|
129
|
+
result["bytes"] += info.size
|
|
130
|
+
add(archive, LAST, _json(result))
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def save(self, folder: str) -> tuple[str, dict[str, Any]]:
|
|
134
|
+
"""Write the archive into ``folder``; returns its file and what :meth:`write` returns. A backup that fails
|
|
135
|
+
leaves nothing in the folder."""
|
|
136
|
+
target = os.path.join(folder, self.filename)
|
|
137
|
+
try:
|
|
138
|
+
with open(target + PART, "wb") as outfile:
|
|
139
|
+
result = self.write(outfile)
|
|
140
|
+
outfile.flush()
|
|
141
|
+
os.fsync(outfile.fileno())
|
|
142
|
+
os.replace(target + PART, target)
|
|
143
|
+
except BaseException:
|
|
144
|
+
with contextlib.suppress(FileNotFoundError):
|
|
145
|
+
os.remove(target + PART)
|
|
146
|
+
raise
|
|
147
|
+
return target, result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _json(value: Any) -> bytes:
|
|
151
|
+
return (json.dumps(value, indent=1, sort_keys=True) + "\n").encode("utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def verify(source: str | IO[bytes]) -> dict[str, Any]:
|
|
155
|
+
"""Read a backup from its first to its last byte; returns ``backup.json`` with what ``complete.json`` says.
|
|
156
|
+
|
|
157
|
+
:raises Incomplete: if it is no backup or was cut off.
|
|
158
|
+
"""
|
|
159
|
+
about: dict[str, Any] | None = None
|
|
160
|
+
result: dict[str, Any] | None = None
|
|
161
|
+
try:
|
|
162
|
+
kwargs: dict[str, Any] = {"name": source} if isinstance(source, str) else {"fileobj": source}
|
|
163
|
+
with tarfile.open(mode="r|*", **kwargs) as archive:
|
|
164
|
+
for index, member in enumerate(archive):
|
|
165
|
+
if index == 0 and member.name == FIRST:
|
|
166
|
+
about = json.load(archive.extractfile(member)) # type: ignore[arg-type]
|
|
167
|
+
elif index == 0:
|
|
168
|
+
break
|
|
169
|
+
result = json.load(archive.extractfile(member)) if member.name == LAST else None # type: ignore[arg-type]
|
|
170
|
+
except (tarfile.TarError, EOFError, OSError, ValueError) as error:
|
|
171
|
+
raise Incomplete(f"cannot be read to its end: {error}") from None
|
|
172
|
+
if about is None:
|
|
173
|
+
raise Incomplete(f"no backup: it does not begin with {FIRST}")
|
|
174
|
+
if result is None:
|
|
175
|
+
raise Incomplete(f"cut off: it does not end with {LAST}")
|
|
176
|
+
return {**about, **result}
|
tinyjukebox/backups.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Backups that the library or a box without a library makes by itself and keeps in a folder
|
|
2
|
+
(docs/developer/operations.md, section 10.2).
|
|
3
|
+
|
|
4
|
+
There are two schedules that are set apart from each other: one for the backups without the albums (``plain``) and one
|
|
5
|
+
for those with them (``albums``). Each says how often and at what time of day a backup is written, how many backups
|
|
6
|
+
of its kind are kept, and into which folder they go; until a folder is set it is the one of the config. A backup can
|
|
7
|
+
also be asked for at once. What a backup contains is described in :mod:`tinyjukebox.backup`; this module only says
|
|
8
|
+
when and where, and looks after the folders.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import calendar
|
|
12
|
+
import contextlib
|
|
13
|
+
import datetime
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from tinyjukebox.backup import NAME, PART, Backup
|
|
22
|
+
from tinyjukebox.core.logging import get_child
|
|
23
|
+
from tinyjukebox.errors import BadRequest, Conflict, NotFound
|
|
24
|
+
from tinyjukebox.sync.util import read_json, timestamp, write_json
|
|
25
|
+
|
|
26
|
+
__all__ = ["DEFAULTS", "KINDS", "UNITS", "Backups", "next_due"]
|
|
27
|
+
|
|
28
|
+
KINDS = ("plain", "albums")
|
|
29
|
+
"""The two schedules: backups without the albums and backups with them. In this order they are made if both are due
|
|
30
|
+
at the same time."""
|
|
31
|
+
UNITS = ("days", "weeks", "months")
|
|
32
|
+
DEFAULTS: dict[str, dict[str, Any]] = {
|
|
33
|
+
"plain": {"enabled": False, "every": 1, "unit": "days", "time": "03:00", "keep": 7},
|
|
34
|
+
"albums": {"enabled": False, "every": 1, "unit": "months", "time": "03:00", "keep": 2},
|
|
35
|
+
}
|
|
36
|
+
_KEPT = {"folder": None, "last": None, "scheduled": None} # what a schedule keeps beside its settings
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _time_of_day(value: object) -> datetime.time:
|
|
40
|
+
if not isinstance(value, str) or not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value):
|
|
41
|
+
raise BadRequest("time must be of the form HH:MM.")
|
|
42
|
+
return datetime.time(int(value[:2]), int(value[3:]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _later(day: datetime.date, every: int, unit: str) -> datetime.date:
|
|
46
|
+
"""``every`` days, weeks or months after ``day``. Months: the same day of the month; a day that the month does
|
|
47
|
+
not have is its last day."""
|
|
48
|
+
if unit != "months":
|
|
49
|
+
return day + datetime.timedelta(days=every * (7 if unit == "weeks" else 1))
|
|
50
|
+
months = day.year * 12 + day.month - 1 + every
|
|
51
|
+
year, month = divmod(months, 12)
|
|
52
|
+
return datetime.date(year, month + 1, min(day.day, calendar.monthrange(year, month + 1)[1]))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def next_due(now: datetime.datetime, settings: dict[str, Any], last: datetime.date | None) -> datetime.datetime:
|
|
56
|
+
"""When the next backup of a schedule is to be made: ``every`` days, weeks or months (``unit``) after the day of
|
|
57
|
+
the last one (``last``), at ``time`` - or today, if there was none. A time that has passed, e.g. because the
|
|
58
|
+
program did not run, moves to the same time of the next day: a backup is not made up for in the middle of the
|
|
59
|
+
day."""
|
|
60
|
+
at = _time_of_day(settings["time"])
|
|
61
|
+
day = now.date() if last is None else _later(last, int(settings["every"]), str(settings.get("unit", "days")))
|
|
62
|
+
due = datetime.datetime.combine(max(day, now.date()), at)
|
|
63
|
+
return due if due > now else due + datetime.timedelta(days=1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _loaded(stored: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
67
|
+
"""The schedules as the settings file has them. Until 0.33 there was one schedule with a tick "with the albums":
|
|
68
|
+
it becomes the schedule of its kind, and the other one is off with its defaults."""
|
|
69
|
+
if stored and not any(isinstance(stored.get(kind), dict) for kind in KINDS):
|
|
70
|
+
kind = "albums" if stored.get("albums") else "plain"
|
|
71
|
+
stored = {kind: {**{key: value for key, value in stored.items() if key != "albums"}, "unit": "days"}}
|
|
72
|
+
state = {}
|
|
73
|
+
for kind in KINDS:
|
|
74
|
+
known = {**DEFAULTS[kind], **_KEPT}
|
|
75
|
+
state[kind] = {**known, **{key: value for key, value in stored.get(kind, {}).items() if key in known}}
|
|
76
|
+
return state
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Backups:
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
folder: str,
|
|
83
|
+
settings_file: str,
|
|
84
|
+
create: Callable[[bool], Backup],
|
|
85
|
+
logger: logging.Logger,
|
|
86
|
+
kind: str = "library",
|
|
87
|
+
active: Callable[[], bool] = lambda: True,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""``folder``: where the backups are kept unless a schedule names another folder; ``settings_file``: the
|
|
90
|
+
schedules with their folders and what their last backups were; ``create``: takes the data for a backup, with
|
|
91
|
+
the albums or without; ``kind``: whose backups these are, ``library`` or ``box`` - only files with that name
|
|
92
|
+
are listed, sent or removed; ``active``: while it says no, nothing is made - a box that works with a library
|
|
93
|
+
leaves the backups to it and keeps its schedules for the day it is unlinked."""
|
|
94
|
+
self._default_folder = folder
|
|
95
|
+
self._settings_file = settings_file
|
|
96
|
+
self._create = create
|
|
97
|
+
self._kind = kind
|
|
98
|
+
self._active = active
|
|
99
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
100
|
+
self._lock = threading.Lock()
|
|
101
|
+
self._state = _loaded(read_json(settings_file, {}))
|
|
102
|
+
self._wake = threading.Event()
|
|
103
|
+
self._stopping = threading.Event()
|
|
104
|
+
self._asked: bool | None = None # a backup asked for at once: with the albums or without
|
|
105
|
+
self._running = False
|
|
106
|
+
self._thread: threading.Thread | None = None
|
|
107
|
+
|
|
108
|
+
# --- what the page shows and sets -------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def active(self) -> bool:
|
|
112
|
+
return self._active()
|
|
113
|
+
|
|
114
|
+
def folder(self, kind: str = "plain") -> str:
|
|
115
|
+
"""Where the backups of a schedule are kept: the folder that was set, else the one of the config."""
|
|
116
|
+
return str(self._state[kind]["folder"] or self._default_folder)
|
|
117
|
+
|
|
118
|
+
def _checked_folder(self, value: object) -> str | None:
|
|
119
|
+
"""The folder to set: None for the one of the config, else an absolute path that the program can write to -
|
|
120
|
+
it is created if it is missing."""
|
|
121
|
+
if value is None or value == "":
|
|
122
|
+
return None
|
|
123
|
+
if not isinstance(value, str) or not os.path.isabs(os.path.expanduser(value)):
|
|
124
|
+
raise BadRequest(
|
|
125
|
+
"folder must be an absolute path, e.g. /volume1/tinyjukebox/backups.", code="backup.folder_absolute"
|
|
126
|
+
)
|
|
127
|
+
folder = os.path.normpath(os.path.expanduser(value))
|
|
128
|
+
try:
|
|
129
|
+
os.makedirs(folder, exist_ok=True)
|
|
130
|
+
except OSError as error:
|
|
131
|
+
raise BadRequest(
|
|
132
|
+
f"the folder {folder} cannot be created: {error.strerror or error}",
|
|
133
|
+
code="backup.folder_create",
|
|
134
|
+
folder=folder,
|
|
135
|
+
) from None
|
|
136
|
+
if not os.access(folder, os.W_OK | os.X_OK):
|
|
137
|
+
raise BadRequest(f"the folder {folder} cannot be written to.", code="backup.folder_write", folder=folder)
|
|
138
|
+
return None if folder == os.path.normpath(self._default_folder) else folder
|
|
139
|
+
|
|
140
|
+
def _files_in(self, folder: str) -> list[dict[str, Any]]:
|
|
141
|
+
"""The backups in ``folder``, newest first. Only files with the name of a backup of this program count."""
|
|
142
|
+
found = []
|
|
143
|
+
with contextlib.suppress(FileNotFoundError, NotADirectoryError):
|
|
144
|
+
for name in sorted(os.listdir(folder), reverse=True):
|
|
145
|
+
match = NAME.fullmatch(name)
|
|
146
|
+
if match is None or match.group(1) != self._kind:
|
|
147
|
+
continue
|
|
148
|
+
day, clock = match.group(2), match.group(3)
|
|
149
|
+
found.append(
|
|
150
|
+
{
|
|
151
|
+
"name": name,
|
|
152
|
+
"when": f"{day[:4]}-{day[4:6]}-{day[6:]} {clock[:2]}:{clock[2:4]}",
|
|
153
|
+
"size": os.path.getsize(os.path.join(folder, name)),
|
|
154
|
+
"albums": match.group(4).startswith("-albums"),
|
|
155
|
+
"folder": folder,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return found
|
|
159
|
+
|
|
160
|
+
def _files(self) -> list[dict[str, Any]]:
|
|
161
|
+
"""The backups of both folders in one list, newest first. The name says when a backup was made and of which
|
|
162
|
+
kind it is."""
|
|
163
|
+
found: dict[str, dict[str, Any]] = {}
|
|
164
|
+
for folder in dict.fromkeys(self.folder(kind) for kind in KINDS):
|
|
165
|
+
for entry in self._files_in(folder):
|
|
166
|
+
found.setdefault(entry["name"], entry)
|
|
167
|
+
return sorted(found.values(), key=lambda entry: (entry["when"], entry["name"]), reverse=True)
|
|
168
|
+
|
|
169
|
+
def status(self) -> dict[str, Any]:
|
|
170
|
+
"""Per schedule (``plain``, ``albums``): its settings, its folder and the one of the config
|
|
171
|
+
(``default_folder``), its last backup (``when``, ``ok``, ``message``) and when the next one is due (local
|
|
172
|
+
time, None without a schedule). Then whether a backup is being made, whether backups are made here at all
|
|
173
|
+
(``active``), and the backups of both folders, newest first."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
state = {kind: dict(self._state[kind]) for kind in KINDS}
|
|
176
|
+
running = self._running or self._asked is not None
|
|
177
|
+
active = self.active
|
|
178
|
+
result: dict[str, Any] = {}
|
|
179
|
+
for kind in KINDS:
|
|
180
|
+
due = self._due(state[kind]) if active and state[kind]["enabled"] else None
|
|
181
|
+
result[kind] = {
|
|
182
|
+
**{key: state[kind][key] for key in DEFAULTS[kind]},
|
|
183
|
+
"folder": self.folder(kind),
|
|
184
|
+
"default_folder": self._default_folder,
|
|
185
|
+
"last": state[kind]["last"],
|
|
186
|
+
"next": due.strftime("%Y-%m-%d %H:%M") if due else None,
|
|
187
|
+
}
|
|
188
|
+
result["running"] = running
|
|
189
|
+
result["active"] = active
|
|
190
|
+
result["backups"] = [{key: value for key, value in entry.items() if key != "folder"} for entry in self._files()]
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
def _checked(self, kind: str, data: object) -> dict[str, Any]:
|
|
194
|
+
"""The settings of a schedule with what ``data`` changes; fields that are left out stay."""
|
|
195
|
+
if not isinstance(data, dict):
|
|
196
|
+
raise BadRequest(f"{kind} must be the settings of a schedule.")
|
|
197
|
+
new = dict(self._state[kind])
|
|
198
|
+
new.update({key: data[key] for key in DEFAULTS[kind] if key in data})
|
|
199
|
+
if "folder" in data:
|
|
200
|
+
new["folder"] = self._checked_folder(data["folder"])
|
|
201
|
+
_time_of_day(new["time"])
|
|
202
|
+
if not isinstance(new["enabled"], bool):
|
|
203
|
+
raise BadRequest("enabled must be true or false.")
|
|
204
|
+
if new["unit"] not in UNITS:
|
|
205
|
+
raise BadRequest(f"unit must be one of {', '.join(UNITS)}.")
|
|
206
|
+
for key, most in (("every", 365), ("keep", 999)):
|
|
207
|
+
if not isinstance(new[key], int) or isinstance(new[key], bool) or not 1 <= new[key] <= most:
|
|
208
|
+
raise BadRequest(f"{key} must be a number from 1 to {most}.")
|
|
209
|
+
return new
|
|
210
|
+
|
|
211
|
+
def set(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
212
|
+
"""Change the schedules: ``{"plain": {…}, "albums": {…}}``; a schedule and fields that are left out stay. An
|
|
213
|
+
empty ``folder`` is the one of the config again. Backups that lie in a folder before stay there; the page
|
|
214
|
+
lists those of the folders that count."""
|
|
215
|
+
if not isinstance(data, dict) or not all(kind in KINDS for kind in data):
|
|
216
|
+
raise BadRequest(f"the schedules are {' and '.join(KINDS)}.")
|
|
217
|
+
new = {kind: self._checked(kind, data[kind]) for kind in data}
|
|
218
|
+
with self._lock:
|
|
219
|
+
moved = any(new[kind]["folder"] != self._state[kind]["folder"] for kind in new)
|
|
220
|
+
if moved and (self._running or self._asked is not None):
|
|
221
|
+
raise Conflict("a backup is being made: the folder cannot be changed now.", code="backup.busy_folder")
|
|
222
|
+
for kind, settings in new.items():
|
|
223
|
+
# what the thread noted about the last backup meanwhile stays
|
|
224
|
+
self._state[kind].update({key: settings[key] for key in (*DEFAULTS[kind], "folder")})
|
|
225
|
+
self._save()
|
|
226
|
+
self._wake.set()
|
|
227
|
+
return self.status()
|
|
228
|
+
|
|
229
|
+
def ask(self, albums: bool) -> None:
|
|
230
|
+
"""Make a backup now, in the background. It goes into the folder of its kind and counts for its number.
|
|
231
|
+
|
|
232
|
+
:raises Conflict: while a backup is being made, and where none are made.
|
|
233
|
+
"""
|
|
234
|
+
if not self.active:
|
|
235
|
+
raise Conflict("this box works with a library, which makes the backups.", code="backup.library")
|
|
236
|
+
with self._lock:
|
|
237
|
+
if self._running or self._asked is not None:
|
|
238
|
+
raise Conflict("a backup is being made.", code="backup.busy")
|
|
239
|
+
if self._thread is None:
|
|
240
|
+
raise Conflict("the program is stopping.")
|
|
241
|
+
self._asked = albums
|
|
242
|
+
self._wake.set()
|
|
243
|
+
|
|
244
|
+
def wake(self) -> None:
|
|
245
|
+
"""Look again at what is due, e.g. because backups are made here from now on."""
|
|
246
|
+
self._wake.set()
|
|
247
|
+
|
|
248
|
+
def path(self, name: str) -> str:
|
|
249
|
+
"""The file of a backup in one of the folders. Only names of the listing are ever opened."""
|
|
250
|
+
for entry in self._files():
|
|
251
|
+
if entry["name"] == name:
|
|
252
|
+
return os.path.join(entry["folder"], name)
|
|
253
|
+
raise NotFound(f"backup '{name}' does not exist.", code="backup.unknown")
|
|
254
|
+
|
|
255
|
+
def remove(self, name: str) -> None:
|
|
256
|
+
os.remove(self.path(name))
|
|
257
|
+
self._logger.info("backup %s removed", name)
|
|
258
|
+
|
|
259
|
+
# --- making them ------------------------------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _save(self) -> None:
|
|
262
|
+
write_json(self._settings_file, self._state)
|
|
263
|
+
|
|
264
|
+
def _due(self, settings: dict[str, Any]) -> datetime.datetime:
|
|
265
|
+
last = datetime.date.fromisoformat(settings["scheduled"]) if settings["scheduled"] else None
|
|
266
|
+
return next_due(datetime.datetime.now(), settings, last)
|
|
267
|
+
|
|
268
|
+
def make(self, albums: bool) -> str:
|
|
269
|
+
"""Write a backup into the folder of its kind and remove the oldest ones of that kind beyond ``keep``;
|
|
270
|
+
returns its name. A backup that fails leaves nothing in the folder."""
|
|
271
|
+
kind = "albums" if albums else "plain"
|
|
272
|
+
folder = self.folder(kind)
|
|
273
|
+
os.makedirs(folder, exist_ok=True)
|
|
274
|
+
backup = self._create(albums)
|
|
275
|
+
_, result = backup.save(folder)
|
|
276
|
+
for name in result["skipped"]:
|
|
277
|
+
self._logger.warning("backup %s: %s disappeared while it was read", backup.filename, name)
|
|
278
|
+
self._logger.info("backup %s: %d files, %d bytes", backup.filename, result["files"], result["bytes"])
|
|
279
|
+
with self._lock:
|
|
280
|
+
keep = int(self._state[kind]["keep"])
|
|
281
|
+
# only the backups of this kind count, also when both kinds are kept in one folder
|
|
282
|
+
for entry in [entry for entry in self._files_in(folder) if entry["albums"] == albums][keep:]:
|
|
283
|
+
os.remove(os.path.join(folder, entry["name"]))
|
|
284
|
+
self._logger.info("backup %s removed: %d are kept", entry["name"], keep)
|
|
285
|
+
return backup.filename
|
|
286
|
+
|
|
287
|
+
def _make(self, kind: str, scheduled: bool) -> None:
|
|
288
|
+
with self._lock:
|
|
289
|
+
self._running = True
|
|
290
|
+
try:
|
|
291
|
+
last = {"when": timestamp(), "ok": True, "message": self.make(kind == "albums")}
|
|
292
|
+
except Exception as error:
|
|
293
|
+
self._logger.exception("the backup failed")
|
|
294
|
+
last = {"when": timestamp(), "ok": False, "message": str(error) or type(error).__name__}
|
|
295
|
+
with self._lock:
|
|
296
|
+
self._running = False
|
|
297
|
+
self._state[kind]["last"] = last
|
|
298
|
+
if scheduled: # also one that failed: the next try is the next day that is due, not at once again
|
|
299
|
+
self._state[kind]["scheduled"] = datetime.date.today().isoformat()
|
|
300
|
+
self._save()
|
|
301
|
+
|
|
302
|
+
def _run(self) -> None:
|
|
303
|
+
while not self._stopping.is_set():
|
|
304
|
+
self._wake.clear() # before the look at what is asked for: what is asked after it wakes the wait below
|
|
305
|
+
with self._lock:
|
|
306
|
+
state = {kind: dict(self._state[kind]) for kind in KINDS}
|
|
307
|
+
asked, self._asked = self._asked, None
|
|
308
|
+
if asked is not None:
|
|
309
|
+
self._make("albums" if asked else "plain", scheduled=False)
|
|
310
|
+
continue
|
|
311
|
+
active = self.active
|
|
312
|
+
due = {kind: self._due(state[kind]) for kind in KINDS if active and state[kind]["enabled"]}
|
|
313
|
+
wait = 3600.0 # looks at the clock again at least every hour: it may have been set
|
|
314
|
+
if due:
|
|
315
|
+
wait = min(wait, (min(due.values()) - datetime.datetime.now()).total_seconds())
|
|
316
|
+
if self._wake.wait(max(wait, 0.0)):
|
|
317
|
+
continue # asked for a backup, a new schedule, or the end
|
|
318
|
+
# what was due before the first one was made: a time that passes while it is written is not put off
|
|
319
|
+
for kind in KINDS:
|
|
320
|
+
if kind in due and datetime.datetime.now() >= due[kind] and not self._stopping.is_set():
|
|
321
|
+
self._make(kind, scheduled=True)
|
|
322
|
+
|
|
323
|
+
def start(self) -> None:
|
|
324
|
+
self._stopping.clear()
|
|
325
|
+
for folder in dict.fromkeys(self.folder(kind) for kind in KINDS):
|
|
326
|
+
with contextlib.suppress(FileNotFoundError, NotADirectoryError):
|
|
327
|
+
for name in os.listdir(folder):
|
|
328
|
+
if name.endswith(PART): # what a program left that was stopped in the middle of a backup
|
|
329
|
+
os.remove(os.path.join(folder, name))
|
|
330
|
+
with self._lock:
|
|
331
|
+
self._thread = threading.Thread(target=self._run, name="backups", daemon=True)
|
|
332
|
+
self._thread.start()
|
|
333
|
+
|
|
334
|
+
def stop(self) -> None:
|
|
335
|
+
self._stopping.set()
|
|
336
|
+
self._wake.set()
|
|
337
|
+
with self._lock:
|
|
338
|
+
thread, self._thread = self._thread, None
|
|
339
|
+
if thread is not None:
|
|
340
|
+
thread.join(timeout=5)
|