DeadAir shortens episodes opened but not played (#173)

The copy maker skips an episode everyone who would hear the copy has
finished, and took finished to be the read mark. Opening an item sets it
as well, so 359: Potatoes From the Sky, opened and downloaded with
DeadAir on for its show, got no copy. Finished is now a place at 90% of
the length or past it, as Currently Listening has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 18:48:25 +00:00
parent 00bc039964
commit 845448a754
3 changed files with 17 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- DeadAir shortens an episode you have opened but not played yet, instead of passing it over as if you had finished it (#173).
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds. - Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.
- Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part. - Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part.
- Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why. - Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why.

View File

@@ -2330,10 +2330,15 @@ impl Db {
Ok(!self.rows(&sql, a.0).await?.is_empty()) Ok(!self.rows(&sql, a.0).await?.is_empty())
} }
/// Who has an item marked read, which playing it to the end or past 90% does. /// Who has heard an episode through: their place at 90% of its length or past it, where the
pub async fn readers(&self, feed_id: &str, guid: &str) -> Result<Vec<i64>> { /// page calls it played. Not the read mark, which opening an item sets too: going by it,
/// DeadAir passed over every episode opened before it was played (#173).
pub async fn finished(&self, feed_id: &str, guid: &str) -> Result<Vec<i64>> {
self.rows( self.rows(
"SELECT user_id FROM entry_state WHERE feed_id = $1 AND guid = $2 AND read", "SELECT s.user_id FROM entry_state s
JOIN entries e ON e.feed_id = s.feed_id AND e.guid = s.guid
WHERE s.feed_id = $1 AND s.guid = $2 AND coalesce(s.duration, e.duration, 0) > 0
AND s.position * 10 >= coalesce(s.duration, e.duration) * 9",
vec![feed_id.into(), guid.into()], vec![feed_id.into(), guid.into()],
) )
.await? .await?
@@ -2464,13 +2469,16 @@ mod tests {
assert!(!db.subscribed_any(&[u], "f").await.unwrap()); assert!(!db.subscribed_any(&[u], "f").await.unwrap());
db.subscribe(u, "f").await.unwrap(); db.subscribe(u, "f").await.unwrap();
assert!(db.subscribed_any(&[u], "f").await.unwrap()); assert!(db.subscribed_any(&[u], "f").await.unwrap());
assert!(db.readers("f", "g").await.unwrap().is_empty()); // Opened, which marks it read, and not played: not finished (#173).
db.set_entry_flag(u, "f", "g", EntryFlag::Read, true).await.unwrap(); db.set_entry_flag(u, "f", "g", EntryFlag::Read, true).await.unwrap();
assert_eq!(db.readers("f", "g").await.unwrap(), [u]); assert!(db.finished("f", "g").await.unwrap().is_empty());
assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(600)); assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(600));
db.set_position(u, "f", "g", 10, Some(590)).await.unwrap(); db.set_position(u, "f", "g", 10, Some(590)).await.unwrap();
assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(590), "what the player measured"); assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(590), "what the player measured");
assert!(db.finished("f", "g").await.unwrap().is_empty());
db.set_position(u, "f", "g", 540, Some(590)).await.unwrap();
assert_eq!(db.finished("f", "g").await.unwrap(), [u], "past 90%");
assert_eq!(db.deadair_total(u).await.unwrap(), 0); assert_eq!(db.deadair_total(u).await.unwrap(), 0);
db.count_deadair(u, "f", "g", 2000).await.unwrap(); db.count_deadair(u, "f", "g", 2000).await.unwrap();

View File

@@ -268,7 +268,7 @@ async fn have_ffmpeg() -> bool {
} }
/// Makes one copy someone wants, if there is one to make. A file everyone who would play the /// Makes one copy someone wants, if there is one to make. A file everyone who would play the
/// copy has already finished gets none: finished episodes are rarely played again. /// copy has already heard through gets none: finished episodes are rarely played again.
// shortcut: those stay on the list and are looked at again each pass, one query each; record // shortcut: those stay on the list and are looked at again each pass, one query each; record
// them as passed over if a large library makes the pass slow. // them as passed over if a large library makes the pass slow.
async fn next(ctx: &Ctx) -> Result<bool> { async fn next(ctx: &Ctx) -> Result<bool> {
@@ -283,8 +283,8 @@ async fn next(ctx: &Ctx) -> Result<bool> {
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? { if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
continue; continue;
} }
let readers = ctx.db.readers(&t.feed_id, &t.guid).await?; let finished = ctx.db.finished(&t.feed_id, &t.guid).await?;
if users.iter().all(|u| readers.contains(u)) { if users.iter().all(|u| finished.contains(u)) {
continue; continue;
} }
let src = crate::download::playable(Path::new(&t.path)); let src = crate::download::playable(Path::new(&t.path));