Do not auto-download enclosures that are not audio or video
Blog feeds put each article's header image in an <enclosure>, so a text feed read as a podcast full of episodes: 149 images, 74 MB across 11 feeds. media_types defaults to audio and video, with a per-feed override. Such enclosures stay listed and stay downloadable by hand; the row names what it is rather than saying "skipped". An unknown type is allowed, since the real type is only known after downloading, and a torrent is allowed as a container judged once unpacked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
@@ -38,6 +38,10 @@ pub struct General {
|
||||
/// Unlimited by default was a trap: subscribing to an OPML of 80 feeds then pulled
|
||||
/// every back-catalogue episode at once. 0 means unlimited, deliberately chosen.
|
||||
pub max_new_per_check: usize,
|
||||
/// Top-level media types worth downloading. Blog feeds put each article's header
|
||||
/// image in an <enclosure>, so taking everything filled the disk with artwork and
|
||||
/// counted it as episodes. Empty means take anything.
|
||||
pub media_types: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -108,6 +112,9 @@ pub struct Feed {
|
||||
/// Overrides the global schedule for this feed. Same forms: "every 6h", "2d".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schedule: Option<String>,
|
||||
/// Media types for this feed. None follows `[general]`; an empty list takes anything.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub media_types: Option<Vec<String>>,
|
||||
/// Cap on new downloads per scan for this feed. None follows `[general]`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_new_per_check: Option<usize>,
|
||||
@@ -135,6 +142,7 @@ impl Default for General {
|
||||
max_total_gb: 0.0,
|
||||
max_age_days: 0,
|
||||
max_new_per_check: 3,
|
||||
media_types: vec!["audio".into(), "video".into()],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +284,25 @@ fn default_socket() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an enclosure's type is one we want.
|
||||
///
|
||||
/// An unknown type is allowed: the real type is only known after downloading, and
|
||||
/// refusing everything untyped would drop feeds that simply omit the attribute.
|
||||
pub fn wanted_media(mime: Option<&str>, wanted: &[String]) -> bool {
|
||||
if wanted.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some(mime) = mime.map(str::trim).filter(|m| !m.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
let top = mime.split('/').next().unwrap_or(mime).to_ascii_lowercase();
|
||||
// A .torrent is a container for media, not media itself; judge it once unpacked.
|
||||
if mime.to_ascii_lowercase().contains("torrent") {
|
||||
return true;
|
||||
}
|
||||
wanted.iter().any(|w| w.trim().eq_ignore_ascii_case(&top) || w.trim().eq_ignore_ascii_case(mime))
|
||||
}
|
||||
|
||||
/// Feed ids are the TOML table key, so they must be readable and punctuation-free.
|
||||
pub fn slug(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
@@ -391,6 +418,26 @@ mod tests {
|
||||
assert_eq!(t.ports(), (6881, 6889), "reversed range is not a range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_types_keep_article_artwork_out() {
|
||||
let want = vec!["audio".to_string(), "video".to_string()];
|
||||
assert!(wanted_media(Some("audio/mpeg"), &want));
|
||||
assert!(wanted_media(Some("audio/mp4"), &want));
|
||||
assert!(wanted_media(Some("video/quicktime"), &want));
|
||||
assert!(!wanted_media(Some("image/jpeg"), &want), "a blog header image is not an episode");
|
||||
assert!(!wanted_media(Some("text/html"), &want));
|
||||
|
||||
// A torrent is a container; what is inside is judged after unpacking.
|
||||
assert!(wanted_media(Some("application/x-bittorrent"), &want));
|
||||
// Unknown type: only discoverable by downloading, so do not refuse it outright.
|
||||
assert!(wanted_media(None, &want));
|
||||
assert!(wanted_media(Some(""), &want));
|
||||
// An empty list means take anything, which is how it behaved before.
|
||||
assert!(wanted_media(Some("image/jpeg"), &[]));
|
||||
// A full type can be named exactly.
|
||||
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_readable_and_unique() {
|
||||
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
||||
@@ -402,7 +449,7 @@ mod tests {
|
||||
|
||||
let mut taken = BTreeMap::new();
|
||||
taken.insert("the-daily".to_string(), Feed {
|
||||
url: "u".into(), folder: None, group: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||
auto_download: true, max_new_per_check: None, username: None,
|
||||
password: None, password_env: None,
|
||||
});
|
||||
@@ -415,6 +462,7 @@ mod tests {
|
||||
url: "https://x/y".into(),
|
||||
folder: None,
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
|
||||
Reference in New Issue
Block a user