From 697836b914bb039b20a2d1363a58c9bf61e202fd Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 17 Oct 2026 06:16:24 +0000 Subject: [PATCH] coomer: creator feeds are a bare array; single-word queries that are creator ids go to the creator --- src/providers/coomer.rs | 75 ++++++++++++++++++++++++++++++++++++++--- src/providers/mod.rs | 20 +++++++++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/providers/coomer.rs b/src/providers/coomer.rs index efa82b0..ccc7d28 100644 --- a/src/providers/coomer.rs +++ b/src/providers/coomer.rs @@ -8,7 +8,7 @@ use wreq::Version; use crate::{ DbPool, api::ClientVersion, - providers::{Provider, requester_or_default}, + providers::{Provider, probe_first_archive_with, requester_or_default}, status::{Channel, ChannelOption, FilterOption}, util::cache::VideoCache, videos::{ServerOptions, VideoFormat, VideoItem}, @@ -147,6 +147,43 @@ impl CoomerProvider { } } + /// A single-word query that is an existing creator id (the profile + /// endpoint answers 404 otherwise) goes to that creator's posts, trying the + /// filtered service first, else keyword search. + async fn resolve_keyword( + query: &str, + service_filter: Option<&str>, + options: &ServerOptions, + ) -> Option { + let name = query.trim().trim_start_matches('@'); + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') + { + return None; + } + let mut services = vec![Service::Onlyfans, Service::Fansly, Service::Candfans]; + if let Some(preferred) = Self::resolve_service(service_filter) { + services.retain(|s| s.as_str() != preferred.as_str()); + services.insert(0, preferred); + } + let urls: Vec = services + .iter() + .map(|s| format!("{BASE_URL}/api/v1/{}/user/{name}/profile", s.as_str())) + .collect(); + let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword"); + let (index, _) = + probe_first_archive_with(&mut requester, &urls, Self::json_headers(), |body| { + body.contains("\"id\"") + }) + .await?; + Some(Target::Uploader { + service: services[index].clone(), + username: name.to_string(), + }) + } + fn json_headers() -> Vec<(String, String)> { vec![ ("Referer".to_string(), REFERER.to_string()), @@ -290,13 +327,16 @@ impl CoomerProvider { ))); } } - let posts: ApiPostsResponse = serde_json::from_str(&body).map_err(|err| { + let posts: ApiPostsPayload = serde_json::from_str(&body).map_err(|err| { Error::from(format!( "json parse failed for {url}: {err}; body[:200]={}", &body.chars().take(200).collect::() )) })?; - Ok(posts.posts) + Ok(match posts { + ApiPostsPayload::List(posts) => posts, + ApiPostsPayload::Wrapped(wrapped) => wrapped.posts, + }) } async fn fetch_items( @@ -543,6 +583,14 @@ struct ApiError { error: String, } +/// The global feed wraps posts in `{posts: [...]}`; a creator's feed is a bare array. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ApiPostsPayload { + List(Vec), + Wrapped(ApiPostsResponse), +} + #[derive(Debug, Deserialize)] struct ApiPostsResponse { #[serde(default)] @@ -601,8 +649,13 @@ impl Provider for CoomerProvider { .clamp(1, 64); let service_filter = options.categories.as_deref().or(options.sites.as_deref()); - let target = + let mut target = Self::resolve_target(&sort_value, query.as_deref(), service_filter); + if let Target::Search { query } = &target { + if let Some(found) = Self::resolve_keyword(query, service_filter, &options).await { + target = found; + } + } // Bump the ignored `Sort` variant warning off by referencing it. let _ = Sort::New; @@ -849,4 +902,18 @@ mod tests { let post: ApiPost = serde_json::from_str(json).expect("parse"); assert!(CoomerProvider::build_video_item(post).is_none()); } + + #[test] + fn parses_bare_array_and_wrapped_post_payloads() { + let bare = r#"[{"id":"1","user":"u","service":"onlyfans","title":"t","file":{},"attachments":[]}]"#; + match serde_json::from_str::(bare).expect("bare array") { + ApiPostsPayload::List(posts) => assert_eq!(posts.len(), 1), + other => panic!("expected list, got {other:?}"), + } + let wrapped = r#"{"posts":[{"id":"1"}],"count":1}"#; + match serde_json::from_str::(wrapped).expect("wrapped") { + ApiPostsPayload::Wrapped(w) => assert_eq!(w.posts.len(), 1), + other => panic!("expected wrapped, got {other:?}"), + } + } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index df268ab..4de9546 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -644,10 +644,26 @@ pub async fn probe_first_archive( urls: &[String], referer: &str, has_items: impl Fn(&str) -> bool, +) -> Option<(usize, String)> { + probe_first_archive_with( + requester, + urls, + vec![("Referer".to_string(), referer.to_string())], + has_items, + ) + .await +} + +/// [`probe_first_archive`] with caller-supplied request headers (JSON APIs +/// that demand a special `Accept`, extra tokens, ...). +pub async fn probe_first_archive_with( + requester: &mut Requester, + urls: &[String], + headers: Vec<(String, String)>, + has_items: impl Fn(&str) -> bool, ) -> Option<(usize, String)> { for (index, url) in urls.iter().enumerate() { - let headers = vec![("Referer".to_string(), referer.to_string())]; - if let Ok(Some(body)) = requester.get_probe(url, headers).await { + if let Ok(Some(body)) = requester.get_probe(url, headers.clone()).await { if has_items(&body) { return Some((index, body)); }