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/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,2 @@
1
+ """The library of Tiny Jukebox: the catalog and the albums for all boxes, and the versions of the box software
2
+ (docs/developer/library.md). Console script: ``tinyjukebox-library``."""
@@ -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
+ ]