Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0443177471 | |||
| 0a83c716eb | |||
| b9e0d9f3cb |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
2
Cargo.lock
generated
@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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
|
||||
|
||||
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]
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user