Mark episodes read when finished, not when started

play() set read=1 the instant playback began. The default view is the
Unread tab, so pressing play removed the episode from the list being
looked at, which reads as the episode going missing. All four affected
rows had position=0: started, never listened to.

Read is now set on 'ended' or past 90% of the duration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-10 01:55:46 +00:00
parent 72280b9ccd
commit 44623098ae
2 changed files with 46 additions and 6 deletions

View File

@@ -56,6 +56,35 @@ and until now nothing set them.
--- ---
## 2026-09-10 — Fixed: pressing play made an episode vanish from the list
Reported as "where did Session Zero go, and why do parts share a number?".
Two separate things, one of them a real bug.
**The bug.** `play()` marked an episode read the moment playback started. The default view is the
Unread tab, so pressing play removed the episode from the list you were looking at — indistinguishable
from it going missing. Confirmed from the data: all four read entries had `position = 0`, meaning
playback never passed the 10-second save threshold. They had been started, never listened to.
Marking read now happens when an episode is actually consumed — on `ended`, or once playback passes
90% — never on play. Two earlier PROGRESS entries described mark-on-play as a deliberate feature;
it was a design error, and this supersedes them.
Also restored the four affected rows to unread, since their read flag was purely an artifact of the
bug (`UPDATE entries SET read=0 WHERE read=1 AND position=0` — no genuinely-played episode could
match, since playing one writes a position).
**Not a bug: repeated episode numbers.** Each story part ships as two items — the main episode and a
shorter `Junk in the Trunk` companion — with distinct GUIDs, durations and files, and the publisher
gives both the same `itunes:episode`. Zero duplicated titles across all 131 entries. `Session Zero`
additionally carries a season with no episode number and drops the "Part N" naming, so it does not
match the pattern its siblings follow.
Verified: the arc shows 11/11 items under the default Unread view again.
---
## 2026-09-10 — Titles: RSS `<title>` is the only source ## 2026-09-10 — Titles: RSS `<title>` is the only source
Confirmed against the live feed rather than assumed: 131 feed items, 131 stored, **0 mismatches** Confirmed against the live feed rather than assumed: 131 feed items, 131 stored, **0 mismatches**

View File

@@ -495,7 +495,20 @@ async function epAction(a,e,el){
/* ---------------- player ---------------- */ /* ---------------- player ---------------- */
const audio=$('#audio'); const audio=$('#audio');
const player={guid:null,feed:null,entry:null,saveAt:0}; const player={guid:null,feed:null,entry:null,saveAt:0,marked:false};
// Marked read when an episode has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the episode vanish from the default Unread list the instant
// you pressed play, which looks exactly like it went missing.
function markPlayed(){
if(!player.guid||player.marked) return;
player.marked=true;
const e=player.entry;
if(!e||e.read) return;
e.read=true;
api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read:true})}).then(()=>loadFeeds(true)).catch(()=>{});
}
function play(e){ function play(e){
const enc=e.enclosures.find(x=>x.path); const enc=e.enclosures.find(x=>x.path);
@@ -512,10 +525,7 @@ function play(e){
$('#pfeed').textContent=f?(f.title||f.id):''; $('#pfeed').textContent=f?(f.title||f.id):'';
$('#player').classList.add('on'); $('#player').classList.add('on');
mediaSession(e,f); mediaSession(e,f);
// Playing it is the clearest signal it has been listened to; retention deletes player.marked=false;
// read episodes before unread ones.
if(!e.read){ e.read=true; api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read:true})}).then(()=>loadFeeds(true)); }
} }
audio.play().catch(err=>toast('Playback failed: '+err.message,true)); audio.play().catch(err=>toast('Playback failed: '+err.message,true));
renderEntries(); renderEntries();
@@ -537,6 +547,7 @@ audio.addEventListener('timeupdate',()=>{
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000)); if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Persist roughly every 10s so a reload resumes where you were. // Persist roughly every 10s so a reload resumes where you were.
if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); } if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); }
if(d && audio.currentTime/d >= 0.9) markPlayed();
}); });
function savePos(){ function savePos(){
if(!player.guid) return; if(!player.guid) return;
@@ -547,7 +558,7 @@ function savePos(){
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime)})],{type:'application/json'})); new Blob([JSON.stringify({secs:Math.floor(audio.currentTime)})],{type:'application/json'}));
} }
audio.addEventListener('pause',savePos); audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();$('#pplay').textContent='▶'}); audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').textContent='▶'});
audio.addEventListener('play',()=>$('#pplay').textContent='❚❚'); audio.addEventListener('play',()=>$('#pplay').textContent='❚❚');
audio.addEventListener('pause',()=>$('#pplay').textContent='▶'); audio.addEventListener('pause',()=>$('#pplay').textContent='▶');
window.addEventListener('beforeunload',savePos); window.addEventListener('beforeunload',savePos);