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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 18:24:27 +00:00
parent c77d152015
commit 470f3e1ff1
2 changed files with 82 additions and 1 deletions

View File

@@ -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
`<enclosure>`, 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 ## 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 Cleaned up on request: removed the `CT Log Archive Torrents` folder (123 preallocated files from the

View File

@@ -251,7 +251,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.filter(|c| !c.is_empty() && !c.starts_with("http")) .filter(|c| !c.is_empty() && !c.starts_with("http"))
.collect(), .collect(),
explicit: explicit || entry_explicit, 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), 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()), 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()), 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<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned) 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<String> {
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<String> {
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"). /// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
fn parse_duration(s: &str) -> Option<i64> { fn parse_duration(s: &str) -> Option<i64> {
let s = s.trim(); let s = s.trim();
@@ -547,6 +580,36 @@ mod tests {
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none"); 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#"<?xml version="1.0"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:media="http://search.yahoo.com/mrss/">
<channel><title>P</title><link>https://x</link><description>d</description>
<item><title>Has itunes</title><guid>a</guid>
<itunes:image href="https://x/itunes.jpg"/>
<media:thumbnail url="https://x/thumb.jpg"/>
<enclosure url="https://x/a.jpg" length="1" type="image/jpeg"/></item>
<item><title>Has thumbnail</title><guid>b</guid>
<media:thumbnail url="https://x/thumb.jpg"/>
<enclosure url="https://x/b.jpg" length="1" type="image/jpeg"/></item>
<item><title>Has media content</title><guid>c</guid>
<media:content url="https://x/pic.jpg" type="image/jpeg"/>
<media:content url="https://x/clip.mp4" type="video/mp4"/></item>
<item><title>Only an image enclosure</title><guid>d</guid>
<enclosure url="https://x/d.jpg" length="1" type="image/jpeg"/></item>
<item><title>Audio only</title><guid>e</guid>
<enclosure url="https://x/e.mp3" length="1" type="audio/mpeg"/></item>
</channel></rss>"#;
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] #[test]
fn durations_parse_from_seconds_or_a_clock() { fn durations_parse_from_seconds_or_a_clock() {
assert_eq!(parse_duration("5649"), Some(5649)); assert_eq!(parse_duration("5649"), Some(5649));