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
@@ -0,0 +1,288 @@
1
+ """The play statistics (docs/developer/library.md, section 7): what the journals of the boxes say about what
2
+ was played when.
3
+
4
+ A journal line carries the box, the start of the box, a running number, the time since that start and the time of
5
+ day with a note whether the clock was synchronized then. The Pi has no real-time clock: for a line written before
6
+ the clock was set, the time of day is computed from another line of the same start that has it. For a start entirely
7
+ without WLAN the durations are right and only the time of day is unknown.
8
+
9
+ Switching a box off leaves no line. How long it ran is what the last line of that start says: a box writes a line
10
+ "alive" from time to time for that.
11
+
12
+ What belongs to one child is counted per box: the days, the hours of a day, speaker or headphones. The caller asks for
13
+ one box by passing its lines only; a box without a library counts its own journal this way.
14
+ """
15
+
16
+ import datetime
17
+ import itertools
18
+ import time
19
+ from collections import defaultdict
20
+ from typing import Any
21
+
22
+ from tinyjukebox.sync.data import RECOVERY_GAP
23
+
24
+ __all__ = ["statistics"]
25
+
26
+ FORMAT = "%Y-%m-%dT%H:%M:%SZ"
27
+
28
+
29
+ def _times(lines: list[dict[str, Any]]) -> dict[tuple[str, int], float]:
30
+ """Per box and start: the time of day (seconds since the epoch) at which the box started, where it is known."""
31
+ started: dict[tuple[str, int], float] = {}
32
+ for line in lines:
33
+ clock = line.get("clock", {})
34
+ if clock.get("synchronized"):
35
+ moment = datetime.datetime.strptime(clock["time"], FORMAT).replace(tzinfo=datetime.UTC).timestamp()
36
+ started[(line["box"], line["start"])] = moment - line["uptime"]
37
+ return started
38
+
39
+
40
+ class _Session:
41
+ """A tag on a box, from the moment it was placed."""
42
+
43
+ def __init__(self, line: dict[str, Any]) -> None:
44
+ self.uid = str(line.get("uid"))
45
+ self.kind = line.get("kind")
46
+ self.target = line.get("target")
47
+ self.output = line.get("output") or "speaker"
48
+ self.start = line["start"]
49
+ self.began = line["uptime"]
50
+
51
+
52
+ def statistics( # noqa: PLR0912, PLR0915 - one pass over the journal that counts everything
53
+ lines: list[dict[str, Any]],
54
+ records: list[dict[str, Any]],
55
+ tags: dict[str, Any],
56
+ targets: dict[str, Any],
57
+ boxes: dict[str, str],
58
+ gone: frozenset[str] = frozenset(),
59
+ now: float | None = None,
60
+ ) -> dict[str, Any]:
61
+ """``lines``: the journal lines of all boxes in the order of their numbers; ``records``: what the library noted
62
+ itself; ``tags`` and ``targets``: the catalog; ``boxes``: names by id; ``gone``: ids of boxes that were removed.
63
+ For the statistics of one box: its lines and its records only."""
64
+ started = _times(lines)
65
+ per_tag: dict[str, dict[str, Any]] = defaultdict(
66
+ lambda: {"plays": 0, "seconds": 0.0, "finished": 0, "last": None, "target": None, "boxes": defaultdict(float)}
67
+ )
68
+ per_day: dict[tuple[str, str], dict[str, Any]] = defaultdict( # per day and box: a day is the day of one box
69
+ lambda: {"seconds": 0.0, "speaker": 0.0, "headphones": 0.0, "hours": defaultdict(float)}
70
+ )
71
+ stations: dict[str, float] = defaultdict(float)
72
+ skipped: dict[tuple[str, int | str], int] = defaultdict(int)
73
+ titles: dict[tuple[str, int | str], str] = {} # the tracks of an entry of Spotify: by address, with their title
74
+ per_box: dict[str, dict[str, Any]] = defaultdict(lambda: {"seconds": 0.0, "plays": 0, "last": None})
75
+ versions: dict[str, dict[str, float]] = defaultdict(
76
+ lambda: {"starts": 0, "errors": 0, "fallbacks": 0, "installed": 0.0, "running": 0.0, "active": 0.0}
77
+ )
78
+ runs: dict[tuple[str, int], dict[str, Any]] = {} # per box and start: the version, and how long the box ran
79
+ unknown_time = 0.0
80
+ sessions: dict[str, _Session | None] = {}
81
+ last: dict[str, dict[str, Any]] = {} # the last line of every box
82
+
83
+ def moment(line: dict[str, Any]) -> float | None:
84
+ offset = started.get((line["box"], line["start"]))
85
+ return None if offset is None else offset + line["uptime"]
86
+
87
+ def listened(line: dict[str, Any], session: _Session, seconds: float) -> None:
88
+ nonlocal unknown_time
89
+ if seconds <= 0:
90
+ return
91
+ entry = per_tag[session.uid]
92
+ entry["seconds"] += seconds
93
+ entry["boxes"][line["box"]] += seconds
94
+ per_box[line["box"]]["seconds"] += seconds
95
+ versions[str(line.get("version"))]["active"] += seconds
96
+ when = moment(line)
97
+ if when is None:
98
+ unknown_time += seconds
99
+ return
100
+ local = datetime.datetime.fromtimestamp(when)
101
+ day = per_day[(local.strftime("%Y-%m-%d"), line["box"])]
102
+ day["seconds"] += seconds
103
+ day[session.output if session.output in ("speaker", "headphones") else "speaker"] += seconds
104
+ day["hours"][local.hour] += seconds
105
+
106
+ def close(box: str, line: dict[str, Any]) -> None:
107
+ """The tag is gone - or the box was switched off, which leaves no line: then the last line of the start
108
+ before says how long the session lasted at least."""
109
+ session = sessions.get(box)
110
+ sessions[box] = None
111
+ if session is None or session.kind != "radio":
112
+ return # the tracks of an album were counted one by one
113
+ end = line if line["start"] == session.start else last.get(box)
114
+ if end is not None and end["start"] == session.start:
115
+ seconds = end["uptime"] - session.began
116
+ stations[str(session.target)] += max(0.0, seconds)
117
+ listened(end, session, seconds)
118
+
119
+ for line in lines:
120
+ box, event = line["box"], line.get("event")
121
+ if line.get("discarded") or event == RECOVERY_GAP:
122
+ continue # written by a version that was found defective: not trusted; or a line that was lost
123
+ session = sessions.get(box)
124
+ run = runs.setdefault((box, line["start"]), {"version": str(line.get("version")), "uptime": 0.0})
125
+ run["uptime"] = max(run["uptime"], line["uptime"])
126
+ if event == "start":
127
+ close(box, line)
128
+ versions[str(line.get("version"))]["starts"] += 1
129
+ elif event == "tag":
130
+ close(box, line)
131
+ placed = sessions[box] = _Session(line)
132
+ if placed.kind is not None:
133
+ entry = per_tag[placed.uid]
134
+ entry["target"] = placed.target # what the tag played last
135
+ when = moment(line)
136
+ for counted in (entry, per_box[box]):
137
+ counted["plays"] += 1
138
+ if when is not None and (counted["last"] is None or when > counted["last"]):
139
+ counted["last"] = when
140
+ elif event == "track" and session is not None:
141
+ listened(line, session, float(line.get("seconds", 0)))
142
+ if not line.get("to_end"):
143
+ key: tuple[str, int | str]
144
+ if line.get("uri"): # an entry of Spotify: the place of a track in a playlist changes
145
+ key = (str(line.get("album")), str(line["uri"]))
146
+ titles[key] = str(line.get("title") or line["uri"])
147
+ else:
148
+ key = (str(line.get("album")), int(line.get("track", 0)))
149
+ skipped[key] += 1
150
+ elif event == "end" and session is not None and line.get("reason") == "album finished":
151
+ per_tag[session.uid]["finished"] += 1
152
+ elif event == "end":
153
+ close(box, line)
154
+ elif event == "unexpected":
155
+ versions[str(line.get("version"))]["errors"] += 1
156
+ last[box] = line
157
+ for record in records:
158
+ if record.get("kind") == "switch" and record.get("why") == "health check failed":
159
+ versions[str(record.get("from", "")).partition(":")[2]]["fallbacks"] += 1
160
+ for run in runs.values():
161
+ versions[run["version"]]["running"] += run["uptime"]
162
+ for version, seconds in _installed(runs, started, gone, time.time() if now is None else now).items():
163
+ versions[version]["installed"] += seconds
164
+ return _tables(
165
+ per_tag, per_day, stations, skipped, titles, per_box, versions, unknown_time, tags, targets, boxes, gone
166
+ )
167
+
168
+
169
+ def _installed(
170
+ runs: dict[tuple[str, int], dict[str, Any]], started: dict[tuple[str, int], float], gone: frozenset[str], now: float
171
+ ) -> dict[str, float]:
172
+ """Seconds a version was the one of a box, summed over the boxes: from its first start with a known time of day
173
+ to the first start of another version, for the version a box has now until today. A box that was removed has its
174
+ last version until it was seen last."""
175
+ installed: dict[str, float] = defaultdict(float)
176
+ per_box: dict[str, list[tuple[float, float, str]]] = defaultdict(list)
177
+ for (box, start), run in sorted(runs.items()):
178
+ if (box, start) in started:
179
+ began = started[(box, start)]
180
+ per_box[box].append((began, began + run["uptime"], run["version"]))
181
+ for box, known in per_box.items():
182
+ for (began, _, version), (following, _, _) in itertools.pairwise(known):
183
+ installed[version] += max(0.0, following - began)
184
+ began, seen, version = known[-1]
185
+ installed[version] += max(0.0, (seen if box in gone else now) - began)
186
+ return installed
187
+
188
+
189
+ def _stamp(when: float | None) -> str | None:
190
+ return None if when is None else datetime.datetime.fromtimestamp(when, datetime.UTC).strftime(FORMAT)
191
+
192
+
193
+ def _tables(
194
+ per_tag: dict[str, dict[str, Any]],
195
+ per_day: dict[tuple[str, str], dict[str, Any]],
196
+ stations: dict[str, float],
197
+ skipped: dict[tuple[str, int | str], int],
198
+ titles: dict[tuple[str, int | str], str],
199
+ per_box: dict[str, dict[str, Any]],
200
+ versions: dict[str, dict[str, float]],
201
+ unknown_time: float,
202
+ tags: dict[str, Any],
203
+ targets: dict[str, Any],
204
+ boxes: dict[str, str],
205
+ gone: frozenset[str],
206
+ ) -> dict[str, Any]:
207
+ """What was counted, as the tables of the page."""
208
+
209
+ def box_of(box: str) -> str:
210
+ return boxes.get(box, box)
211
+
212
+ def name_of(uid: str) -> str:
213
+ return str(tags.get(uid, {}).get("tag", {}).get("name") or uid)
214
+
215
+ def title_of(target: str) -> str:
216
+ value = targets.get(target, {})
217
+ return str(value.get("name") or value.get("title") or value.get("album-folder") or target)
218
+
219
+ by_tag: list[dict[str, Any]] = [
220
+ {
221
+ "uid": uid,
222
+ "name": name_of(uid),
223
+ "album": title_of(str(entry["target"])) if entry["target"] is not None else None,
224
+ "plays": entry["plays"],
225
+ "seconds": round(entry["seconds"]),
226
+ "finished": entry["finished"],
227
+ "last": _stamp(entry["last"]),
228
+ "boxes": [
229
+ {"id": box, "name": box_of(box), "seconds": round(seconds)}
230
+ for box, seconds in sorted(entry["boxes"].items(), key=lambda item: (-item[1], box_of(item[0])))
231
+ ],
232
+ }
233
+ for uid, entry in per_tag.items()
234
+ ]
235
+ by_day = [
236
+ {
237
+ "day": day,
238
+ "box": box,
239
+ "name": box_of(box),
240
+ "seconds": round(entry["seconds"]),
241
+ "speaker": round(entry["speaker"]),
242
+ "headphones": round(entry["headphones"]),
243
+ "hours": {str(hour): round(seconds) for hour, seconds in sorted(entry["hours"].items())},
244
+ }
245
+ # the newest day first, the boxes of a day by name
246
+ for (day, box), entry in sorted(
247
+ sorted(per_day.items(), key=lambda item: box_of(item[0][1]).casefold()),
248
+ key=lambda item: item[0][0],
249
+ reverse=True,
250
+ )
251
+ ]
252
+ # every box that is known, and every box that left something: a removed box stays as long as it has lines
253
+ listed = [box for box in boxes if box not in gone or box in per_box] + [box for box in per_box if box not in boxes]
254
+ by_box = [
255
+ {
256
+ "id": box,
257
+ "name": box_of(box),
258
+ "seconds": round(per_box[box]["seconds"]) if box in per_box else 0,
259
+ "plays": per_box[box]["plays"] if box in per_box else 0,
260
+ "last": _stamp(per_box[box]["last"]) if box in per_box else None,
261
+ }
262
+ for box in listed
263
+ ]
264
+ by_station: list[dict[str, Any]] = [
265
+ {"id": target, "name": title_of(target), "seconds": round(seconds)} for target, seconds in stations.items()
266
+ ]
267
+ by_track: list[dict[str, Any]] = [
268
+ {
269
+ "album": album,
270
+ "title": title_of(album),
271
+ "track": track + 1 if isinstance(track, int) else titles[(album, track)],
272
+ "count": count,
273
+ }
274
+ for (album, track), count in skipped.items()
275
+ ]
276
+ return {
277
+ "tags": sorted(by_tag, key=lambda entry: (-entry["seconds"], entry["name"])),
278
+ "never": sorted(name_of(uid) for uid in tags if per_tag.get(uid, {}).get("plays", 0) == 0),
279
+ "days": by_day,
280
+ "unknown_time": round(unknown_time),
281
+ "stations": sorted(by_station, key=lambda entry: -entry["seconds"]),
282
+ "skipped": sorted(by_track, key=lambda entry: -entry["count"])[:50],
283
+ "versions": [
284
+ {"version": version, **{key: round(value) for key, value in counts.items()}}
285
+ for version, counts in sorted(versions.items())
286
+ ],
287
+ "boxes": sorted(by_box, key=lambda entry: (entry["name"].casefold(), entry["id"])),
288
+ }