diff --git a/PROGRESS.md b/PROGRESS.md index f6ea08c..bab3615 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -30,6 +30,33 @@ The full design and step list live in the plan file at --- +## 2026-09-10 — Tested against a real feed (Patreon / Glass Cannon) + +First run against a live subscriber feed: 131 items, 6.17 GB total, all `audio/mpeg`, no ``. +Parsed clean, titled `Get in the Trunk | Anthology Series | Delta Green`, downloaded a valid MP3 +(`file` confirms ID3v2.3, MPEG layer III). A second scan pulled the *next* episode, confirming +`max_new_per_check` defers rather than drops. Over the socket: 101 progress events for a 79 MB +episode -- whole-percent throttling behaving exactly as intended -- then `download_done`, +`feed_done`, `scan_done`. + +**Fixed: stripped separators left doubled spaces.** The feed title separates words with `|`, a +forbidden filename character, so the folder came out `Get in the Trunk Anthology Series Delta +Green`. `sanitize()` now collapses runs of whitespace (and maps control characters to a space +rather than deleting them). The Python had the same wart. + +**Worth knowing, not a bug:** + +- The channel-level `itunes:explicit` is `true`, so with the default `allow_explicit = false` all + 131 episodes are skipped. Because filter verdicts are recorded once at discovery, flipping the + setting afterwards does not re-evaluate enclosures already marked `skipped` -- they would need a + `UPDATE enclosures SET state='pending'`. Worth a `ipx retry ` command if this bites. +- Patreon sends neither `etag` nor `last-modified` on a GET (a `last-modified` does appear on HEAD, + which is what made it look briefly like a storage bug). So this feed can never 304: every poll + re-fetches ~160 KB and re-parses 131 entries, and `interval_mins` is the only thing limiting the + rate. Cheap, but it means conditional GET buys nothing here. + +--- + ## 2026-09-09 — Step 8: OPML and polish `ipx add ` fetches the feed to name it from its own title (`Accidental Tech Podcast` -> diff --git a/src/download.rs b/src/download.rs index 7615d4c..3b3ea51 100644 --- a/src/download.rs +++ b/src/download.rs @@ -15,10 +15,24 @@ const MAX_NAME_BYTES: usize = 255; /// Keeps UTF-8: the original transliterated to ASCII via latin1_to_ascii because 2004 /// filesystems demanded it. Ours do not. pub fn sanitize(name: &str) -> String { - let mut out: String = name + let stripped: String = name .chars() - .filter(|c| !c.is_control() && !FORBIDDEN.contains(c)) + .map(|c| if c.is_control() { ' ' } else { c }) + .filter(|c| !FORBIDDEN.contains(c)) .collect(); + + // Stripping a separator leaves a gap: "Show | Series | Delta" would otherwise become + // "Show Series Delta". Collapse runs of whitespace rather than leaving the seams. + let mut out = String::with_capacity(stripped.len()); + for c in stripped.chars() { + if c.is_whitespace() { + if !out.ends_with(' ') { + out.push(' '); + } + } else { + out.push(c); + } + } out = out.trim().trim_matches('.').trim().to_owned(); if out.len() > MAX_NAME_BYTES { @@ -266,10 +280,21 @@ mod tests { fn sanitize_strips_path_and_control_characters() { assert_eq!(sanitize("../../etc/passwd"), "etcpasswd"); assert_eq!(sanitize("Ep 12: The \"Best\" One?"), "Ep 12 The Best One"); - assert_eq!(sanitize("bad\u{0}name\u{7}.mp3"), "badname.mp3"); + assert_eq!(sanitize("bad\u{0}name\u{7}.mp3"), "bad name .mp3"); assert_eq!(sanitize(" spaced.mp3 "), "spaced.mp3"); } + #[test] + fn sanitize_collapses_the_gaps_left_by_stripped_separators() { + // A real Patreon feed title; the pipes are forbidden characters. + assert_eq!( + sanitize("Get in the Trunk | Anthology Series | Delta Green"), + "Get in the Trunk Anthology Series Delta Green" + ); + assert_eq!(sanitize("Ep 12: The One"), "Ep 12 The One"); + assert_eq!(sanitize("a b"), "a b"); + } + #[test] fn sanitize_never_yields_an_empty_or_dot_name() { assert_eq!(sanitize(""), "download");