Episodes can be kept on the device to listen offline (#412)
Offline mode (#389) kept lists, text and pictures, but the worker let every /media/ request pass, so no episode played without a connection. A downloaded episode's files pane has a keep button beside Play. It fetches the copy that person plays, the file or, when DeadAir cuts something for them, the version without its dead air (/media/{id}/deadair, the save button's), whole into a cache of its own, ipx-episodes, and asks once for persistent storage. A kept episode plays as /media/...?offline, online or not, and the worker answers that from the cache with the byte ranges an audio element asks for (206, 416, 200); not kept there, it goes to the server as before. Pressing it again takes it off; signing out clears it with everything else. Only someone's choice is kept, so there is no budget: a full device is the quota's refusal, said in words. The button is data-a="offline", not "keep", which the Directory's show page already uses for subscribing. Places saved from DeadAir's version were read in its clock only if ipx had handed out that file's playlist since it started (state.playing). A kept copy plays without one, so the player now names the file in the position it saves (deadair: <enclosure>) whenever it plays DeadAir's clock, and ipx loads that file's plan when it has none, for that item's file only. Checked the beacon is queued by the worker offline: it is. Tests: playing a kept episode offline, its place queued and sent once back, and taking it off (the offline show plays quiet.mp3, the only real MP3 among the fixtures); the worker's ranges; which copy is kept and played, in page-unit; the server reading a flagged place in DeadAir's clock without a playlist, and ignoring another item's file. The files pane's one-row test counts five buttons and checks a phone's width too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
This commit is contained in:
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- A place in an episode saved from DeadAir's shortened version names its file (`deadair` in `POST .../position`), so it is read in that version's clock even when ipx never handed out its playlist, as for a copy kept on the device or after a restart (#412).
|
||||
- `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389).
|
||||
- The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384).
|
||||
- `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369).
|
||||
|
||||
@@ -1500,6 +1500,50 @@ mod tests {
|
||||
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.9", &[], login("correct horse")).await.0, 429);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_place_saved_in_deadairs_clock_is_read_in_it_without_the_playlist() {
|
||||
let (app, ctx) = app(crate::config::Config::default()).await;
|
||||
let db = &ctx.db;
|
||||
let u = db.create_user("dee", None, false).await.unwrap();
|
||||
db.subscribe(u, "f").await.unwrap();
|
||||
for sql in [
|
||||
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'h', 1, 600)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (8, 'f', 'h', 'http://x/b.mp3', '/x/b.mp3', 'done', 100)",
|
||||
] {
|
||||
db.sql(sql).await.unwrap();
|
||||
}
|
||||
// Two minutes cut, both before 3:20 of the shortened version.
|
||||
let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []}}"#;
|
||||
db.store_plans(7, plans).await.unwrap();
|
||||
db.store_plans(8, plans).await.unwrap();
|
||||
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||
db.create_session(u, "s1").await.unwrap();
|
||||
let ctx = &ctx;
|
||||
let place = |guid: &'static str| async move {
|
||||
let order = crate::db::order_sql("published", "desc");
|
||||
let rows = ctx.db.entries_in(u, Some("f"), crate::db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||
rows.into_iter().find(|r| r.guid == guid).unwrap().position
|
||||
};
|
||||
let save = |guid: &str, body: serde_json::Value| {
|
||||
let uri = format!("/api/entries/f/{guid}/position");
|
||||
let app = app.clone();
|
||||
async move { ask(&app, "POST", &uri, "10.0.0.5", &[("cookie", "ipx_session=s1")], Some(body)).await.0 }
|
||||
};
|
||||
// Naming another item's file is taken as no file: the place stays as sent.
|
||||
assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 8 })).await, 204);
|
||||
assert_eq!(place("g").await, 200);
|
||||
// From a copy kept on the device, no playlist asked for: 3:20 of it is 5:20 of the original.
|
||||
assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 7 })).await, 204);
|
||||
assert_eq!(place("g").await, 320);
|
||||
// The original's own clock, from a player that says nothing of DeadAir, is left as it is.
|
||||
assert_eq!(save("h", serde_json::json!({ "secs": 200 })).await, 204);
|
||||
assert_eq!(place("h").await, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_of_wrong_passwords_ends_and_is_forgotten() {
|
||||
let mut wrong = HashMap::new();
|
||||
@@ -2785,6 +2829,10 @@ struct Position {
|
||||
secs: i64,
|
||||
/// The length the player measured, for an episode whose feed gives none.
|
||||
duration: Option<i64>,
|
||||
/// The file, when the player was playing it without its dead air: its places are in that
|
||||
/// clock. The playlist tells the server so too, but a copy kept on the device (#412) plays
|
||||
/// without one, and the server forgets what it was told when it restarts.
|
||||
deadair: Option<i64>,
|
||||
}
|
||||
|
||||
async fn set_position(
|
||||
@@ -2793,6 +2841,17 @@ async fn set_position(
|
||||
user: crate::db::User,
|
||||
Json(body): Json<Position>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let key = (user.id, feed_id.clone(), guid.clone());
|
||||
if let Some(id) = body.deadair
|
||||
&& !state.playing.lock().expect("a panic elsewhere poisoned this lock").contains_key(&key)
|
||||
&& let Some(enc) = state.ctx.db.enclosure(id).await?
|
||||
// Only this item's file: another's plan would put the place somewhere else.
|
||||
&& enc.feed_id == feed_id
|
||||
&& enc.guid == guid
|
||||
&& let Some(plan) = crate::deadair::plan_for(&state.ctx, user.id, &feed_id, id).await?
|
||||
{
|
||||
state.playing.lock().expect("a panic elsewhere poisoned this lock").insert(key, Arc::new(plan));
|
||||
}
|
||||
let (secs, duration) =
|
||||
crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?;
|
||||
state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?;
|
||||
|
||||
Reference in New Issue
Block a user