Skip to content

Commit a9f498f

Browse files
fhirschmannclaude
andcommitted
feat: harden audiobook resume (periodic checkpoint + restart-from-start)
Audiobook play-position was only persisted at track changes, on an RFID swap, or on a graceful shutdown. An ungraceful power loss (dead battery, hard power switch) mid-track therefore fell back to the start of the current track. Checkpoint the position to NVS every ~30 s while an audiobook is playing (AudioPlayer_Cyclic), gated by a new "savePosPeriodic" setting (default on, exposed as the "Checkpoint audiobook position periodically" option). The write reuses the existing NvsRfidWriteWrapper path and is throttled, so NVS wear stays negligible. Add a way to restart a finished/abandoned book from the beginning: the learned-cards list shows each audiobook tag's saved resume point (track + pos) and a reset button that calls the new POST /rfidresetpos?id=<tag>, which zeroes the saved position/track while preserving the folder and play mode. i18n de/en/fr, Swagger updated. Verified on device: savePosPeriodic defaults to true; /rfidresetpos returns 200 (folder + mode preserved, position zeroed), 409 for a modification card, 400 without an id and 404 for an unknown tag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent addff5c commit a9f498f

9 files changed

Lines changed: 158 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
101101

102102
| Change | Commit |
103103
| --- | --- |
104+
| **Audiobook resume hardening**: while an audio book plays, the play-position is now checkpointed to flash every ~30 s (new **"Checkpoint audiobook position periodically"** option, on by default), so an *ungraceful* power-off (dead battery, hard power switch) only loses the last few seconds instead of falling back to the start of the track — the existing save points (track change / RFID swap / graceful shutdown) didn't cover a sudden loss of power mid-track. The learned-cards list shows each audiobook tag's saved resume point and adds a **restart-from-beginning** button (new `POST /rfidresetpos`) to clear it. i18n de/en/fr | [`PENDING4`](../../commit/PENDING4) |
104105
| **EQ-rule store overflow guard**: per-path EQ rules are serialized into a single NVS string capped at ~4 KB; adding too many used to silently fail. The device now rejects an over-full save with `HTTP 413` (and reports a failed NVS write as `500`), and the Files tab surfaces a clear *"EQ rule storage is full"* toast instead of losing the rule without feedback | [`PENDING5`](../../commit/PENDING5) |
105106
| **HTTP-sync robustness**: the file-sync manifest is now streamed straight from the network into the JSON parser (instead of buffering the whole payload in a `String` first), roughly halving peak RAM during the parse on large manifests; the cross-core sync progress message is guarded by a spinlock so the web UI can never read a half-written line | [`PENDING3`](../../commit/PENDING3) |
106107
| PWA support: web app manifest + app icon, "add to home screen" with proper icon and name | [`b4287b9`](../../commit/b4287b9) |

‎REST-API.yaml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,30 @@ paths:
439439
responses:
440440
"200":
441441
description: Successful RFID-tag assignment deletion.
442+
/rfidresetpos:
443+
post:
444+
tags: [Fork]
445+
summary: Reset an audiobook tag's saved play-position.
446+
description: >
447+
Resets the saved play-position and last-played track of a tag back to the start
448+
(preserving the folder/file and play mode), so a finished or abandoned audiobook
449+
can be restarted from the beginning without re-assigning the card.
450+
parameters:
451+
- in: query
452+
name: id
453+
required: true
454+
schema:
455+
type: string
456+
description: RFID tag id.
457+
responses:
458+
"200":
459+
description: Position reset.
460+
"400":
461+
description: Missing id.
462+
"404":
463+
description: Unknown tag.
464+
"409":
465+
description: Tag has no play-position (NO_PLAYLIST or a modification card).
442466
/rfid/ids-only:
443467
get:
444468
summary: Get an array of RFID tag ID names.

‎html/locales/de.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@
292292
"title": "Datei, Verzeichnis oder URL (^ und # als Zeichen nicht erlaubt)",
293293
"placeholder": "z.B. /mp3/Hoerspiele/Yakari/Yakari_und_seine_Freunde.mp3"
294294
},
295+
"resume": {
296+
"info": "Fortsetzen bei Titel {{track}} ({{pos}} s)",
297+
"reset": "Von vorne beginnen"
298+
},
295299
"playmode": {
296300
"title": "Abspielmodus",
297301
"placeholder": "Modus auswählen",
@@ -608,6 +612,8 @@
608612
"savePlayPosShutdownExp": "Beim Ausschalten der Box wird für Hörbücher die Abspielposition gespeichert. Mehr Infos <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>hier</a> unter <code>SAVE_PLAYPOS_BEFORE_SHUTDOWN</code>.",
609613
"savePlayPosRfidChange": "Abspielposition bei RFID-Tag Wechsel merken",
610614
"savePlayPosRfidChangeExp": "Beim Wechsel zu einem neuen RFID-Tag wird für Hörbücher die Abspielposition gespeichert. Betrifft keine Modifikations-Tags. Mehr Infos <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>hier</a> unter <code>SAVE_PLAYPOS_WHEN_RFID_CHANGE</code>.",
615+
"savePlayPosPeriodic": "Hörbuch-Position regelmäßig sichern",
616+
"savePlayPosPeriodicExp": "Während ein Hörbuch läuft, wird die Position alle ~30 s im Flash gesichert. So gehen bei einem unerwarteten Stromausfall (leerer Akku, harter Aus-Schalter) nur die letzten Sekunden verloren statt bis zum Anfang des Tracks zurückzuspringen. Fork-Funktion.",
611617
"pauseIfRfidRemoved": "Pause wenn RFID-Tag entfernt",
612618
"pauseIfRfidRemovedExp": "Pausieren wenn RFID-Tag entfernt wird. Mehr Infos <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>hier</a> unter <code>PAUSE_WHEN_RFID_REMOVED</code>.",
613619
"dontAcceptRfidTwice": "Denselben RFID-Tag nicht erneut akzeptieren",

‎html/locales/en.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@
292292
"title": "File, directory or URL (^ and # aren't allowed as chars)",
293293
"placeholder": "f.e. /mp3/Audiobook/Yakari/Yakari_and_Friends.mp3"
294294
},
295+
"resume": {
296+
"info": "Resume at track {{track}} ({{pos}} s)",
297+
"reset": "Restart from the beginning"
298+
},
295299
"playmode": {
296300
"title": "Playmode",
297301
"placeholder": "Select mode",
@@ -608,6 +612,8 @@
608612
"savePlayPosShutdownExp": "When the box is switched off, the playback position for audio books is saved. More information <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>here</a> under <code>SAVE_PLAYPOS_BEFORE_SHUTDOWN</code>.",
609613
"savePlayPosRfidChange": "Remember playback position at RFID tag change",
610614
"savePlayPosRfidChangeExp": "When switching to a new RFID tag, the playback position is saved for audio books. Does not affect modification tags. More information <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>here</a> under <code>SAVE_PLAYPOS_WHEN_RFID_CHANGE</code>.",
615+
"savePlayPosPeriodic": "Checkpoint audiobook position periodically",
616+
"savePlayPosPeriodicExp": "While an audio book plays, the position is saved to flash every ~30 s. This way an unexpected power loss (dead battery, hard power-off) only loses the last few seconds instead of falling back to the start of the track. Fork feature.",
611617
"pauseIfRfidRemoved": "Pause when RFID tag is removed",
612618
"pauseIfRfidRemovedExp": "Playback is paused when the RFID tag is removed. More information <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>here</a> under <code>PAUSE_IF_RFID_REMOVED</code>.",
613619
"dontAcceptRfidTwice": "Don't accept the same RFID twice",

‎html/locales/fr.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,10 @@
285285
"title": "Fichier, répertoire ou URL (^ et # ne sont pas autorisés comme caractères)",
286286
"placeholder": "par exemple /mp3/Audiobook/Yakari/Yakari_and_Friends.mp3"
287287
},
288+
"resume": {
289+
"info": "Reprendre à la piste {{track}} ({{pos}} s)",
290+
"reset": "Recommencer depuis le début"
291+
},
288292
"playmode": {
289293
"title": "Mode de lecture",
290294
"placeholder": "Sélectionnez le mode",
@@ -597,6 +601,8 @@
597601
"savePlayPosShutdownExp": "Lorsque la boîte est éteinte, la position de lecture est enregistrée pour les livres audio. Plus d'informations <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>ici</a> sous <code>SAVE_PLAYPOS_BEFORE_SHUTDOWN</code>.",
598602
"savePlayPosRfidChange": "Mémoriser la position de lecture au changement de tag RFID",
599603
"savePlayPosRfidChangeExp": "Lors du passage à une nouvelle balise RFID, la position de lecture est enregistrée pour les livres audio. Plus d'informations <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>ici</a> sous <code>SAVE_PLAYPOS_WHEN_RFID_CHANGE</code>.",
604+
"savePlayPosPeriodic": "Sauvegarder régulièrement la position du livre audio",
605+
"savePlayPosPeriodicExp": "Pendant la lecture d'un livre audio, la position est enregistrée dans la mémoire flash toutes les ~30 s. Ainsi, une coupure de courant inattendue (batterie vide, arrêt brutal) ne perd que les dernières secondes au lieu de revenir au début de la piste. Fonction du fork.",
600606
"pauseIfRfidRemoved": "Mettre en pause si la balise RFID est retirée",
601607
"pauseIfRfidRemovedExp": "Mettre en pause la lecture si la balise RFID est retirée. Plus d'informations <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>ici</a> sous <code>PAUSE_IF_RFID_REMOVED</code>.",
602608
"dontAcceptRfidTwice": "Ne pas accepter la balise RFID deux fois",

‎html/management.html‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2245,6 +2245,15 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
22452245
data-i18n="[data-bs-content]general.options.savePlayPosRfidChangeExp"
22462246
tabindex="0"><i class="fas fa-circle-question"></i></a>
22472247
</div>
2248+
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
2249+
<input type="checkbox" id="savePlayPosPeriodic" name="savePlayPosPeriodic"
2250+
value="false">
2251+
<label for="savePlayPosPeriodic"
2252+
data-i18n="general.options.savePlayPosPeriodic"></label>
2253+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2254+
data-i18n="[data-bs-content]general.options.savePlayPosPeriodicExp"
2255+
tabindex="0"><i class="fas fa-circle-question"></i></a>
2256+
</div>
22482257
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
22492258
<input type="checkbox" id="playLastRfidOnReboot" name="playLastRfidOnReboot"
22502259
value="false">
@@ -4757,6 +4766,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
47574766
$('#playMono').prop('checked', genSettings.playMono);
47584767
$('#savePlayPosShutdown').prop('checked', genSettings.savePosShutdown);
47594768
$('#savePlayPosRfidChange').prop('checked', genSettings.savePosRfidChge);
4769+
$('#savePlayPosPeriodic').prop('checked', genSettings.savePosPeriodic);
47604770
$('#playLastRfidOnReboot').prop('checked', genSettings.playLastRfidOnReboot);
47614771
$('#pauseIfRfidRemoved').prop('checked', genSettings.pauseIfRfidRemoved);
47624772
$('#dontAcceptRfidTwice').prop('checked', genSettings.dontAcceptRfidTwice);
@@ -5110,9 +5120,27 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
51105120
rfidElem.appendChild(document.createElement("br"));
51115121
rfidElem.appendChild(document.createTextNode(rfid.fileOrUrl));
51125122
}
5123+
// audiobook modes (AUDIOBOOK / _LOOP / _RECURSIVE) can carry a saved play-position
5124+
var isAudiobook = (rfid.playMode === 3 || rfid.playMode === 4 || rfid.playMode === 16);
5125+
var hasProgress = isAudiobook && (((rfid.lastPlayPos || 0) > 0) || ((rfid.trackLastPlayed || 0) > 0));
5126+
if (hasProgress) {
5127+
rfidElem.appendChild(document.createElement("br"));
5128+
let resumeInfo = document.createElement("small");
5129+
resumeInfo.classList.add("text-info");
5130+
resumeInfo.textContent = i18next.t("files.rfid.resume.info", { track: (rfid.trackLastPlayed || 0) + 1, pos: rfid.lastPlayPos || 0 });
5131+
rfidElem.appendChild(resumeInfo);
5132+
}
51135133
let deleteSpan = document.createElement("span");
51145134
deleteSpan.classList.add("float-end");
51155135
deleteSpan.classList.add("button-group");
5136+
if (hasProgress) {
5137+
// reset the saved play-position so the book restarts from the beginning
5138+
let resetButton = document.createElement("button");
5139+
resetButton.classList.add("btn", "btn-secondary", "fa", "fa-backward-step", "me-1");
5140+
resetButton.setAttribute("title", i18next.t("files.rfid.resume.reset"));
5141+
resetButton.addEventListener("click", function () { resetRfidPos(rfid.id); });
5142+
deleteSpan.appendChild(resetButton);
5143+
}
51165144
let deleteButton = document.createElement("button");
51175145
deleteButton.classList.add("btn");
51185146
deleteButton.classList.add("btn-danger");
@@ -5129,6 +5157,16 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
51295157
document.getElementById('eraseNvs').setAttribute('data-visible', (rfidList.length > 0));
51305158
document.getElementById('exportNvs').setAttribute('data-visible', (rfidList.length > 0));
51315159
}
5160+
// reset an audiobook tag's saved play-position back to the start (POST /rfidresetpos)
5161+
async function resetRfidPos(tagId) {
5162+
var response = await fetch("http://" + host + "/rfidresetpos?id=" + encodeURIComponent(tagId), { method: "POST" });
5163+
if (response.ok) {
5164+
flashActionOk();
5165+
rebuildRFIDList();
5166+
} else {
5167+
toaster.error(response.statusText);
5168+
}
5169+
}
51325170
async function deleteRFID(tagId) {
51335171
console.log("deleting", tagId);
51345172
$('#deleteRFIDModal').modal('hide');
@@ -5299,6 +5337,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
52995337
playMono: $('#playMono').prop('checked'),
53005338
savePosShutdown: $('#savePlayPosShutdown').prop('checked'),
53015339
savePosRfidChge: $('#savePlayPosRfidChange').prop('checked'),
5340+
savePosPeriodic: $('#savePlayPosPeriodic').prop('checked'),
53025341
playLastRfidOnReboot: $('#playLastRfidOnReboot').prop('checked'),
53035342
pauseIfRfidRemoved: $('#pauseIfRfidRemoved').prop('checked'),
53045343
dontAcceptRfidTwice: $('#dontAcceptRfidTwice').prop('checked'),

‎src/AudioPlayer.cpp‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,14 @@ float Audio_GetVolume(float t) {
298298
return val1 + (val2 - val1) * fraction;
299299
}
300300

301+
// When true, the audiobook play-position is checkpointed to NVS periodically while playing
302+
// (see AudioPlayer_Cyclic). Mirrors the "savePosPeriodic" web setting; default on.
303+
static bool gSavePosPeriodic = true;
304+
305+
void AudioPlayer_SetSavePosPeriodic(bool enabled) {
306+
gSavePosPeriodic = enabled;
307+
}
308+
301309
void AudioPlayer_Init(void) {
302310
// create audio object
303311
audio = new AudioCustom();
@@ -378,6 +386,7 @@ void AudioPlayer_Init(void) {
378386
AudioPlayer_StationLogoUrl = "";
379387
gPlayProperties.playlist = allocatePlaylist();
380388
gPlayProperties.SavePlayPosRfidChange = gPrefsSettings.getBool("savePosRfidChge", false); // SAVE_PLAYPOS_WHEN_RFID_CHANGE
389+
gSavePosPeriodic = gPrefsSettings.getBool("savePosPeriodic", true); // periodic audiobook play-position checkpoint
381390
gPlayProperties.pauseOnMinVolume = gPrefsSettings.getBool("pauseOnMinVol", false); // PAUSE_ON_MIN_VOLUME
382391
#ifdef PAUSE_WHEN_RFID_REMOVED
383392
gPlayProperties.pauseIfRfidRemoved = gPrefsSettings.getBool("pauseRfidRem", true);
@@ -440,6 +449,13 @@ void AudioPlayer_Exit(void) {
440449

441450
static uint32_t lastPlayingTimestamp = 0;
442451

452+
// Periodically persist the audiobook play-position to NVS while playing, so an *ungraceful*
453+
// power-off (dead battery, hard power switch) doesn't lose the whole book back to the last
454+
// track boundary. The existing save points (track change / RFID swap / graceful shutdown)
455+
// don't cover a sudden loss of power mid-track. Throttled so NVS wear stays negligible.
456+
static constexpr uint32_t SAVE_POS_CHECKPOINT_INTERVAL_MS = 30000;
457+
static uint32_t lastPosCheckpointMs = 0;
458+
443459
void AudioPlayer_Cyclic(void) {
444460
if (AudioPlayer_UploadActive) {
445461
return;
@@ -451,6 +467,15 @@ void AudioPlayer_Cyclic(void) {
451467
lastPlayingTimestamp = millis();
452468
playTimeSecSinceStart += 1;
453469
Playstats_AddSecond(); // accumulate per-day listening-time statistics
470+
471+
// periodic audiobook play-position checkpoint (see note above). saveLastPlayPosition
472+
// is only set for the position-saving audiobook modes, so this is a no-op otherwise.
473+
if (gSavePosPeriodic && gPlayProperties.saveLastPlayPosition && gPlayProperties.playlist != nullptr && audio && audio->isRunning()) {
474+
if (millis() - lastPosCheckpointMs >= SAVE_POS_CHECKPOINT_INTERVAL_MS) {
475+
lastPosCheckpointMs = millis();
476+
AudioPlayer_NvsRfidWriteWrapper(gPlayProperties.playRfidTag, audio->getAudioCurrentTime(), gPlayProperties.playMode, gPlayProperties.currentTrackNumber);
477+
}
478+
}
454479
}
455480

456481
// Actual loop stuff
@@ -1678,6 +1703,12 @@ size_t AudioPlayer_NvsRfidWriteWrapper(const char *_rfidCardId, const uint32_t _
16781703
gPrefsRfid.putString("212130160042", "#/mp3/Hoerspiele/Yakari/Sammlung2#0#3#0");*/
16791704
}
16801705

1706+
// Resets a tag's saved play-position and last-played track to the start, keeping the
1707+
// folder/file and play mode intact (so an audiobook can be restarted from the beginning).
1708+
void AudioPlayer_ResetRfidPos(const char *_rfidCardId, const uint8_t _playMode) {
1709+
AudioPlayer_NvsRfidWriteWrapper(_rfidCardId, 0, _playMode, 0);
1710+
}
1711+
16811712
// Adds webstream to playlist; same like SdCard_ReturnPlaylist() but always only one entry
16821713
std::optional<Playlist *> AudioPlayer_ReturnPlaylistFromWebstream(const char *_webUrl) {
16831714
Playlist *playlist = allocatePlaylist();

‎src/AudioPlayer.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ void AudioPlayer_NotifyUploadEnd(void);
6565

6666
void AudioPlayer_Init(void);
6767
void AudioPlayer_Exit(void);
68+
void AudioPlayer_SetSavePosPeriodic(bool enabled);
6869
void AudioPlayer_Cyclic(void);
6970
void AudioPlayer_Loop(void);
7071
uint8_t AudioPlayer_GetRepeatMode(void);
@@ -76,6 +77,8 @@ String AudioPlayer_GetEqualizerProfile(void);
7677
void AudioPlayer_ReloadEqRules(void);
7778
void AudioPlayer_ApplyEqualizerForPath(const char *trackPath);
7879
void AudioPlayer_SetPlaylist(const char *_itemToPlay, const uint32_t _lastPlayPos, const uint32_t _playMode, const uint16_t _trackLastPlayed);
80+
// Resets a tag's saved play-position + last-played track to the start (preserving folder/mode).
81+
void AudioPlayer_ResetRfidPos(const char *_rfidCardId, const uint8_t _playMode);
7982
void AudioPlayer_SetTrackControl(const uint8_t trackCommand);
8083
void AudioPlayer_PauseOnMinVolume(const uint8_t oldVolume, const uint8_t newVolume);
8184
void AudioPlayer_PlayReadyMsg(void);

0 commit comments

Comments
 (0)