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/images.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The covers of the albums, the radio stations and the entries of Spotify: images in one folder, the images folder.
|
|
2
|
+
|
|
3
|
+
A cover is kept here under a generated name that begins with :data:`ALBUM_PREFIX`, :data:`STATION_PREFIX` or
|
|
4
|
+
:data:`SPOTIFY_PREFIX`; the entry names it. Only these images count: any other file in the folder is ignored. An album
|
|
5
|
+
without such a cover shows an image of its folder or of its tracks (:func:`tinyjukebox.metadata.find_cover`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from tinyjukebox.errors import BadRequest, NotFound, PayloadTooLarge
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ALBUM_PREFIX",
|
|
17
|
+
"IMAGE_TYPES",
|
|
18
|
+
"MAX_IMAGE_SIZE",
|
|
19
|
+
"SPOTIFY_PREFIX",
|
|
20
|
+
"STATION_PREFIX",
|
|
21
|
+
"Images",
|
|
22
|
+
"image_extension",
|
|
23
|
+
"is_cover",
|
|
24
|
+
"take_over",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
IMAGE_TYPES = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
|
|
28
|
+
MAX_IMAGE_SIZE = 10 * 2**20
|
|
29
|
+
STATION_PREFIX = "station-"
|
|
30
|
+
SPOTIFY_PREFIX = "spotify-"
|
|
31
|
+
ALBUM_PREFIX = "album-"
|
|
32
|
+
# the generated name of the cover of an album. Not every "album-…": until 0.28 the folder held the card images of
|
|
33
|
+
# tags, and the images that tinyjukebox-import-covers reads are named "album-<id>"
|
|
34
|
+
_ALBUM_COVER = re.compile(r"album-[0-9a-f]{8}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def image_extension(data: bytes) -> str | None:
|
|
38
|
+
"""File extension for the image type detected from the content - ``None`` for unsupported data."""
|
|
39
|
+
if data.startswith(b"\xff\xd8\xff"):
|
|
40
|
+
return ".jpg"
|
|
41
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
42
|
+
return ".png"
|
|
43
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
44
|
+
return ".webp"
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_cover(name: str) -> bool:
|
|
49
|
+
"""Whether ``name`` is the name of a cover: the prefix of a station or an entry of Spotify, or the generated name
|
|
50
|
+
of the cover of an album, and the extension of an image."""
|
|
51
|
+
base, extension = os.path.splitext(name)
|
|
52
|
+
if extension.lower() not in IMAGE_TYPES:
|
|
53
|
+
return False
|
|
54
|
+
return name.startswith((STATION_PREFIX, SPOTIFY_PREFIX)) or _ALBUM_COVER.fullmatch(base) is not None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def take_over(old_folder: str, folder: str) -> int:
|
|
58
|
+
"""Until 0.28 the covers of the stations were in the folder ``cards``, among the card images of tags. Move them
|
|
59
|
+
from ``old_folder`` to ``folder``; whatever else is in the old folder stays there and is not used any more.
|
|
60
|
+
Returns the number of covers moved."""
|
|
61
|
+
try:
|
|
62
|
+
names = [name for name in os.listdir(old_folder) if is_cover(name)]
|
|
63
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
64
|
+
return 0
|
|
65
|
+
os.makedirs(folder, exist_ok=True)
|
|
66
|
+
moved = 0
|
|
67
|
+
for name in names:
|
|
68
|
+
if not os.path.exists(os.path.join(folder, name)):
|
|
69
|
+
os.replace(os.path.join(old_folder, name), os.path.join(folder, name))
|
|
70
|
+
moved += 1
|
|
71
|
+
return moved
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Images:
|
|
75
|
+
"""The covers of the albums, stations and entries of Spotify in ``folder``. Only files listed in the folder are
|
|
76
|
+
ever opened - names from requests are never used as paths directly."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, folder: str) -> None:
|
|
79
|
+
self.folder = folder
|
|
80
|
+
self._listener: Callable[[], None] | None = None
|
|
81
|
+
|
|
82
|
+
def listen(self, listener: Callable[[], None]) -> None:
|
|
83
|
+
"""``listener()`` is called whenever an image was added or removed - for the synchronization
|
|
84
|
+
(:mod:`tinyjukebox.sync`)."""
|
|
85
|
+
self._listener = listener
|
|
86
|
+
|
|
87
|
+
def _tell(self) -> None:
|
|
88
|
+
if self._listener is not None:
|
|
89
|
+
self._listener()
|
|
90
|
+
|
|
91
|
+
def names(self) -> list[str]:
|
|
92
|
+
try:
|
|
93
|
+
names = os.listdir(self.folder)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
return []
|
|
96
|
+
return sorted(name for name in names if is_cover(name))
|
|
97
|
+
|
|
98
|
+
def has(self, name: str) -> bool:
|
|
99
|
+
"""Whether the image ``name`` is in the folder."""
|
|
100
|
+
return name in self.names()
|
|
101
|
+
|
|
102
|
+
def _check_name(self, name: str) -> str:
|
|
103
|
+
if name not in self.names():
|
|
104
|
+
raise NotFound(f"unknown image '{name}'.")
|
|
105
|
+
return name
|
|
106
|
+
|
|
107
|
+
def image(self, name: str) -> tuple[bytes, str]:
|
|
108
|
+
"""(content, mime type) of an image."""
|
|
109
|
+
name = self._check_name(name)
|
|
110
|
+
with open(os.path.join(self.folder, name), "rb") as f:
|
|
111
|
+
return f.read(), IMAGE_TYPES[os.path.splitext(name)[1].lower()]
|
|
112
|
+
|
|
113
|
+
def add(self, data: bytes, prefix: str = STATION_PREFIX) -> str:
|
|
114
|
+
"""Store a new cover (jpeg, png or webp) of a station or, with :data:`ALBUM_PREFIX` or :data:`SPOTIFY_PREFIX`,
|
|
115
|
+
of an album or an entry of Spotify - returns its generated name."""
|
|
116
|
+
if len(data) > MAX_IMAGE_SIZE:
|
|
117
|
+
raise PayloadTooLarge("image is too large.", code="image.too_large")
|
|
118
|
+
extension = image_extension(data)
|
|
119
|
+
if extension is None:
|
|
120
|
+
raise BadRequest("only jpeg, png and webp images are supported.", code="image.type")
|
|
121
|
+
os.makedirs(self.folder, exist_ok=True)
|
|
122
|
+
name = f"{prefix}{secrets.token_hex(4)}{extension}"
|
|
123
|
+
temp = os.path.join(self.folder, f".{name}.tmp") # hidden: no cover while it is written
|
|
124
|
+
with open(temp, "wb") as f:
|
|
125
|
+
f.write(data)
|
|
126
|
+
os.replace(temp, os.path.join(self.folder, name))
|
|
127
|
+
self._tell()
|
|
128
|
+
return name
|
|
129
|
+
|
|
130
|
+
def delete(self, name: str) -> None:
|
|
131
|
+
self._check_name(name)
|
|
132
|
+
os.remove(os.path.join(self.folder, name))
|
|
133
|
+
self._tell()
|
tinyjukebox/latency.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Latency of the reaction chains of the box (tag read → playback, button pressed → volume set)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from tinyjukebox.core.logging import get_child
|
|
8
|
+
|
|
9
|
+
__all__ = ["ReactionTimer"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ReactionTimer:
|
|
13
|
+
"""Measures one reaction chain and logs one info line per step with the time since its trigger.
|
|
14
|
+
|
|
15
|
+
:meth:`begin` starts a measurement (a pending one is dropped), :meth:`step` logs an intermediate step and
|
|
16
|
+
:meth:`end` logs the last step and finishes the measurement. Steps without a pending measurement are ignored -
|
|
17
|
+
e.g. the start of the next track is no reaction to a tag.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, name: str, logger: logging.Logger) -> None:
|
|
21
|
+
self._name = name
|
|
22
|
+
self._logger = get_child(logger, "latency")
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._started: float | None = None
|
|
25
|
+
|
|
26
|
+
def begin(self, started: float | None = None) -> None:
|
|
27
|
+
"""Start a measurement at ``started`` (``time.monotonic()``; default: now)."""
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._started = time.monotonic() if started is None else started
|
|
30
|
+
|
|
31
|
+
def step(self, what: str) -> None:
|
|
32
|
+
self._log(what, finish=False)
|
|
33
|
+
|
|
34
|
+
def end(self, what: str) -> None:
|
|
35
|
+
self._log(what, finish=True)
|
|
36
|
+
|
|
37
|
+
def _log(self, what: str, finish: bool) -> None:
|
|
38
|
+
with self._lock:
|
|
39
|
+
started = self._started
|
|
40
|
+
if finish:
|
|
41
|
+
self._started = None
|
|
42
|
+
if started is not None:
|
|
43
|
+
self._logger.info("%s: %s after %.0f ms", self._name, what, (time.monotonic() - started) * 1000)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""What the library keeps of the logs of the boxes (docs/developer/library.md, section 4.22): the records a box
|
|
2
|
+
delivers, and the last startup failure of a box that cannot start.
|
|
3
|
+
|
|
4
|
+
SQLite of its own, apart from the journal: a listening record and a technical log are different things, and this one
|
|
5
|
+
is thrown away by age and by number. A record is taken once - a box that sends a batch again after a lost answer
|
|
6
|
+
changes nothing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from tinyjukebox.sync.util import timestamp
|
|
16
|
+
|
|
17
|
+
__all__ = ["DAYS", "PER_BOX", "Diagnostics"]
|
|
18
|
+
|
|
19
|
+
DAYS = 30
|
|
20
|
+
PER_BOX = 20000
|
|
21
|
+
LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|
22
|
+
COLUMNS = ("box", "boot", "seq", "received", "time", "uptime", "level", "component", "code", "message", "version")
|
|
23
|
+
INSERT = "INSERT OR IGNORE INTO records VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
24
|
+
FILTERS = ("box", "level", "component", "version", "code", "since", "until", "text")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Diagnostics:
|
|
28
|
+
def __init__(self, filename: str, days: int = DAYS, per_box: int = PER_BOX) -> None:
|
|
29
|
+
self._days = days
|
|
30
|
+
self._per_box = per_box
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self._db = sqlite3.connect(filename, check_same_thread=False)
|
|
33
|
+
with self._lock, self._db:
|
|
34
|
+
self._db.execute(
|
|
35
|
+
"CREATE TABLE IF NOT EXISTS records (box TEXT, boot INTEGER, seq INTEGER, received TEXT, time TEXT, "
|
|
36
|
+
"uptime REAL, level TEXT, component TEXT, code TEXT, message TEXT, version TEXT, data TEXT, "
|
|
37
|
+
"PRIMARY KEY (box, boot, seq))"
|
|
38
|
+
)
|
|
39
|
+
self._db.execute("CREATE INDEX IF NOT EXISTS records_time ON records (box, time)")
|
|
40
|
+
self._db.execute("CREATE TABLE IF NOT EXISTS startup (box TEXT PRIMARY KEY, received TEXT, report TEXT)")
|
|
41
|
+
self._added = 0
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._db.close()
|
|
46
|
+
|
|
47
|
+
# --- what a box delivers -----------------------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def add(self, box: str, envelope: dict[str, Any]) -> int:
|
|
50
|
+
"""Records of one start of a box; returns how many were new."""
|
|
51
|
+
boot, version = int(envelope.get("boot") or 0), str(envelope.get("version") or "")
|
|
52
|
+
records = envelope.get("records") or []
|
|
53
|
+
if not isinstance(records, list):
|
|
54
|
+
raise TypeError("'records' must be a list")
|
|
55
|
+
received, new = timestamp(), 0
|
|
56
|
+
rows = []
|
|
57
|
+
for record in records:
|
|
58
|
+
if not isinstance(record, dict) or not isinstance(record.get("seq"), int):
|
|
59
|
+
continue
|
|
60
|
+
extra = {key: value for key, value in record.items() if key not in COLUMNS}
|
|
61
|
+
rows.append((
|
|
62
|
+
box, boot, record["seq"], received, str(record.get("time") or received),
|
|
63
|
+
float(record.get("uptime") or 0), str(record.get("level") or "INFO"),
|
|
64
|
+
str(record.get("component") or ""), str(record.get("code") or "")[:200],
|
|
65
|
+
str(record.get("message") or "")[:4000], version, json.dumps(extra),
|
|
66
|
+
)) # fmt: skip
|
|
67
|
+
dropped = {str(level): int(count) for level, count in (envelope.get("dropped") or {}).items() if count}
|
|
68
|
+
if dropped: # the box had to throw records away: the library says how many
|
|
69
|
+
rows.append((
|
|
70
|
+
box, boot, _own_number(), received, received, 0.0,
|
|
71
|
+
"WARNING", "diagnostics", "diagnostics.dropped",
|
|
72
|
+
"the box dropped records it could not deliver: "
|
|
73
|
+
+ ", ".join(f"{count} {level}" for level, count in sorted(dropped.items())),
|
|
74
|
+
version, json.dumps({"params": dropped}),
|
|
75
|
+
)) # fmt: skip
|
|
76
|
+
with self._lock, self._db:
|
|
77
|
+
for row in rows:
|
|
78
|
+
new += self._db.execute(INSERT, row).rowcount
|
|
79
|
+
self._added += new
|
|
80
|
+
if self._added > 500: # now and then, not with every batch
|
|
81
|
+
self._added = 0
|
|
82
|
+
self._prune()
|
|
83
|
+
return new
|
|
84
|
+
|
|
85
|
+
def _prune(self) -> None:
|
|
86
|
+
"""Called with the lock held: the records older than the days, and per box beyond the number."""
|
|
87
|
+
limit = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=self._days)
|
|
88
|
+
self._db.execute("DELETE FROM records WHERE time < ?", (limit.strftime("%Y-%m-%dT%H:%M:%SZ"),))
|
|
89
|
+
for (box,) in self._db.execute("SELECT DISTINCT box FROM records").fetchall():
|
|
90
|
+
self._db.execute(
|
|
91
|
+
"DELETE FROM records WHERE box = ? AND rowid NOT IN "
|
|
92
|
+
"(SELECT rowid FROM records WHERE box = ? ORDER BY time DESC, seq DESC LIMIT ?)",
|
|
93
|
+
(box, box, self._per_box),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def prune(self) -> None:
|
|
97
|
+
with self._lock, self._db:
|
|
98
|
+
self._prune()
|
|
99
|
+
|
|
100
|
+
# --- a box that cannot start -------------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def startup(self, box: str, report: dict[str, Any]) -> None:
|
|
103
|
+
"""The last startup failure of a box replaces the one before: a restart loop does not fill the library."""
|
|
104
|
+
clean: dict[str, Any] = {
|
|
105
|
+
key: str(report[key])[:limit]
|
|
106
|
+
for key, limit in (("version", 50), ("slot", 20), ("since", 40), ("error", 8000), ("recovery", 2000))
|
|
107
|
+
if report.get(key) not in (None, "")
|
|
108
|
+
}
|
|
109
|
+
clean["failures"] = int(report.get("failures") or 0)
|
|
110
|
+
with self._lock, self._db:
|
|
111
|
+
self._db.execute("INSERT OR REPLACE INTO startup VALUES (?, ?, ?)", (box, timestamp(), json.dumps(clean)))
|
|
112
|
+
|
|
113
|
+
def started(self, box: str) -> None:
|
|
114
|
+
"""The application of the box runs again: its last startup failure becomes a record of its history."""
|
|
115
|
+
with self._lock, self._db:
|
|
116
|
+
row = self._db.execute("SELECT received, report FROM startup WHERE box = ?", (box,)).fetchone()
|
|
117
|
+
if row is None:
|
|
118
|
+
return
|
|
119
|
+
received, report = row[0], json.loads(row[1])
|
|
120
|
+
self._db.execute("DELETE FROM startup WHERE box = ?", (box,))
|
|
121
|
+
message = (
|
|
122
|
+
f"the box could not start ({report.get('failures', 0)} failed starts since {report.get('since', '?')})"
|
|
123
|
+
+ (f": {report['error'].strip().splitlines()[-1]}" if report.get("error", "").strip() else "")
|
|
124
|
+
)
|
|
125
|
+
self._db.execute(
|
|
126
|
+
INSERT,
|
|
127
|
+
(box, 0, _own_number(), received, received, 0.0, "ERROR", "start", "startup.failed", message[:4000],
|
|
128
|
+
report.get("version", ""), json.dumps({"traceback": report.get("error", ""), "params": report})),
|
|
129
|
+
) # fmt: skip
|
|
130
|
+
|
|
131
|
+
def startups(self) -> dict[str, dict[str, Any]]:
|
|
132
|
+
"""The boxes that cannot start, with their last report."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
rows = self._db.execute("SELECT box, received, report FROM startup").fetchall()
|
|
135
|
+
return {box: {**json.loads(report), "received": received} for box, received, report in rows}
|
|
136
|
+
|
|
137
|
+
# --- for the page ------------------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def query(self, limit: int = 1000, **filters: str) -> list[dict[str, Any]]:
|
|
140
|
+
"""The newest records that match, oldest first. ``level``: at least this level; ``text``: in the message, the
|
|
141
|
+
code or the component; ``since``/``until``: times as the records carry them."""
|
|
142
|
+
where, values = ["1"], []
|
|
143
|
+
for key in ("box", "component", "version", "code"):
|
|
144
|
+
if filters.get(key):
|
|
145
|
+
where.append(f"{key} = ?")
|
|
146
|
+
values.append(filters[key])
|
|
147
|
+
if filters.get("level") in LEVELS:
|
|
148
|
+
levels = LEVELS[LEVELS.index(filters["level"]) :]
|
|
149
|
+
where.append(f"level IN ({', '.join('?' * len(levels))})")
|
|
150
|
+
values.extend(levels)
|
|
151
|
+
if filters.get("since"):
|
|
152
|
+
where.append("time >= ?")
|
|
153
|
+
values.append(filters["since"])
|
|
154
|
+
if filters.get("until"):
|
|
155
|
+
where.append("time <= ?")
|
|
156
|
+
values.append(filters["until"])
|
|
157
|
+
if filters.get("text"):
|
|
158
|
+
where.append("(message LIKE ? OR code LIKE ? OR component LIKE ?)")
|
|
159
|
+
values.extend([f"%{filters['text']}%"] * 3)
|
|
160
|
+
with self._lock:
|
|
161
|
+
rows = self._db.execute(
|
|
162
|
+
f"SELECT {', '.join(COLUMNS)}, data FROM records WHERE {' AND '.join(where)} " # noqa: S608 - fixed names
|
|
163
|
+
"ORDER BY time DESC, boot DESC, seq DESC LIMIT ?",
|
|
164
|
+
(*values, limit),
|
|
165
|
+
).fetchall()
|
|
166
|
+
return [{**dict(zip(COLUMNS, row[:-1], strict=True)), **json.loads(row[-1])} for row in reversed(rows)]
|
|
167
|
+
|
|
168
|
+
def choices(self, box: str | None = None) -> dict[str, list[str]]:
|
|
169
|
+
"""What the filters of the page offer: the components, versions and codes there are."""
|
|
170
|
+
result = {}
|
|
171
|
+
with self._lock:
|
|
172
|
+
for key in ("component", "version", "code"):
|
|
173
|
+
rows = self._db.execute(
|
|
174
|
+
f"SELECT DISTINCT {key} FROM records WHERE (? IS NULL OR box = ?) ORDER BY {key}", # noqa: S608
|
|
175
|
+
(box, box),
|
|
176
|
+
).fetchall()
|
|
177
|
+
result[key] = [row[0] for row in rows if row[0]]
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _own_number() -> int:
|
|
182
|
+
"""The number of a record the library writes itself: negative, so that it never meets one of a box."""
|
|
183
|
+
return -int(datetime.datetime.now(datetime.UTC).timestamp() * 1_000_000)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The journal of the library (docs/developer/library.md, section 7): what the boxes delivered, and what
|
|
2
|
+
the library noted itself.
|
|
3
|
+
|
|
4
|
+
SQLite, part of Python. A line of a box is taken exactly once and never changed; it can only be flagged as discarded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from tinyjukebox.sync.util import timestamp
|
|
15
|
+
|
|
16
|
+
__all__ = ["Journal"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Journal:
|
|
20
|
+
def __init__(self, filename: str) -> None:
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._db = sqlite3.connect(filename, check_same_thread=False)
|
|
23
|
+
with self._lock, self._db:
|
|
24
|
+
self._db.execute(
|
|
25
|
+
"CREATE TABLE IF NOT EXISTS lines (box TEXT, n INTEGER, received TEXT, version TEXT, line TEXT, "
|
|
26
|
+
"discarded INTEGER DEFAULT 0, PRIMARY KEY (box, n))"
|
|
27
|
+
)
|
|
28
|
+
self._db.execute(
|
|
29
|
+
"CREATE TABLE IF NOT EXISTS records (id INTEGER PRIMARY KEY, time TEXT, kind TEXT, box TEXT, data TEXT)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._db.close()
|
|
35
|
+
|
|
36
|
+
def add(self, box: str, confirmed: int, lines: list[dict[str, Any]]) -> int:
|
|
37
|
+
"""Take the lines that follow the number ``confirmed`` without a gap; return the number confirmed now."""
|
|
38
|
+
with self._lock, self._db:
|
|
39
|
+
for line in sorted(lines, key=lambda line: int(line["n"])):
|
|
40
|
+
if line["n"] <= confirmed:
|
|
41
|
+
continue # delivered before: the confirmation got lost
|
|
42
|
+
if line["n"] != confirmed + 1:
|
|
43
|
+
break
|
|
44
|
+
self._db.execute(
|
|
45
|
+
"INSERT OR IGNORE INTO lines (box, n, received, version, line) VALUES (?, ?, ?, ?, ?)",
|
|
46
|
+
(box, line["n"], timestamp(), line.get("version"), json.dumps(line)),
|
|
47
|
+
)
|
|
48
|
+
confirmed = line["n"]
|
|
49
|
+
return confirmed
|
|
50
|
+
|
|
51
|
+
def snapshot(self) -> bytes:
|
|
52
|
+
"""The database as a file of its own, for a backup: a copy made by SQLite, never the file that is written."""
|
|
53
|
+
with self._lock, tempfile.TemporaryDirectory() as folder:
|
|
54
|
+
filename = os.path.join(folder, "journal.sqlite")
|
|
55
|
+
copy = sqlite3.connect(filename)
|
|
56
|
+
try:
|
|
57
|
+
self._db.backup(copy)
|
|
58
|
+
finally:
|
|
59
|
+
copy.close()
|
|
60
|
+
with open(filename, "rb") as infile:
|
|
61
|
+
return infile.read()
|
|
62
|
+
|
|
63
|
+
def discard(self, box: str, version: str) -> int:
|
|
64
|
+
with self._lock, self._db:
|
|
65
|
+
return self._db.execute(
|
|
66
|
+
"UPDATE lines SET discarded = 1 WHERE box = ? AND version = ?", (box, version)
|
|
67
|
+
).rowcount
|
|
68
|
+
|
|
69
|
+
def lines(self, box: str | None = None) -> list[dict[str, Any]]:
|
|
70
|
+
with self._lock:
|
|
71
|
+
rows = self._db.execute(
|
|
72
|
+
"SELECT box, discarded, line FROM lines WHERE (? IS NULL OR box = ?) ORDER BY box, n", (box, box)
|
|
73
|
+
).fetchall()
|
|
74
|
+
return [{**json.loads(line), "box": row_box, "discarded": bool(discarded)} for row_box, discarded, line in rows]
|
|
75
|
+
|
|
76
|
+
def record(self, kind: str, box: str | None, data: dict[str, Any]) -> None:
|
|
77
|
+
"""Something the library notes itself: a conflict, a rejected change, a switch of version of a box."""
|
|
78
|
+
with self._lock, self._db:
|
|
79
|
+
self._db.execute(
|
|
80
|
+
"INSERT INTO records (time, kind, box, data) VALUES (?, ?, ?, ?)",
|
|
81
|
+
(timestamp(), kind, box, json.dumps(data)),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def records(self, kind: str | None = None, box: str | None = None) -> list[dict[str, Any]]:
|
|
85
|
+
with self._lock:
|
|
86
|
+
rows = self._db.execute(
|
|
87
|
+
"SELECT time, kind, box, data FROM records WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR box = ?) "
|
|
88
|
+
"ORDER BY id",
|
|
89
|
+
(kind, kind, box, box),
|
|
90
|
+
).fetchall()
|
|
91
|
+
return [
|
|
92
|
+
{"time": time, "kind": row_kind, "box": row_box, **json.loads(data)}
|
|
93
|
+
for time, row_kind, row_box, data in rows
|
|
94
|
+
]
|