Skip to content

Commit c2e7d44

Browse files
fhirschmannclaude
andcommitted
feat: most-played-cards statistics
Adds a per-card play counter (NVS 'rfidPlayCnt'), incremented in the RFID lookup handler when a music card starts a playlist (Playstats_NoteCardPlay). New GET /topcards returns the top 10 cards by play count with their assigned fileOrUrl + mode; the info dialog shows the top 3 next to the daily listening totals. A card's counter is cleared when the card is deleted, so removed cards leave no ghost stats. Verified on the test device: playing card A twice + B once -> /topcards ranks A(2) before B(1) with their paths; deleting both clears the counters (/topcards empty); no console errors. Synthetic test data cleaned from server + device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4123db6 commit c2e7d44

9 files changed

Lines changed: 92 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
147147
| **Now-playing info dialog**: an info icon next to "Current track" in the Control tab opens a dialog with the full track detail — ID3 title/artist/album, embedded cover, file path + size, play mode, track x/y, **codec / sample rate / bitrate / channels**, position/duration and the RFID tag that started the playlist. Served on demand via a new `GET /currenttrack` (so it doesn't bloat the frequent `trackinfo` websocket). Also: the **Unlocked** control button now uses the neutral (inactive) styling like the other toggles — only **Locked** keeps an accent colour. i18n de/en/fr | [`trackinfo-dialog`](../../releases/tag/trackinfo-dialog) |
148148
| **Settings tabs de-cluttered**: the long "General" sub-tab was split — the **LED** settings (NeoPixel brightness + LED config) moved into a new **LEDs** sub-tab, and **Deep sleep + Battery** into a new **Power** sub-tab. Both new tabs are covered by the same auto-save. General now holds just volume/branding/options/playlist/rotary. i18n de/en/fr | [`settings-split`](../../releases/tag/settings-split) |
149149
| **Folder cover-art fallback**: when a track has no embedded ID3 cover, `/cover` now serves a sidecar image from the track's folder (`cover.jpg`/`.jpeg`/`.png` or `folder.jpg`/`.png`) before falling back to the placeholder icon — so audiobooks/albums with a folder image show art in the Control tab | [`cover-fallback`](../../releases/tag/cover-fallback) |
150+
| **Most-played cards (listening stats)**: a per-card play counter (incremented when a music card starts a playlist) drives a **most-played** list — the top 3 cards (path + play count) appear in the info dialog next to the daily totals; full top-10 via a new `GET /topcards`. A card's counter is cleared when the card is deleted (no ghost stats). Stored in NVS (`rfidPlayCnt`) | [`stats-topcards`](../../releases/tag/stats-topcards) |
150151
| **OpenAPI/Swagger spec updated**: the bundled `REST_API.yaml` (served at `/swagger.html`) now documents all fork-added endpoints (`/sync`, `/syncstop`, `/playlist`, `/githubupdate`, `/version`, `/login`, `/logout`, `/security`, `/eqrule(s)`, `/activeequalizer`, `/bluetooth*`, `/sdclean`) and declares the security schemes (`X-API-Key` header / `apikey` query param, or the `ESPUINO_SESSION` login cookie) so the Swagger "Authorize" button works. The web-interface footer links to the Swagger UI (`/swagger.html`) | [`f8298d9`](../../commit/f8298d9) |
151152

152153
#### Feature highlights

‎html/locales/de.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@
788788
"lblPlayToday": "Heute gehört",
789789
"lblPlayYesterday": "Gestern gehört",
790790
"lblPlay7d": "Letzte 7 Tage",
791-
"lblPlay30d": "Letzte 30 Tage"
791+
"lblPlay30d": "Letzte 30 Tage",
792+
"lblTopCard": "Meistgespielt"
792793
}
793794
}

‎html/locales/en.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@
788788
"lblPlayToday": "Listened today",
789789
"lblPlayYesterday": "Listened yesterday",
790790
"lblPlay7d": "Last 7 days",
791-
"lblPlay30d": "Last 30 days"
791+
"lblPlay30d": "Last 30 days",
792+
"lblTopCard": "Most played"
792793
}
793794
}

‎html/locales/fr.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,7 @@
777777
"lblPlayToday": "Écouté aujourd'hui",
778778
"lblPlayYesterday": "Écouté hier",
779779
"lblPlay7d": "7 derniers jours",
780-
"lblPlay30d": "30 derniers jours"
780+
"lblPlay30d": "30 derniers jours",
781+
"lblTopCard": "Plus joué"
781782
}
782783
}

‎html/management.html‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6599,6 +6599,16 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
65996599
if (info.audio.playYesterday !== undefined) add('systeminfo.lblPlayYesterday', timestampToDuration(info.audio.playYesterday * 1000, true));
66006600
if (info.audio.play7d !== undefined) add('systeminfo.lblPlay7d', timestampToDuration(info.audio.play7d * 1000, true));
66016601
if (info.audio.play30d !== undefined) add('systeminfo.lblPlay30d', timestampToDuration(info.audio.play30d * 1000, true));
6602+
// most-played cards (top 3), label -> "name (N×)"
6603+
try {
6604+
const top = await (await fetch("http://" + host + "/topcards")).json();
6605+
if (Array.isArray(top)) {
6606+
top.slice(0, 3).forEach(function (c) {
6607+
const name = (c.fileOrUrl && c.fileOrUrl !== "0") ? c.fileOrUrl : c.id;
6608+
add('systeminfo.lblTopCard', name + ' (' + c.count + '×)');
6609+
});
6610+
}
6611+
} catch (e) { /* ignore */ }
66026612
if (info.sdcard && info.sdcard.size) add('systeminfo.lblSd', (info.sdcard.free / 1024).toFixed(1) + ' / ' + (info.sdcard.size / 1024).toFixed(1) + ' GB');
66036613
if (info.battery) {
66046614
add('systeminfo.lblVoltage', info.battery.currVoltage.toFixed(2) + ' V');

‎src/Playstats.cpp‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,31 @@ static uint32_t gLastDay = 0; // local day number of the most recent tracked day
2121
static bool gDirty = false;
2222
static uint32_t gLastSaveMs = 0;
2323

24+
// Per-card play counters live in their own NVS namespace, keyed by the 12-digit tag id.
25+
static Preferences gPrefsCardCnt;
26+
static bool gCardCntReady = false;
27+
static void Playstats_CardCntInit(void) {
28+
if (!gCardCntReady) {
29+
gPrefsCardCnt.begin("rfidPlayCnt");
30+
gCardCntReady = true;
31+
}
32+
}
33+
void Playstats_NoteCardPlay(const char *tagId) {
34+
if (!tagId || !tagId[0]) {
35+
return;
36+
}
37+
Playstats_CardCntInit();
38+
gPrefsCardCnt.putULong(tagId, gPrefsCardCnt.getULong(tagId, 0) + 1);
39+
}
40+
uint32_t Playstats_GetCardPlays(const char *tagId) {
41+
Playstats_CardCntInit();
42+
return gPrefsCardCnt.getULong(tagId, 0);
43+
}
44+
void Playstats_ClearCardPlays(const char *tagId) {
45+
Playstats_CardCntInit();
46+
gPrefsCardCnt.remove(tagId);
47+
}
48+
2449
// Days since 1970-01-01 for a (proleptic Gregorian) calendar date (Howard Hinnant's algorithm).
2550
// Avoids needing tm_gmtoff (not available in this newlib build): we feed it the local Y/M/D.
2651
static long Playstats_DaysFromCivil(int y, unsigned m, unsigned d) {

‎src/Playstats.h‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ uint32_t Playstats_GetToday(void); // seconds listened today (local calendar day
1515
uint32_t Playstats_GetYesterday(void); // seconds listened yesterday
1616
uint32_t Playstats_GetLastDays(uint16_t days); // sum over the last <days> calendar days (incl. today)
1717

18+
// Per-card play counter (most-played statistics). Incremented when a music card starts a playlist.
19+
void Playstats_NoteCardPlay(const char *tagId);
20+
uint32_t Playstats_GetCardPlays(const char *tagId);
21+
void Playstats_ClearCardPlays(const char *tagId); // drop a card's play counter (e.g. when deleted)
22+
1823
// Raw ring-buffer access for backup/restore (JSON is built/parsed by the caller).
1924
uint16_t Playstats_GetRingSize(void); // number of day-slots (365)
2025
uint32_t Playstats_GetRingLastDay(void); // local day number of the most recent tracked day

‎src/RfidCommon.cpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "Log.h"
88
#include "MemX.h"
99
#include "Mqtt.h"
10+
#include "Playstats.h"
1011
#include "Queues.h"
1112
#include "Rfid.h"
1213
#include "RfidConfig.h"
@@ -91,6 +92,7 @@ void Rfid_PreferenceLookupHandler(void) {
9192
}
9293
#endif
9394

95+
Playstats_NoteCardPlay(gCurrentRfidTagId); // count plays per music card (most-played stats)
9496
AudioPlayer_SetPlaylist(_file, _lastPlayPos, _playMode, _trackLastPlayed);
9597
}
9698
}

‎src/Web.cpp‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
#include <Update.h>
3939
#include <WiFi.h>
4040
#include <WiFiClientSecure.h>
41+
#include <algorithm>
4142
#include <atomic>
4243
#include <esp_random.h>
4344
#include <esp_task_wdt.h>
@@ -95,6 +96,7 @@ static void handleBluetoothResultsRequest(AsyncWebServerRequest *request);
9596
static void handleBluetoothConnectRequest(AsyncWebServerRequest *request, JsonVariant &json);
9697
static void handleWiFiScanRequest(AsyncWebServerRequest *request);
9798
static void handleGetRFIDRequest(AsyncWebServerRequest *request);
99+
bool DumpNvsToArrayCallback(const char *key, void *data); // defined later; used by the /topcards handler
98100
static void handlePostRFIDRequest(AsyncWebServerRequest *request, JsonVariant &json);
99101
static void handleCreatePlaylistRequest(AsyncWebServerRequest *request, JsonVariant &json);
100102
static void handleDeleteRFIDRequest(AsyncWebServerRequest *request);
@@ -1045,7 +1047,8 @@ void webserverStart(void) {
10451047
static uint32_t buf[365];
10461048
uint16_t c = 0;
10471049
if (o["days"].is<JsonArray>()) {
1048-
for (JsonVariant v : o["days"].as<JsonArray>()) {
1050+
JsonArray daysArr = o["days"].as<JsonArray>();
1051+
for (JsonVariant v : daysArr) {
10491052
if (c < 365) {
10501053
buf[c++] = v.as<uint32_t>();
10511054
}
@@ -1055,6 +1058,44 @@ void webserverStart(void) {
10551058
request->send(200, "text/plain; charset=utf-8", "ok");
10561059
}));
10571060

1061+
// Most-played RFID cards (top 10 by play count), with their assigned file/URL + play mode.
1062+
wServer.on("/topcards", HTTP_GET, [](AsyncWebServerRequest *request) {
1063+
std::vector<String> ids;
1064+
listNVSKeys("rfidPlayCnt", &ids, DumpNvsToArrayCallback);
1065+
std::vector<std::pair<uint32_t, String>> ranked;
1066+
for (const String &id : ids) {
1067+
ranked.push_back({Playstats_GetCardPlays(id.c_str()), id});
1068+
}
1069+
std::sort(ranked.begin(), ranked.end(), [](const std::pair<uint32_t, String> &a, const std::pair<uint32_t, String> &b) { return a.first > b.first; });
1070+
AsyncJsonResponse *response = new AsyncJsonResponse(true);
1071+
JsonArray arr = response->getRoot();
1072+
for (size_t i = 0; i < ranked.size() && i < 10; i++) {
1073+
JsonObject o = arr.add<JsonObject>();
1074+
o["id"] = ranked[i].second;
1075+
o["count"] = ranked[i].first;
1076+
// extract fileOrUrl + mode from the stored tag string "#fileOrUrl#pos#mode#track"
1077+
String s = gPrefsRfid.getString(ranked[i].second.c_str(), "");
1078+
if (s.length() > 0 && s != "-1") {
1079+
char buf[300];
1080+
strncpy(buf, s.c_str(), sizeof(buf) - 1);
1081+
buf[sizeof(buf) - 1] = '\0';
1082+
char *tok = strtok(buf, stringDelimiter);
1083+
uint8_t fi = 1;
1084+
while (tok != NULL) {
1085+
if (fi == 1) {
1086+
o["fileOrUrl"] = String(tok);
1087+
} else if (fi == 3) {
1088+
o["mode"] = (uint32_t) strtoul(tok, NULL, 10);
1089+
}
1090+
fi++;
1091+
tok = strtok(NULL, stringDelimiter);
1092+
}
1093+
}
1094+
}
1095+
response->setLength();
1096+
request->send(response);
1097+
});
1098+
10581099
// WiFi scan
10591100
wServer.on("/wifiscan", HTTP_GET, handleWiFiScanRequest);
10601101

@@ -3346,6 +3387,7 @@ static void handleDeleteRFIDRequest(AsyncWebServerRequest *request) {
33463387
}
33473388
if (gPrefsRfid.remove(tagId.c_str())) {
33483389
RfidSync_OnDelete(tagId.c_str()); // record tombstone + propagate the deletion to server/peers
3390+
Playstats_ClearCardPlays(tagId.c_str()); // drop the card's play counter too
33493391
Log_Printf(LOGLEVEL_INFO, "/rfid (DELETE): tag %s removed successfuly", tagId);
33503392
request->send(200, "text/plain; charset=utf-8", tagId + " removed successfuly");
33513393
} else {

0 commit comments

Comments
 (0)