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/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)