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:
2026-09-15 18:39:40 +00:00
parent b9e0d9f3cb
commit 0a83c716eb
4 changed files with 48 additions and 28 deletions

View File

@@ -105,6 +105,8 @@ CREATE TABLE IF NOT EXISTS entry_state (
read INTEGER NOT NULL DEFAULT 0,
flagged 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)
);
@@ -173,6 +175,7 @@ fn migrate(conn: &Connection) -> Result<()> {
("users", "last_login", "INTEGER"),
("feeds", "error_since", "INTEGER"),
("feeds", "category", "TEXT"),
("entry_state", "duration", "INTEGER"),
];
let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from
@@ -704,8 +707,9 @@ pub enum Filter {
Flagged,
/// 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
/// read, so filtering on that hid nearly every episode anyone had started. An episode of
/// unknown length counts as unfinished until the player reports one (`set_position`).
/// read, so filtering on that hid nearly every episode anyone had started. The length is the
/// 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.
InProgress,
}
@@ -734,7 +738,8 @@ impl Filter {
}
Self::InProgress => {
"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();
let sql = format!(
"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)
FROM entries e
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.
/// `duration` is the length the player measured. It fills in an episode whose feed gives
/// none, and never replaces one the feed gave: without a length, Currently Listening cannot
/// tell a finished episode from a started one, and would keep it listed for good.
/// `duration` is the length their player measured, kept beside the position and preferred to
/// the feed's for time left and for when an episode counts as finished. A feed can be minutes
/// 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(
&self,
user_id: i64,
@@ -884,17 +892,12 @@ impl Db {
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position",
rusqlite::params![user_id, feed_id, guid, secs.max(0)],
"INSERT INTO entry_state (user_id, feed_id, guid, position, duration)
VALUES (?1, ?2, ?3, ?4, ?5)
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(())
}
@@ -1680,7 +1683,7 @@ mod tests {
// Starring and position are just as private.
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 page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
let (ray, sam) = (page(1), page(2));
@@ -1688,6 +1691,9 @@ mod tests {
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
assert!(ray_b.flagged && ray_b.position == 0);
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.
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
@@ -1874,11 +1880,17 @@ mod tests {
};
assert_eq!(listening(), ["h", "d"]);
// The player's measured length fills in the one d's feed left out, which settles it:
// 42 of 45 seconds is finished. It never replaces a feed's own length.
// The player's measured length is the one that counts, in place of a missing one or over
// 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", "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]