lib.rs held 2,000 lines: fetching, parsing, OPML, Patreon, discovery, artwork, categories, Jev and redaction, and their tests. It is now the module list and the re-exports, so ipx and the directory call feed::fetch, feed::parse and the rest as before: fetch.rs fetch and its limits parse.rs RSS and Atom outlines.rs OPML (not opml.rs, which would shadow the opml crate) patreon.rs a creator's shows discover.rs finding a feed wordpress.rs WordPress.com's API failure.rs failures in words privacy.rs redact, looks_private artwork.rs https art, icons category.rs Apple's categories jev.rs Jev's category Moved verbatim, private items pub(crate) where a sibling calls them, calls across modules module::function. Every definition and doc comment line of the old lib.rs is in exactly one of the new files; each test went with what it tests, and all 40 run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
7.6 KiB
Rust
148 lines
7.6 KiB
Rust
//! Patreon: a creator's feed, its shows, and when two Patreon addresses are the same feed.
|
|
|
|
use crate::fetch;
|
|
use anyhow::{Context, Result, anyhow};
|
|
|
|
/// The token and show of a Patreon feed link, or None for any other URL.
|
|
///
|
|
/// Patreon gives each patron one token per creator. With no show it stands for the creator,
|
|
/// whose feed carries every show at once.
|
|
pub(crate) fn patreon_parts(url: &str) -> Option<(String, Option<String>)> {
|
|
let u = url::Url::parse(url).ok()?;
|
|
if !matches!(u.host_str()?, "patreon.com" | "www.patreon.com") || !u.path().starts_with("/rss") {
|
|
return None;
|
|
}
|
|
let param = |name: &str| u.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned());
|
|
Some((param("auth")?, param("show")))
|
|
}
|
|
|
|
/// A Patreon link naming a creator but no show.
|
|
#[must_use]
|
|
pub fn is_patreon_creator(url: &str) -> bool {
|
|
matches!(patreon_parts(url), Some((_, None)))
|
|
}
|
|
|
|
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
|
/// creator's name, by number, or with no creator at all -- and the token and show are what
|
|
/// identify it.
|
|
#[must_use]
|
|
pub fn same_feed(a: &str, b: &str) -> bool {
|
|
// Parsing both is the slow part, and only Patreon's addresses can differ and still match.
|
|
a == b
|
|
|| (a.contains("patreon.com")
|
|
&& b.contains("patreon.com")
|
|
&& patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b)))
|
|
}
|
|
|
|
/// A Patreon creator's name and shows, each show as (title, feed URL).
|
|
///
|
|
/// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes,
|
|
/// finding shows stops and the show feeds already found keep working. The documented API
|
|
/// needs an OAuth client per install and does not list shows.
|
|
pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Option<String>, Vec<(String, String)>)> {
|
|
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
|
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
|
let mut resp = client.get(url).timeout(fetch::FEED_TIMEOUT).send().await.context("connecting")?;
|
|
if !resp.status().is_success() {
|
|
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
|
}
|
|
let mut head = Vec::new();
|
|
while patreon_campaign(&head).is_none() && head.len() < 64 * 1024 {
|
|
let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break };
|
|
head.extend_from_slice(&chunk);
|
|
}
|
|
let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
|
|
|
let api = format!(
|
|
"https://www.patreon.com/api/campaigns/{campaign}\
|
|
?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title"
|
|
);
|
|
let resp = client.get(api).send().await.context("asking Patreon for the shows")?;
|
|
if !resp.status().is_success() {
|
|
return Err(anyhow!("Patreon would not list the shows: HTTP {}", resp.status()));
|
|
}
|
|
let (name, shows) = parse_patreon_shows(&resp.bytes().await.context("reading the shows")?)?;
|
|
Ok((name, shows.into_iter().map(|(id, title)| (title, format!("{url}&show={id}"))).collect()))
|
|
}
|
|
|
|
/// The campaign number in the start of a Patreon feed.
|
|
pub(crate) fn patreon_campaign(head: &[u8]) -> Option<String> {
|
|
let text = String::from_utf8_lossy(head);
|
|
text.match_indices("patreon.com/rss/").find_map(|(i, m)| {
|
|
let id: String = text[i + m.len()..].chars().take_while(char::is_ascii_digit).collect();
|
|
(!id.is_empty()).then_some(id)
|
|
})
|
|
}
|
|
|
|
/// A campaign's name, and its shows as (id, title).
|
|
pub(crate) type PatreonShows = (Option<String>, Vec<(String, String)>);
|
|
|
|
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
|
|
pub(crate) fn parse_patreon_shows(json: &[u8]) -> Result<PatreonShows> {
|
|
let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?;
|
|
// Missing is not the same as none. Read as no shows, the creator feed would be scanned as
|
|
// a plain feed, claim every show's files, and leave the shows empty once the list returned.
|
|
let ids = v["data"]["relationships"]["shows"]["data"]
|
|
.as_array()
|
|
.ok_or_else(|| anyhow!("Patreon's answer does not list the shows"))?;
|
|
let title = |id: &str| -> Option<String> {
|
|
let show = v["included"].as_array()?.iter().find(|x| x["type"] == "collection" && x["id"] == id)?;
|
|
show["attributes"]["title"].as_str().map(|t| t.trim().to_owned())
|
|
};
|
|
let shows = ids
|
|
.iter()
|
|
.filter_map(|s| s["id"].as_str())
|
|
.map(|id| (id.to_owned(), title(id).unwrap_or_else(|| format!("Show {id}"))))
|
|
.collect();
|
|
Ok((v["data"]["attributes"]["name"].as_str().map(str::to_owned), shows))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::discover;
|
|
|
|
#[test]
|
|
fn a_patreon_creator_is_a_list_of_its_shows() {
|
|
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
|
assert_eq!(discover::expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
|
assert_eq!(discover::expand_input("https://example.com/rss"), "https://example.com/rss");
|
|
assert_eq!(discover::expand_input("http://example.com/rss"), "http://example.com/rss");
|
|
assert_eq!(discover::expand_input(" cnn.com "), "https://cnn.com");
|
|
assert_eq!(discover::expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
|
|
assert_eq!(discover::expand_input("//example.com/rss"), "https://example.com/rss");
|
|
|
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
|
assert!(!is_patreon_creator(&format!("https://www.patreon.com/rss/x?auth={tok}&show=1")), "one show is a feed");
|
|
assert!(!is_patreon_creator(&format!("https://example.com/rss?auth={tok}")));
|
|
|
|
// The show you already have by name is the one a bare token would add by number.
|
|
assert!(same_feed(
|
|
&format!("https://www.patreon.com/rss/glasscannon?auth={tok}&show=2073588"),
|
|
&format!("https://www.patreon.com/rss?auth={tok}&show=2073588"),
|
|
));
|
|
assert!(!same_feed(
|
|
&format!("https://www.patreon.com/rss?auth={tok}&show=1"),
|
|
&format!("https://www.patreon.com/rss?auth={tok}&show=2"),
|
|
));
|
|
|
|
// The self link carries the campaign by number, whichever spelling was asked for.
|
|
let head = br#"<rss><channel><link>https://www.patreon.com/glasscannon</link>
|
|
<atom:link href="https://www.patreon.com/rss/369921?auth=t" rel="self"/>"#;
|
|
assert_eq!(patreon_campaign(head).as_deref(), Some("369921"));
|
|
assert_eq!(patreon_campaign(b"<rss><channel><title>T"), None);
|
|
|
|
let json = br#"{"data":{"id":"369921","type":"campaign","attributes":{"name":"The Glass Cannon Network"},
|
|
"relationships":{"shows":{"data":[{"id":"2073588","type":"collection"},{"id":"2073636","type":"collection"}]}}},
|
|
"included":[{"id":"2073588","type":"collection","attributes":{"title":"Get in the Trunk "}},
|
|
{"id":"2073636","type":"collection","attributes":{"title":"Shadowdark"}}]}"#;
|
|
let (name, shows) = parse_patreon_shows(json).unwrap();
|
|
assert_eq!(name.as_deref(), Some("The Glass Cannon Network"));
|
|
assert_eq!(shows, [("2073588".into(), "Get in the Trunk".into()), ("2073636".into(), "Shadowdark".into())]);
|
|
|
|
// An answer that stops naming the shows is an error, never "this creator has none".
|
|
assert!(parse_patreon_shows(br#"{"data":{"attributes":{"name":"X"}}}"#).is_err());
|
|
}
|
|
}
|