diff --git a/PROGRESS.md b/PROGRESS.md index 6e71ea6..e84a5db 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,20 @@ and until now nothing set them. --- +## 2026-09-11 — Retention caught up with per-user state + +Moving read and starred into `entry_state` left `reap_candidates` reading `entries.read` and +`entries.flagged`, which nothing writes any more: **starring stopped protecting a file**, and the +"delete the ones already read first" ordering was dead. Harmless while the quota and age limits are +0 -- the reaper deletes nothing at all then -- but it would have bitten the moment one was set. + +One file serves every subscriber, so both tests are now about all of them: **anyone** starring it +keeps it, and it only counts as read once **everyone** subscribed has read it. A file whose feed +nobody subscribes to has nobody left to keep it, so it sorts with the read ones. Tested with two +subscribers disagreeing. + +--- + ## 2026-09-11 — Steps B and C: what is yours, what is everyone's Read, starred and playback position moved out of `entries` into `entry_state (user_id, feed_id, diff --git a/src/db.rs b/src/db.rs index 45f9f73..e56d75f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -466,23 +466,31 @@ pub struct Candidate { } impl Db { - /// Files on disk, flagged ones excluded, read before unread and oldest first within - /// each group. + /// Files on disk that may be deleted to get back under quota: starred by nobody, + /// with the ones everybody has finished going first, oldest first within each group. /// - /// The Python intended `read = 1 AND flagged = 0` but never achieved it (a missing - /// plistlib import and an `EntreiesData` typo meant the filter always threw). Requiring - /// `read = 1` outright would be just as dead here, since nothing marks episodes read - /// until a UI exists -- so `flagged` is the keep-forever marker, and `read` only decides - /// what goes first. + /// One file serves every subscriber, so both tests are about all of them: **anyone** + /// starring it keeps it, and it only counts as read when **everyone** subscribed has + /// read it. A file whose feed nobody subscribes to has no one left to keep it, so it + /// sorts with the read ones. + /// + /// (The Python intended `read = 1 AND flagged = 0` but never achieved it -- a missing + /// plistlib import and an `EntreiesData` typo meant the filter always threw.) pub fn reap_candidates(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT e.id, e.url, e.path, e.bytes_done, - coalesce(e.downloaded_at, 0), coalesce(n.read, 0), coalesce(n.flagged, 0) + "SELECT e.id, e.url, e.path, e.bytes_done, coalesce(e.downloaded_at, 0), + CASE WHEN coalesce(readers.n, 0) >= coalesce(subs.n, 0) THEN 1 ELSE 0 END FROM enclosures e - LEFT JOIN entries n ON n.feed_id = e.feed_id AND n.guid = e.guid - WHERE e.path IS NOT NULL AND coalesce(n.flagged, 0) = 0 - ORDER BY coalesce(n.read, 0) DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC", + LEFT JOIN (SELECT feed_id, count(*) n FROM subscriptions GROUP BY feed_id) subs + ON subs.feed_id = e.feed_id + LEFT JOIN (SELECT feed_id, guid, count(*) n FROM entry_state + WHERE read = 1 GROUP BY feed_id, guid) readers + ON readers.feed_id = e.feed_id AND readers.guid = e.guid + WHERE e.path IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM entry_state s + WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged = 1) + ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC", )?; let rows = stmt .query_map([], |r| { diff --git a/src/retention.rs b/src/retention.rs index 1904201..de9b296 100644 --- a/src/retention.rs +++ b/src/retention.rs @@ -150,22 +150,39 @@ mod tests { } #[test] - fn query_never_offers_flagged_files_and_prefers_read_ones() { + fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() { + // One file serves both subscribers, so it takes both of them to release it. let db = Db::memory().unwrap(); db.exec_for_test( - "INSERT INTO entries (feed_id, guid, first_seen, read, flagged) VALUES - ('f', 'keep', 0, 1, 1), - ('f', 'unread', 0, 0, 0), - ('f', 'read', 0, 1, 0); + "INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0); + INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0); + INSERT INTO entries (feed_id, guid, first_seen) VALUES + ('f', 'keep', 0), + ('f', 'half', 0), + ('f', 'unread', 0), + ('f', 'read', 0); + -- Starred by one of the two, so it stays whatever the other thinks. + INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES + (1, 'f', 'keep', 1, 1), + (2, 'f', 'keep', 1, 0), + (1, 'f', 'half', 1, 0), + (1, 'f', 'read', 1, 0), + (2, 'f', 'read', 1, 0); INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES (1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10), - (2, 'f', 'unread', 'u2', '/tmp/unread', 10, 'done', 20), - (3, 'f', 'read', 'u3', '/tmp/read', 10, 'done', 30);", + (2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20), + (3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30), + (4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);", ) .unwrap(); let got: Vec = db.reap_candidates().unwrap().iter().map(|c| c.id).collect(); - assert_eq!(got, vec![3, 2], "flagged excluded; read goes before unread"); + assert_eq!( + got, + vec![4, 2, 3], + "starred by anyone is never offered; read by everyone goes first, and one \ + person still having it unread keeps it back with the unread ones" + ); } #[test]