Time left and finished go by the length the player measured
A feed can be minutes out: ReThinking's gave 41:23 for a 43:48 file, which read 0:08 left with 2:33 to play. The player's length is kept in entry_state beside the position, per listener, where no scan can put the feed's figure back, and preferred to the feed's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Time left, and when an episode counts as finished, go by the length your player measured
|
||||||
|
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
|
||||||
- A player left open in another tab or on another device no longer saves its older place over
|
- A player left open in another tab or on another device no longer saves its older place over
|
||||||
where you have got to since, which could drop an episode out of Currently Listening.
|
where you have got to since, which could drop an episode out of Currently Listening.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ reasoning lives. New write-ups go at the top.
|
|||||||
|
|
||||||
See [README.md](../README.md) for what the thing is.
|
See [README.md](../README.md) for what the thing is.
|
||||||
|
|
||||||
## 2026-09-15 — A player nobody was listening to, saving over one that was
|
## 2026-09-15 — Kristen Bell again: a length minutes out, and an idle player
|
||||||
|
|
||||||
After 0.6.0 the Kristen Bell episode dropped out of Currently Listening again, with eleven
|
After 0.6.0 the Kristen Bell episode dropped out of Currently Listening again, with eleven
|
||||||
minutes left in the player. Its saved position was 41:15 of a file 43:48 long, 94% and finished
|
minutes left in the player. Its saved position was 41:15 of a file 43:48 long, 94% and finished
|
||||||
@@ -15,9 +15,11 @@ the feed, and it was wrong: the feed does say 41:23, but no length makes 41:15 u
|
|||||||
|
|
||||||
The log had the answer. Saves every ten seconds while it played, then two that were not
|
The log had the answer. Saves every ten seconds while it played, then two that were not
|
||||||
playback: one beside the only request for the file since the restart, and the last in the same
|
playback: one beside the only request for the file since the restart, and the last in the same
|
||||||
millisecond as a page load. A player left paused further on, in another tab or on another
|
millisecond as a page load. The likeliest reading was a player left paused further on, in
|
||||||
device, saved its own place as its page reloaded. Every save goes through `savePos`, and it
|
another tab or on another device, saving its place as its page reloaded, since every save goes
|
||||||
saved whatever the player held whether or not anyone had played it since.
|
through `savePos` and it saved whatever the player held whether or not anyone had played it
|
||||||
|
since. That was never confirmed. Fifteen minutes later the listener was playing on from 41:15
|
||||||
|
and heard 2:33 left, which fits that position having been theirs all along.
|
||||||
|
|
||||||
`savePos` now saves only once the player has played since its last save, so an idle one never
|
`savePos` now saves only once the player has played since its last save, so an idle one never
|
||||||
writes, and only playing counts: the seek to where you left off happens paused, and counting it
|
writes, and only playing counts: the seek to where you left off happens paused, and counting it
|
||||||
@@ -25,8 +27,11 @@ would save back whatever the list said, however old. A jump back is also saved a
|
|||||||
the ten-second check only ever looked forward. The fixture audio does not decode, so the browser
|
the ten-second check only ever looked forward. The fixture audio does not decode, so the browser
|
||||||
test stands in for a loaded player and counts what `savePos` sends.
|
test stands in for a loaded player and counts what `savePos` sends.
|
||||||
|
|
||||||
The feed's length, 2 minutes 25 seconds short of the file, still moves this episode's 90% line
|
What was certainly wrong was the length. At 41:15 the item list said 0:08 left and the player
|
||||||
earlier than the player's. Not fixed here; the measured length only fills in a missing one.
|
2:33: the feed gives 41:23 for a file that decodes to 43:48. The length the player measures now
|
||||||
|
goes into `entry_state` beside the position, and wins over the feed's for the 90% line and every
|
||||||
|
time left. Not into `entries`, where each scan writes the feed's figure back, and per listener,
|
||||||
|
so one person's player never changes what another sees.
|
||||||
|
|
||||||
## 2026-09-15 — Currently Listening, empty for anyone who opens what they play
|
## 2026-09-15 — Currently Listening, empty for anyone who opens what they play
|
||||||
|
|
||||||
|
|||||||
54
src/db.rs
54
src/db.rs
@@ -105,6 +105,8 @@ CREATE TABLE IF NOT EXISTS entry_state (
|
|||||||
read INTEGER NOT NULL DEFAULT 0,
|
read INTEGER NOT NULL DEFAULT 0,
|
||||||
flagged INTEGER NOT NULL DEFAULT 0,
|
flagged INTEGER NOT NULL DEFAULT 0,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- The length this person's player measured, beside the position it is measured against.
|
||||||
|
duration INTEGER,
|
||||||
PRIMARY KEY (user_id, feed_id, guid)
|
PRIMARY KEY (user_id, feed_id, guid)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -173,6 +175,7 @@ fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
("users", "last_login", "INTEGER"),
|
("users", "last_login", "INTEGER"),
|
||||||
("feeds", "error_since", "INTEGER"),
|
("feeds", "error_since", "INTEGER"),
|
||||||
("feeds", "category", "TEXT"),
|
("feeds", "category", "TEXT"),
|
||||||
|
("entry_state", "duration", "INTEGER"),
|
||||||
];
|
];
|
||||||
let retired: &[(&str, &str)] = &[
|
let retired: &[(&str, &str)] = &[
|
||||||
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
||||||
@@ -704,8 +707,9 @@ pub enum Filter {
|
|||||||
Flagged,
|
Flagged,
|
||||||
/// Started (a saved playback position past the first few seconds) and short of the 90%
|
/// Started (a saved playback position past the first few seconds) and short of the 90%
|
||||||
/// where `markPlayed` in the UI calls it finished. Not `read`: opening an item marks it
|
/// where `markPlayed` in the UI calls it finished. Not `read`: opening an item marks it
|
||||||
/// read, so filtering on that hid nearly every episode anyone had started. An episode of
|
/// read, so filtering on that hid nearly every episode anyone had started. The length is the
|
||||||
/// unknown length counts as unfinished until the player reports one (`set_position`).
|
/// one this person's player measured where there is one (`set_position`), else the feed's;
|
||||||
|
/// with neither, the episode counts as unfinished.
|
||||||
/// Currently Listening, below Popular, is this filter on every feed at once.
|
/// Currently Listening, below Popular, is this filter on every feed at once.
|
||||||
InProgress,
|
InProgress,
|
||||||
}
|
}
|
||||||
@@ -734,7 +738,8 @@ impl Filter {
|
|||||||
}
|
}
|
||||||
Self::InProgress => {
|
Self::InProgress => {
|
||||||
"coalesce(s.position, 0) > 5
|
"coalesce(s.position, 0) > 5
|
||||||
AND (coalesce(e.duration, 0) = 0 OR s.position * 10 < e.duration * 9)"
|
AND (coalesce(s.duration, e.duration, 0) = 0
|
||||||
|
OR s.position * 10 < coalesce(s.duration, e.duration) * 9)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -773,7 +778,8 @@ impl Db {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
|
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
|
||||||
coalesce(s.read, 0), coalesce(s.flagged, 0), e.image, e.duration,
|
coalesce(s.read, 0), coalesce(s.flagged, 0), e.image,
|
||||||
|
coalesce(s.duration, e.duration),
|
||||||
e.episode, e.season, coalesce(s.position, 0)
|
e.episode, e.season, coalesce(s.position, 0)
|
||||||
FROM entries e
|
FROM entries e
|
||||||
LEFT JOIN entry_state s
|
LEFT JOIN entry_state s
|
||||||
@@ -871,9 +877,11 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Where playback got to, so it resumes there next time -- for this listener only.
|
/// Where playback got to, so it resumes there next time -- for this listener only.
|
||||||
/// `duration` is the length the player measured. It fills in an episode whose feed gives
|
/// `duration` is the length their player measured, kept beside the position and preferred to
|
||||||
/// none, and never replaces one the feed gave: without a length, Currently Listening cannot
|
/// the feed's for time left and for when an episode counts as finished. A feed can be minutes
|
||||||
/// tell a finished episode from a started one, and would keep it listed for good.
|
/// out: ReThinking's gave 41:23 for a 43:48 file, which said 0:08 left with 2:33 to play.
|
||||||
|
/// Not written to `entries`, where every scan puts the feed's figure back, and kept per
|
||||||
|
/// listener so one person's player never changes what anyone else sees.
|
||||||
pub fn set_position(
|
pub fn set_position(
|
||||||
&self,
|
&self,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -884,17 +892,12 @@ impl Db {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (?1, ?2, ?3, ?4)
|
"INSERT INTO entry_state (user_id, feed_id, guid, position, duration)
|
||||||
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position",
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
rusqlite::params![user_id, feed_id, guid, secs.max(0)],
|
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position,
|
||||||
|
duration = coalesce(excluded.duration, duration)",
|
||||||
|
rusqlite::params![user_id, feed_id, guid, secs.max(0), duration.filter(|d| *d > 0)],
|
||||||
)?;
|
)?;
|
||||||
if let Some(d) = duration.filter(|d| *d > 0) {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE entries SET duration = ?3
|
|
||||||
WHERE feed_id = ?1 AND guid = ?2 AND coalesce(duration, 0) = 0",
|
|
||||||
rusqlite::params![feed_id, guid, d],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,7 +1683,7 @@ mod tests {
|
|||||||
|
|
||||||
// Starring and position are just as private.
|
// Starring and position are just as private.
|
||||||
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
|
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
|
||||||
db.set_position(2, "f", "b", 42, None).unwrap();
|
db.set_position(2, "f", "b", 42, Some(600)).unwrap();
|
||||||
let order = order_sql("published", "desc");
|
let order = order_sql("published", "desc");
|
||||||
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
||||||
let (ray, sam) = (page(1), page(2));
|
let (ray, sam) = (page(1), page(2));
|
||||||
@@ -1688,6 +1691,9 @@ mod tests {
|
|||||||
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
||||||
assert!(ray_b.flagged && ray_b.position == 0);
|
assert!(ray_b.flagged && ray_b.position == 0);
|
||||||
assert!(!sam_b.flagged && sam_b.position == 42);
|
assert!(!sam_b.flagged && sam_b.position == 42);
|
||||||
|
// So is the length sam's player measured.
|
||||||
|
assert_ne!(ray_b.duration, Some(600));
|
||||||
|
assert_eq!(sam_b.duration, Some(600));
|
||||||
|
|
||||||
// Marking a whole feed read is likewise one person's business.
|
// Marking a whole feed read is likewise one person's business.
|
||||||
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
|
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
|
||||||
@@ -1874,11 +1880,17 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(listening(), ["h", "d"]);
|
assert_eq!(listening(), ["h", "d"]);
|
||||||
|
|
||||||
// The player's measured length fills in the one d's feed left out, which settles it:
|
// The player's measured length is the one that counts, in place of a missing one or over
|
||||||
// 42 of 45 seconds is finished. It never replaces a feed's own length.
|
// the feed's: d, 42 of a measured 45 seconds, is finished; e, which its feed calls 45
|
||||||
|
// seconds long, is 42 into a 9000-second file and is not. Times left use it too.
|
||||||
db.set_position(7, "f", "d", 42, Some(45)).unwrap();
|
db.set_position(7, "f", "d", 42, Some(45)).unwrap();
|
||||||
db.set_position(7, "f", "e", 42, Some(9000)).unwrap();
|
db.set_position(7, "f", "e", 42, Some(9000)).unwrap();
|
||||||
assert_eq!(listening(), ["h"]);
|
assert_eq!(listening(), ["h", "e"]);
|
||||||
|
let rows = db.entries_in(7, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
||||||
|
assert_eq!(rows.iter().find(|r| r.guid == "e").unwrap().duration, Some(9000));
|
||||||
|
// A save without one (before the player knows) keeps the length already measured.
|
||||||
|
db.set_position(7, "f", "e", 43, None).unwrap();
|
||||||
|
assert_eq!(listening(), ["h", "e"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1916,7 +1916,8 @@ function paintListenRow(el){
|
|||||||
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
||||||
// Zero until the player has sought to where you left off; the saved position stands till then.
|
// Zero until the player has sought to where you left off; the saved position stands till then.
|
||||||
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
||||||
const d=e.duration||(now&&isFinite(audio.duration)?Math.floor(audio.duration):0);
|
// The player's own length first: a feed's can be minutes out.
|
||||||
|
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
|
||||||
el.classList.toggle('now',now);
|
el.classList.toggle('now',now);
|
||||||
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
||||||
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
||||||
|
|||||||
Reference in New Issue
Block a user