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
|
@@ -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
|
+
}
|