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:
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,
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user