From 470f3e1ff1e56fae1bd8dc244196e17bdedf6c04 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 18:24:27 +0000 Subject: [PATCH] Use an item's thumbnail as its picture Resolves in order of deliberateness: itunes:image, media:thumbnail, a media:content that says it is an image, then an image enclosure -- which is where a blog's article picture actually lives, so those entries had artwork available all along and showed none. Audio enclosures are never taken for pictures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 18 +++++++++++++++ src/feed.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/PROGRESS.md b/PROGRESS.md index 7068456..651729d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,24 @@ and until now nothing set them. --- +## 2026-09-10 — An item's picture + +An item's artwork now resolves in order of how deliberate the source is: `itunes:image`, then Media +RSS `media:thumbnail`, then a `media:content` that declares itself an image, and finally an image +**enclosure**. That last one matters here -- Substack puts each article's header picture in an +``, which is why those blog entries had no artwork despite carrying one all along. Audio +enclosures are never mistaken for pictures. + +Backfilling needed the validators cleared first: `record_entry` fills a missing image on update, but +a 304 skips parsing entirely, so the feeds would have kept their blank squares. (The self-heal added +earlier only fires when a feed has *zero* entries, which was not the case here.) + +Result across the library: 325 of 3470 entries now carry their own picture, 13 feeds where every +entry has one, 69 feeds that publish no per-item image at all -- those fall back to the feed's +artwork, which is the intended behaviour rather than a gap. + +--- + ## 2026-09-10 — Database cleanup, and the 304 trap it walked into Cleaned up on request: removed the `CT Log Archive Torrents` folder (123 preallocated files from the diff --git a/src/feed.rs b/src/feed.rs index f65cc4e..03db071 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -251,7 +251,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed { .filter(|c| !c.is_empty() && !c.starts_with("http")) .collect(), explicit: explicit || entry_explicit, - image: it.and_then(|i| i.image()).map(str::to_owned), + image: item_image(item, &enclosures), duration: it.and_then(|i| i.duration()).and_then(parse_duration), episode: it.and_then(|i| i.episode()).and_then(|e| e.trim().parse().ok()), season: it.and_then(|i| i.season()).and_then(|e| e.trim().parse().ok()), @@ -358,6 +358,39 @@ fn non_empty(s: Option<&str>) -> Option { s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned) } +/// The picture to show beside an item, in order of how deliberate it is: +/// `itunes:image`, then Media RSS `media:thumbnail`, then a `media:content` that is an +/// image, and finally an image enclosure -- which is how a blog's article picture arrives +/// (Substack puts it there), so those entries get artwork rather than a blank square. +fn item_image(item: &rss::Item, enclosures: &[Enclosure]) -> Option { + if let Some(url) = item.itunes_ext().and_then(|i| i.image()) { + return non_empty(Some(url)); + } + + let media = item.extensions().get("media"); + let attr = |name: &str, want_image: bool| -> Option { + media?.get(name)?.iter().find_map(|e| { + if want_image { + // media:content carries anything; only take it when it says it is a picture. + let is_image = e.attrs.get("type").is_some_and(|t| t.starts_with("image/")) + || e.attrs.get("medium").is_some_and(|m| m == "image"); + if !is_image { + return None; + } + } + non_empty(e.attrs.get("url").map(String::as_str)) + }) + }; + attr("thumbnail", false) + .or_else(|| attr("content", true)) + .or_else(|| { + enclosures + .iter() + .find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/"))) + .map(|e| e.url.clone()) + }) +} + /// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45"). fn parse_duration(s: &str) -> Option { let s = s.trim(); @@ -547,6 +580,36 @@ mod tests { assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none"); } + #[test] + fn an_items_picture_comes_from_the_most_deliberate_source() { + let xml = br#" + + Phttps://xd + Has itunesa + + + + Has thumbnailb + + + Has media contentc + + + Only an image enclosured + + Audio onlye + + "#; + let f = parse(xml).unwrap(); + let img = |i: usize| f.entries[i].image.as_deref(); + assert_eq!(img(0), Some("https://x/itunes.jpg"), "itunes:image wins"); + assert_eq!(img(1), Some("https://x/thumb.jpg"), "then media:thumbnail"); + assert_eq!(img(2), Some("https://x/pic.jpg"), "media:content, and only the image one"); + assert_eq!(img(3), Some("https://x/d.jpg"), "a blog's article picture arrives as an enclosure"); + assert_eq!(img(4), None, "audio is not a picture"); + } + #[test] fn durations_parse_from_seconds_or_a_clock() { assert_eq!(parse_duration("5649"), Some(5649));