3 Commits

Author SHA1 Message Date
0443177471 Release 0.6.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:19:44 +00:00
0a83c716eb 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>
2026-09-15 18:39:40 +00:00
b9e0d9f3cb Save a position only from a player that has played since its last save
A tab left paused further into an episode saved its older place as it
reloaded, over where the listener had got to since, and the episode
dropped out of Currently Listening. A jump back is now saved at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:29:10 +00:00
7 changed files with 106 additions and 29 deletions

View File

@@ -10,6 +10,15 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased]
## [0.6.1] - 2026-09-15
### 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
where you have got to since, which could drop an episode out of Currently Listening.
## [0.6.0] - 2026-09-15
### Added
@@ -421,7 +430,8 @@ The long form, with what was wrong before and how it was found, is in
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...main
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4

2
Cargo.lock generated
View File

@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"ammonia",
"anyhow",

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx"
version = "0.6.0"
version = "0.6.1"
edition = "2024"
[dependencies]

View File

@@ -6,6 +6,33 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is.
## 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
minutes left in the player. Its saved position was 41:15 of a file 43:48 long, 94% and finished
by any measure, while the listener was at about 32:48. The first guess was a wrong length from
the feed, and it was wrong: the feed does say 41:23, but no length makes 41:15 unfinished.
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
millisecond as a page load. The likeliest reading was a player left paused further on, in
another tab or on another device, saving its place as its page reloaded, since every save goes
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
writes, and only playing counts: the seek to where you left off happens paused, and counting it
would save back whatever the list said, however old. A jump back is also saved at once, where
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.
What was certainly wrong was the length. At 41:15 the item list said 0:08 left and the player
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
Issue #14: an episode 32 minutes into 41 was missing from Currently Listening, which said nothing

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]

View File

@@ -200,6 +200,26 @@ test('Currently Listening, its own place below Popular, resumes an episode you s
await expect(row.locator('.eq')).toBeHidden();
});
test('a player nobody has played since it last saved does not save again', async ({ page }) => {
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
const sent = await page.evaluate(() => {
Object.defineProperty(audio, 'readyState', { get: () => 4 });
Object.defineProperty(audio, 'currentTime', { get: () => 2, set() {} });
let n = 0;
navigator.sendBeacon = () => (n++, true);
player.guid = 'ui-2'; player.feed = 'test-show'; player.entry = null; player.moved = false;
savePos(); // what a reload, a pause or the close button calls
const idle = n;
player.moved = true; // what playing sets
savePos();
savePos(); // and once saved, it is idle again
return [idle, n];
});
expect(sent).toEqual([0, 1]);
});
test('the filter tabs change what is listed', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });

View File

@@ -1507,7 +1507,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
// The same file carries on where it was; another of the item's files starts from its top.
const resuming = player.guid===e.guid && player.enc===enc.id;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
@@ -1539,14 +1539,21 @@ audio.addEventListener('timeupdate',()=>{
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Persist roughly every 10s so a reload resumes where you were.
if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); }
// Only playing counts as moving: the seek to where you left off happens paused, and saving
// that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true;
// Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used
// to wait for the next pause to be saved.
if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); }
if(d && audio.currentTime/d >= 0.9) markPlayed();
});
function savePos(){
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
// a failed load, or a pause before the seek to where you left off -- wiped the position.
if(!player.guid||!audio.readyState) return;
// Nor from a player nobody has played since it last saved: one left paused in another tab
// saved its older place as that tab reloaded, over where you had got to since.
if(!player.guid||!audio.readyState||!player.moved) return;
player.moved=false;
player.saveAt=audio.currentTime;
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
// The measured length stands in for one the feed left out: without it Currently Listening
@@ -1909,7 +1916,8 @@ function paintListenRow(el){
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.
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);
$('.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.