diff --git a/CHANGELOG.md b/CHANGELOG.md index 8956c99..7c77f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186). - Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183). ### Changed diff --git a/src/deadair.rs b/src/deadair.rs index fd26889..2322a6e 100644 --- a/src/deadair.rs +++ b/src/deadair.rs @@ -60,6 +60,49 @@ impl Plan { } } +/// The length of the ID3v2 tag a file starts with, 0 if it has none. +pub fn id3_len(head: &[u8]) -> u64 { + if head.len() < 10 || &head[..3] != b"ID3" { + return 0; + } + // Seven bits a byte, so no byte of the size looks like the start of a frame. + let size = head[6..10].iter().fold(0u64, |n, b| n << 7 | u64::from(b & 0x7f)); + let footer = if head[5] & 0x10 != 0 { 10 } else { 0 }; + 10 + size + footer +} + +/// A file as `plan` plays it, to save (#186): its ID3 tag, so the title and artwork come too, then +/// the pieces one after another. MP3 and ADTS frames each stand alone, so that is a file any +/// player plays. Read as it is sent and never stored, as the playlist is: (its length, its bytes). +pub async fn kept( + path: &Path, + plan: &Plan, +) -> std::io::Result<(u64, impl futures_util::Stream> + use<>)> { + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + let mut file = tokio::fs::File::open(path).await?; + let mut head = [0u8; 10]; + let n = file.read(&mut head).await?; + // Only up to the first frame kept: past the tag can be the Xing frame, which gives the + // original's length. + let first = plan.pieces.first().map_or(0, |p| p.0); + let tag = id3_len(&head[..n]).min(first); + let ranges: std::collections::VecDeque<(u64, u64)> = + std::iter::once((0, tag)).chain(plan.pieces.iter().map(|&(o, n, _)| (o, n))).filter(|r| r.1 > 0).collect(); + let len = ranges.iter().map(|r| r.1).sum(); + let body = futures_util::stream::try_unfold((file, ranges), |(mut f, mut left)| async move { + let Some((off, n)) = left.pop_front() else { return Ok(None) }; + let mut buf = vec![0; n.min(1 << 16) as usize]; + f.seek(std::io::SeekFrom::Start(off)).await?; + f.read_exact(&mut buf).await?; + let got = buf.len() as u64; + if got < n { + left.push_front((off + got, n - got)); + } + Ok(Some((axum::body::Bytes::from(buf), (f, left)))) + }); + Ok((len, body)) +} + /// The silences silencedetect reported, as (start, end) seconds. One still open at the end of /// the file has no end and is left alone. pub fn parse_silences(log: &str) -> Vec<(f64, f64)> { @@ -597,6 +640,14 @@ mod tests { assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours"); } + #[test] + fn id3_len_reads_the_tags_size() { + assert_eq!(id3_len(b"ID3\x04\x00\x00\x00\x00\x02\x01xx"), 10 + 257); + assert_eq!(id3_len(b"ID3\x04\x00\x10\x00\x00\x02\x01"), 10 + 257 + 10, "with a footer"); + assert_eq!(id3_len(b"\xff\xfb\x90\x00\x00\x00\x00\x00\x00\x00"), 0); + assert_eq!(id3_len(b"ID3"), 0); + } + /// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on, /// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not /// installed. @@ -648,6 +699,18 @@ mod tests { assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut"); // Decodes without a complaint. assert!(ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty()); + // Saved as a file of its own (#186): as long as the playlist, and it plays. + let (n, body) = kept(&src, p).await.unwrap(); + let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap(); + let bytes = parts.concat(); + assert_eq!(bytes.len() as u64, n); + if ext == "mp3" { + assert!(bytes.starts_with(b"ID3"), "the original's tag comes too"); + } + let saved = dir.join(format!("saved.{ext}")); + std::fs::write(&saved, &bytes).unwrap(); + assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}"); + assert!(ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty()); } std::fs::remove_dir_all(&dir).unwrap(); } diff --git a/src/web.rs b/src/web.rs index 9c83fef..c109f55 100644 --- a/src/web.rs +++ b/src/web.rs @@ -71,6 +71,7 @@ pub fn router(state: WebState) -> Router { .route("/media/{id}", get(media)) .route("/media/{id}/deadair.m3u8", get(media_deadair)) .route("/media/{id}/deadair.mp3", get(media_piece)) + .route("/media/{id}/deadair", get(media_save)) .route("/api/art", get(art)) .layer(middleware::from_fn_with_state(state.clone(), auth)) // Signing in cannot require being signed in, so these sit outside the auth layer. @@ -1973,6 +1974,46 @@ async fn media_deadair(State(state): State, Path(id): Path, user: .into_response() } +/// A file without its dead air, to keep (#186): what the save button fetches for someone who +/// plays it shortened. +// shortcut: no Range requests, so a save that breaks off starts again; fine for a button that +// saves, add them if a player is ever pointed here. +async fn media_save(State(state): State, Path(id): Path, user: crate::db::User) -> Response { + let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else { + return (StatusCode::NOT_FOUND, "no such enclosure").into_response(); + }; + if let Err(e) = subscribed(&state, &user, &enc.feed_id).await { + return e.into_response(); + } + let Some(path) = enc.path else { + return (StatusCode::NOT_FOUND, "not downloaded").into_response(); + }; + let plan = match crate::deadair::plan_for(&state.ctx, user.id, &enc.feed_id, id).await { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, "DeadAir has nothing to cut in this file for you").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(), + }; + let path = crate::download::playable(std::path::Path::new(&path)); + let (len, body) = match crate::deadair::kept(&path, &plan).await { + Ok(k) => k, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + let stem = path.file_stem().map_or("episode".into(), |s| s.to_string_lossy()); + let ext = path.extension().map_or("mp3".into(), |s| s.to_string_lossy()); + let name = format!("{stem} (DeadAir).{ext}"); + let name = percent_encoding::utf8_percent_encode(&name, percent_encoding::NON_ALPHANUMERIC); + let kind = if ext.eq_ignore_ascii_case("aac") { "audio/aac" } else { "audio/mpeg" }; + ( + [ + (header::CONTENT_TYPE, kind.to_owned()), + (header::CONTENT_LENGTH, len.to_string()), + (header::CONTENT_DISPOSITION, format!("attachment; filename*=UTF-8''{name}")), + ], + axum::body::Body::from_stream(body), + ) + .into_response() +} + /// The original's bytes, for the pieces of DeadAir's playlist. async fn media_piece( State(state): State, diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index e7f5f1c..c8e5f52 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1787,6 +1787,15 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y await page.locator('#ssave').click(); await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual); + // Saved without its dead air too (#186): smaller than the original, named to say so. + const saved = await page.evaluate(async id => { + const [d, o] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`)]); + return { status: d.status, name: d.headers.get('content-disposition'), size: (await d.arrayBuffer()).byteLength, full: (await o.arrayBuffer()).byteLength }; + }, (await entry()).enclosures[0].id); + expect(saved.status).toBe(200); + expect(saved.name).toContain('DeadAir'); + expect(saved.size).toBeLessThan(saved.full); + // Played through DeadAir's playlist (#181), by hls.js in Chromium, with the switch beside the // speed pressed. The element's own src is then hls.js's blob: address. await row.locator('[data-a=play]').click(); diff --git a/web/src/items.ts b/web/src/items.ts index 8feee8d..05b37da 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -327,7 +327,9 @@ function encBox(x){ ? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)` : 'Delete file'; const delBtn=``; - const saveBtn=`${ICON.save}`; + // Played without its dead air, so saved without it too (#186). + const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer'; + const saveBtn=`${ICON.save}`; // The publisher's address, not this server's copy, which only someone signed in here can open. const shareBtn=``; if(x.path && !isPlayable(x)){