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]
|
## [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
|
## [0.6.0] - 2026-09-15
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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/`.
|
- `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.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.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
|
[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]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -6,6 +6,33 @@ reasoning lives. New write-ups go at the top.
|
|||||||
|
|
||||||
See [README.md](../README.md) for what the thing is.
|
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
|
## 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
|
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,
|
read INTEGER NOT NULL DEFAULT 0,
|
||||||
flagged INTEGER NOT NULL DEFAULT 0,
|
flagged INTEGER NOT NULL DEFAULT 0,
|
||||||
position 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)
|
PRIMARY KEY (user_id, feed_id, guid)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -173,6 +175,7 @@ fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
("users", "last_login", "INTEGER"),
|
("users", "last_login", "INTEGER"),
|
||||||
("feeds", "error_since", "INTEGER"),
|
("feeds", "error_since", "INTEGER"),
|
||||||
("feeds", "category", "TEXT"),
|
("feeds", "category", "TEXT"),
|
||||||
|
("entry_state", "duration", "INTEGER"),
|
||||||
];
|
];
|
||||||
let retired: &[(&str, &str)] = &[
|
let retired: &[(&str, &str)] = &[
|
||||||
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
||||||
@@ -704,8 +707,9 @@ pub enum Filter {
|
|||||||
Flagged,
|
Flagged,
|
||||||
/// Started (a saved playback position past the first few seconds) and short of the 90%
|
/// 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
|
/// 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
|
/// read, so filtering on that hid nearly every episode anyone had started. The length is the
|
||||||
/// unknown length counts as unfinished until the player reports one (`set_position`).
|
/// 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.
|
/// Currently Listening, below Popular, is this filter on every feed at once.
|
||||||
InProgress,
|
InProgress,
|
||||||
}
|
}
|
||||||
@@ -734,7 +738,8 @@ impl Filter {
|
|||||||
}
|
}
|
||||||
Self::InProgress => {
|
Self::InProgress => {
|
||||||
"coalesce(s.position, 0) > 5
|
"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();
|
.unwrap_or_default();
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
|
"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)
|
e.episode, e.season, coalesce(s.position, 0)
|
||||||
FROM entries e
|
FROM entries e
|
||||||
LEFT JOIN entry_state s
|
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.
|
/// 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
|
/// `duration` is the length their player measured, kept beside the position and preferred to
|
||||||
/// none, and never replaces one the feed gave: without a length, Currently Listening cannot
|
/// the feed's for time left and for when an episode counts as finished. A feed can be minutes
|
||||||
/// tell a finished episode from a started one, and would keep it listed for good.
|
/// 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(
|
pub fn set_position(
|
||||||
&self,
|
&self,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -884,17 +892,12 @@ impl Db {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (?1, ?2, ?3, ?4)
|
"INSERT INTO entry_state (user_id, feed_id, guid, position, duration)
|
||||||
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position",
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
rusqlite::params![user_id, feed_id, guid, secs.max(0)],
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,7 +1683,7 @@ mod tests {
|
|||||||
|
|
||||||
// Starring and position are just as private.
|
// Starring and position are just as private.
|
||||||
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
|
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 order = order_sql("published", "desc");
|
||||||
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
||||||
let (ray, sam) = (page(1), page(2));
|
let (ray, sam) = (page(1), page(2));
|
||||||
@@ -1688,6 +1691,9 @@ mod tests {
|
|||||||
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
||||||
assert!(ray_b.flagged && ray_b.position == 0);
|
assert!(ray_b.flagged && ray_b.position == 0);
|
||||||
assert!(!sam_b.flagged && sam_b.position == 42);
|
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.
|
// Marking a whole feed read is likewise one person's business.
|
||||||
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
|
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
|
||||||
@@ -1874,11 +1880,17 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(listening(), ["h", "d"]);
|
assert_eq!(listening(), ["h", "d"]);
|
||||||
|
|
||||||
// The player's measured length fills in the one d's feed left out, which settles it:
|
// The player's measured length is the one that counts, in place of a missing one or over
|
||||||
// 42 of 45 seconds is finished. It never replaces a feed's own length.
|
// 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", "d", 42, Some(45)).unwrap();
|
||||||
db.set_position(7, "f", "e", 42, Some(9000)).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]
|
#[test]
|
||||||
|
|||||||
@@ -200,6 +200,26 @@ test('Currently Listening, its own place below Popular, resumes an episode you s
|
|||||||
await expect(row.locator('.eq')).toBeHidden();
|
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 }) => {
|
test('the filter tabs change what is listed', async ({ page }) => {
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
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.
|
// 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;
|
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||||
if(!resuming){
|
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');
|
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||||||
audio.src=`/media/${enc.id}`;
|
audio.src=`/media/${enc.id}`;
|
||||||
audio.currentTime=0;
|
audio.currentTime=0;
|
||||||
@@ -1539,14 +1539,21 @@ audio.addEventListener('timeupdate',()=>{
|
|||||||
$('#pcur').textContent=clock(audio.currentTime);
|
$('#pcur').textContent=clock(audio.currentTime);
|
||||||
$('#pdur').textContent=clock(d);
|
$('#pdur').textContent=clock(d);
|
||||||
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||||
// Persist roughly every 10s so a reload resumes where you were.
|
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||||
if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); }
|
// 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();
|
if(d && audio.currentTime/d >= 0.9) markPlayed();
|
||||||
});
|
});
|
||||||
function savePos(){
|
function savePos(){
|
||||||
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
|
// 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.
|
// 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;
|
player.saveAt=audio.currentTime;
|
||||||
if(player.entry) player.entry.position=Math.floor(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
|
// 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;
|
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.
|
// 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);
|
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);
|
el.classList.toggle('now',now);
|
||||||
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
$('.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.
|
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
||||||
|
|||||||
Reference in New Issue
Block a user