//! 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)> { 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, 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 { 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, 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 { 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 { 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#"https://www.patreon.com/glasscannon "#; assert_eq!(patreon_campaign(head).as_deref(), Some("369921")); assert_eq!(patreon_campaign(b"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()); } }