use async_trait::async_trait; use chrono::{DateTime, Utc}; use error_chain::error_chain; use futures::stream::{self, StreamExt}; use serde::Deserialize; use wreq::Version; use crate::{ DbPool, api::ClientVersion, providers::{Provider, probe_first_archive_with, requester_or_default}, status::{Channel, ChannelOption, FilterOption}, util::cache::VideoCache, videos::{ServerOptions, VideoFormat, VideoItem}, }; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { group_id: "onlyfans", tags: &["onlyfans", "fansly", "candfans", "leaks", "aggregator"], }; const BASE_URL: &str = "https://coomer.st"; const CHANNEL_ID: &str = "coomer"; // Matches the UA the wreq Chrome 120 emulation ships with so the site's // DDoS-Guard (DDG) edge treats us as a normal browser. Coomer sits behind DDG // rather than Cloudflare — it issues a per-day anti-scrape cookie and a hard // `Accept: text/css` directive on JSON endpoints, but does NOT serve a JS // challenge. const BROWSER_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; const REFERER: &str = "https://coomer.st/"; // `text/css` is the magic Accept value the JSON endpoints demand under the // DDG anti-scrape rule: "If you want to scrape, use Accept: text/css header in // your requests for now. For whatever reason DDG does not like SPA and JSON, // so we have to be funny." const JSON_ACCEPT: &str = "text/css,*/*;q=0.1"; const CACHE_TTL_SECS: u64 = 60 * 15; const MAX_PAGE: u16 = 50; /// The API pages by offset (`o`), in blocks of this many posts. `page=` is ignored. const API_PAGE_SIZE: u16 = 50; const DEFAULT_PER_PAGE: usize = 30; const USER_POSTS_FETCH_CONCURRENCY: usize = 4; error_chain! { foreign_links { HttpRequest(wreq::Error); Io(std::io::Error); } errors { Parse(msg: String) { description("parse error") display("parse error: {}", msg) } } } #[derive(Debug, Clone)] pub struct CoomerProvider { url: String, } #[derive(Debug, Clone)] enum Sort { New, Popular, Oldest, } #[derive(Debug, Clone)] enum Service { Onlyfans, Fansly, Candfans, } impl Service { fn as_str(&self) -> &'static str { match self { Service::Onlyfans => "onlyfans", Service::Fansly => "fansly", Service::Candfans => "candfans", } } } #[derive(Debug, Clone)] enum Target { Latest, Popular, Search { query: String }, Uploader { service: Service, username: String }, } impl CoomerProvider { pub fn new() -> Self { Self { url: BASE_URL.to_string(), } } fn build_channel(&self, _clientversion: ClientVersion) -> Channel { Channel { id: CHANNEL_ID.to_string(), name: "Coomer".to_string(), description: "Coomer — free OnlyFans, Fansly, and CandFans leak aggregator. \ Browse the latest, most popular, or oldest posts across all services, \ search, or jump directly to a creator." .to_string(), premium: false, favicon: "https://www.google.com/s2/favicons?sz=64&domain=coomer.st".to_string(), status: "active".to_string(), categories: vec![], options: vec![ ChannelOption { id: "sort".to_string(), title: "Sort".to_string(), description: "Browse the Coomer archive.".to_string(), systemImage: "arrow.up.arrow.down".to_string(), colorName: "blue".to_string(), options: vec![ FilterOption { id: "new".to_string(), title: "Latest".to_string() }, FilterOption { id: "popular".to_string(), title: "Most Popular".to_string() }, FilterOption { id: "oldest".to_string(), title: "Oldest".to_string() }, ], multiSelect: false, }, ChannelOption { id: "service".to_string(), title: "Service".to_string(), description: "Limit the feed to a single platform.".to_string(), systemImage: "globe".to_string(), colorName: "purple".to_string(), options: vec![ FilterOption { id: "all".to_string(), title: "All Services".to_string() }, FilterOption { id: "onlyfans".to_string(), title: "OnlyFans".to_string() }, FilterOption { id: "fansly".to_string(), title: "Fansly".to_string() }, FilterOption { id: "candfans".to_string(), title: "CandFans".to_string() }, ], multiSelect: false, }, ], nsfw: true, cacheDuration: Some(1800), ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()), } } /// 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()), ("User-Agent".to_string(), BROWSER_UA.to_string()), ("Accept".to_string(), JSON_ACCEPT.to_string()), ("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()), ] } fn absolute_url(&self, value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return String::new(); } if trimmed.starts_with("http://") || trimmed.starts_with("https://") { return trimmed.to_string(); } if trimmed.starts_with("//") { return format!("https:{trimmed}"); } format!( "{}/{}", self.url.trim_end_matches('/'), trimmed.trim_start_matches('/') ) } fn resolve_service(value: Option<&str>) -> Option { let raw = value?.trim().to_ascii_lowercase(); if raw.is_empty() || raw == "all" { return None; } match raw.as_str() { "onlyfans" | "of" => Some(Service::Onlyfans), "fansly" | "fl" => Some(Service::Fansly), "candfans" | "cf" => Some(Service::Candfans), _ => None, } } fn resolve_target( sort: &str, query: Option<&str>, service_filter: Option<&str>, ) -> Target { // Uploader shortcut: a query that starts with "u:" or matches the // ":" pattern routes to a creator archive. Bare "u:name" // falls back to onlyfans for compatibility with the most common case. if let Some(raw) = query.map(str::trim).filter(|q| !q.is_empty()) { if let Some(rest) = raw.strip_prefix("u:") { let service = CoomerProvider::resolve_service(service_filter) .unwrap_or(Service::Onlyfans); let username = rest.trim(); if !username.is_empty() { return Target::Uploader { service, username: username.to_string(), }; } } // @service:user → that service's archive if raw.starts_with('@') { let stripped = raw.trim_start_matches('@'); if let Some((svc, user)) = stripped.split_once(':') { if let Some(service) = CoomerProvider::resolve_service(Some(svc)) { let username = user.trim(); if !username.is_empty() { return Target::Uploader { service, username: username.to_string(), }; } } } } return Target::Search { query: raw.to_string(), }; } // Service filter without a query is a no-op on the main feed (the // service filter only matters for search and for uploader routing); // ignore it here so we still hit the global /api/v1/posts endpoint. let _ = service_filter; match sort { "popular" | "hot" | "most_viewed" | "most-viewed" => Target::Popular, "old" | "oldest" => Target::Latest, // sorted=oldest on the global feed _ => Target::Latest, } } fn offset_for(page: u16) -> u32 { (page.max(1).min(MAX_PAGE) as u32 - 1) * API_PAGE_SIZE as u32 } fn build_main_url(target: &Target, page: u16) -> String { let offset = Self::offset_for(page); match target { Target::Latest => format!("{}/api/v1/posts?o={offset}", BASE_URL), Target::Popular => format!("{}/api/v1/posts/popular?o={offset}", BASE_URL), Target::Search { query } => { let encoded = percent_encode(query); format!("{}/api/v1/posts?q={encoded}&o={offset}", BASE_URL) } // Uploader URL is built separately. Target::Uploader { .. } => String::new(), } } fn build_uploader_url(service: &Service, username: &str, page: u16) -> String { format!( "{}/api/v1/{}/user/{}/posts?o={}", BASE_URL, service.as_str(), percent_encode(username), Self::offset_for(page), ) } async fn fetch_json( &self, options: &ServerOptions, context: &str, url: &str, ) -> Result> { let mut requester = requester_or_default(options, CHANNEL_ID, context); let body = requester .get_with_headers(url, Self::json_headers(), Some(Version::HTTP_11)) .await .map_err(|err| Error::from(format!("request failed for {url}: {err}")))?; // The DDG anti-scrape path returns a JSON body whose `error` field // documents the rule. Treat it as a hard error so callers can fall back. if let Ok(api_err) = serde_json::from_str::(&body) { if !api_err.error.trim().is_empty() { return Err(Error::from(format!( "DDG anti-scrape triggered for {url}: {}", api_err.error ))); } } 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(match posts { ApiPostsPayload::List(posts) => posts, ApiPostsPayload::Wrapped(wrapped) => wrapped.posts, }) } async fn fetch_items( &self, cache: VideoCache, target: Target, page: u16, per_page_limit: usize, options: &ServerOptions, ) -> Result> { let url = match &target { Target::Uploader { service, username } => { Self::build_uploader_url(service, username, page) } _ => Self::build_main_url(&target, page), }; // Key on the full request URL: the search query must be part of it, // otherwise every search shares one cached page. let cache_key = format!("coomer::{url}"); if let Some((time, items)) = cache.get(&cache_key) { if time.elapsed().unwrap_or_default().as_secs() < CACHE_TTL_SECS { return Ok(items.clone()); } } let posts = self.fetch_json(options, "coomer.fetch_items", &url).await?; eprintln!("[coomer] fetched {} posts from {}", posts.len(), url); if posts.is_empty() { cache.insert(cache_key, vec![]); return Ok(vec![]); } let mut items: Vec = stream::iter(posts.into_iter()) .filter_map(|post| async move { Self::build_video_item(post) }) .collect() .await; if items.len() > per_page_limit.max(1) { items.truncate(per_page_limit.max(1)); } cache.insert(cache_key, items.clone()); Ok(items) } fn build_video_item(post: ApiPost) -> Option { let id = post.id?; if id.is_empty() { return None; } let service = post.service.unwrap_or_else(|| "onlyfans".to_string()); let user = post.user.unwrap_or_default(); let title = post .title .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .unwrap_or_else(|| { // Fall back to the post body's first line so the title is // never empty; the substring is HTML-escaped. let body = post .substring .as_deref() .map(strip_html) .unwrap_or_default(); body.lines() .next() .map(str::trim) .filter(|line| !line.is_empty()) .map(|line| { if line.chars().count() > 80 { let mut idx = line.char_indices(); for _ in 0..80 { if idx.next().is_none() { break; } } let cutoff = idx.next().map(|(i, _)| i).unwrap_or(line.len()); format!("{}…", &line[..cutoff]) } else { line.to_string() } }) .unwrap_or_else(|| format!("Post #{id}")) }); let page_url = format!("{}/{}/user/{}/post/{}", BASE_URL, service, user, id); // Coomer returns the main file in `file` and the rest in `attachments`. // Pick the first *video* across both: a video can sit in an attachment // while `file` is a cover image. Image-only posts have nothing // playable (and yt-dlp can't resolve the post page), so they are // dropped from this video channel. let is_video_ext = |ext: &str| matches!(ext.to_ascii_lowercase().as_str(), "mp4" | "webm" | "mov" | "mkv" | "m4v"); let mut candidates: Vec<&ApiFile> = Vec::new(); if let Some(file) = post.file.as_ref() { candidates.push(file); } if let Some(attachments) = post.attachments.as_ref() { candidates.extend(attachments.iter()); } let video_file = candidates.into_iter().find(|file| { file.path.as_deref().is_some_and(|path| !path.is_empty()) && file .name .as_deref() .and_then(extension_from_name) .or_else(|| file.path.as_deref().and_then(extension_from_name)) .is_some_and(|ext| is_video_ext(ext)) })?; let preview_url: Option = post .file .as_ref() .and_then(|file| file.path.as_deref()) .filter(|path| !path.is_empty()) .map(|path| format!("{}/data/{}", BASE_URL, path.trim_start_matches('/'))); let format_url: Option = video_file .path .as_deref() .map(|path| format!("{}/data/{}", BASE_URL, path.trim_start_matches('/'))); let ext_hint: Option = video_file .name .as_deref() .and_then(extension_from_name) .or_else(|| video_file.path.as_deref().and_then(extension_from_name)) .map(|ext| ext.to_ascii_lowercase()); // Final fallback for the thumbnail: the preview file's data URL, or // the format URL if the file itself is the only image. let thumb = preview_url .clone() .or_else(|| format_url.clone()) .unwrap_or_default(); let mut item = VideoItem::new( id.clone(), title, page_url, CHANNEL_ID.to_string(), thumb, 0, ); if !user.is_empty() { let uploader_url = format!("{}/{}/user/{}", BASE_URL, service, user); let uploader_id = format!("{CHANNEL_ID}:{service}:{user}"); item.uploader = Some(user.clone()); item.uploaderUrl = Some(uploader_url); item.uploaderId = Some(uploader_id); } if let Some(ts) = post.published.as_deref().and_then(parse_timestamp) { item.uploadedAt = Some(ts); } if let Some(ref url) = format_url { let ext = ext_hint.unwrap_or_else(|| "mp4".to_string()); let is_video = is_video_ext(&ext); // An image-only post (very common - many creators post photos, // not just videos) has nothing playable; emitting a "format" // pointing at a static jpg/png just gives players a broken // pseudo-video to try to stream. The preview URL already covers // showing the image. if is_video { let mut format = VideoFormat::new(url.clone(), "original".to_string(), ext.clone()); // The /data/... path 302s to the n*.coomer.st CDN; that CDN will // serve the bytes without a Referer requirement, but the *initial* // redirect to the CDN does require a valid coomer.st session cookie // and Referer. Set the Referer so the 302 actually returns the // Location header. format = format.http_header("Referer".to_string(), REFERER.to_string()); format = format.http_header("User-Agent".to_string(), BROWSER_UA.to_string()); format = format.video_ext(ext); item.formats = Some(vec![format]); } } // Drop the auto-generated preview URL if it duplicates the format URL. if item.preview.is_none() { if let (Some(p), Some(f)) = (preview_url.as_ref(), format_url.as_ref()) { if p != f { item.preview = Some(p.clone()); } } } Some(item) } } fn percent_encode(value: &str) -> String { let mut out = String::with_capacity(value.len()); for byte in value.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(*byte as char); } _ => { out.push_str(&format!("%{:02X}", byte)); } } } out } fn extension_from_name(name: &str) -> Option<&str> { let trimmed = name.trim(); let idx = trimmed.rfind('.')?; let ext = &trimmed[idx + 1..]; if ext.is_empty() || ext.len() > 5 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) { None } else { Some(ext) } } fn parse_timestamp(value: &str) -> Option { DateTime::parse_from_rfc3339(value) .ok() .map(|dt| dt.with_timezone(&Utc).timestamp().max(0) as u64) } fn strip_html(value: &str) -> String { // Cheap tag stripper — good enough for the title fallback. We don't try to // handle entities here because the substring is plain text on the wire. let mut out = String::with_capacity(value.len()); let mut in_tag = false; for ch in value.chars() { match ch { '<' => in_tag = true, '>' => in_tag = false, _ if !in_tag => out.push(ch), _ => {} } } out.trim().to_string() } #[derive(Debug, Deserialize)] struct ApiError { #[serde(default)] 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)] posts: Vec, } #[derive(Debug, Deserialize)] struct ApiPost { #[serde(default)] id: Option, #[serde(default)] user: Option, #[serde(default)] service: Option, #[serde(default)] title: Option, #[serde(default)] substring: Option, #[serde(default)] published: Option, #[serde(default)] file: Option, #[serde(default)] attachments: Option>, } #[derive(Debug, Deserialize)] struct ApiFile { #[serde(default)] name: Option, #[serde(default)] path: Option, } #[async_trait] impl Provider for CoomerProvider { async fn get_videos( &self, cache: VideoCache, _pool: DbPool, sort: String, query: Option, page: String, per_page: String, options: ServerOptions, ) -> Vec { let sort_value = if sort.is_empty() { options.sort.as_deref().unwrap_or("new").to_string() } else { sort }; let page_num = page.parse::().unwrap_or(1).max(1); let per_page_limit = per_page .parse::() .unwrap_or(DEFAULT_PER_PAGE) .clamp(1, 64); let service_filter = options.categories.as_deref().or(options.sites.as_deref()); 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; match self .fetch_items(cache, target, page_num, per_page_limit, &options) .await { Ok(videos) => { eprintln!("[coomer] get_videos returned {} items", videos.len()); videos } Err(error) => { eprintln!("[coomer] get_videos error: {error}"); crate::providers::report_provider_error( CHANNEL_ID, "get_videos", &error.to_string(), ) .await; vec![] } } } fn get_channel(&self, clientversion: ClientVersion) -> Option { Some(self.build_channel(clientversion)) } } #[cfg(test)] mod tests { use super::*; fn provider() -> CoomerProvider { CoomerProvider::new() } #[test] fn builds_latest_url() { assert_eq!( CoomerProvider::build_main_url(&Target::Latest, 1), "https://coomer.st/api/v1/posts?o=0" ); assert_eq!( CoomerProvider::build_main_url(&Target::Latest, 3), "https://coomer.st/api/v1/posts?o=100" ); } #[test] fn builds_popular_url() { assert_eq!( CoomerProvider::build_main_url(&Target::Popular, 1), "https://coomer.st/api/v1/posts/popular?o=0" ); } #[test] fn builds_search_url_with_percent_encoding() { let target = Target::Search { query: "milf + solo".to_string(), }; let url = CoomerProvider::build_main_url(&target, 1); assert!(url.contains("q=milf%20%2B%20solo")); assert!(url.ends_with("&o=0")); let page_three = CoomerProvider::build_main_url(&target, 3); assert!(page_three.ends_with("&o=100")); } #[test] fn builds_uploader_url_per_service() { assert_eq!( CoomerProvider::build_uploader_url(&Service::Onlyfans, "tabycatxoxo", 1), "https://coomer.st/api/v1/onlyfans/user/tabycatxoxo/posts?o=0" ); assert_eq!( CoomerProvider::build_uploader_url(&Service::Fansly, "ruby", 2), "https://coomer.st/api/v1/fansly/user/ruby/posts?o=50" ); assert_eq!( CoomerProvider::build_uploader_url(&Service::Candfans, "model", 1), "https://coomer.st/api/v1/candfans/user/model/posts?o=0" ); } #[test] fn resolves_target_for_default_feed() { assert!(matches!( CoomerProvider::resolve_target("new", None, None), Target::Latest )); assert!(matches!( CoomerProvider::resolve_target("popular", None, None), Target::Popular )); assert!(matches!( CoomerProvider::resolve_target("", None, None), Target::Latest )); } #[test] fn resolves_uploader_target_via_u_prefix() { let target = CoomerProvider::resolve_target("new", Some("u:tabycatxoxo"), Some("fansly")); match target { Target::Uploader { service, username } => { assert_eq!(service.as_str(), "fansly"); assert_eq!(username, "tabycatxoxo"); } _ => panic!("expected Uploader target"), } } #[test] fn resolves_uploader_target_via_at_prefix() { let target = CoomerProvider::resolve_target("new", Some("@onlyfans:ruby"), None); match target { Target::Uploader { service, username } => { assert_eq!(service.as_str(), "onlyfans"); assert_eq!(username, "ruby"); } _ => panic!("expected Uploader target"), } } #[test] fn resolves_search_for_plain_query() { assert!(matches!( CoomerProvider::resolve_target("new", Some("milf"), None), Target::Search { .. } )); } #[test] fn resolves_service_filter() { assert!(matches!( CoomerProvider::resolve_service(Some("onlyfans")), Some(Service::Onlyfans) )); assert!(matches!( CoomerProvider::resolve_service(Some("FANSLY")), Some(Service::Fansly) )); assert!(matches!( CoomerProvider::resolve_service(Some("candfans")), Some(Service::Candfans) )); assert!(CoomerProvider::resolve_service(Some("all")).is_none()); assert!(CoomerProvider::resolve_service(None).is_none()); assert!(CoomerProvider::resolve_service(Some("")).is_none()); assert!(CoomerProvider::resolve_service(Some("bogus")).is_none()); } #[test] fn extension_from_name_basic() { assert_eq!(extension_from_name("clip.mp4"), Some("mp4")); assert_eq!(extension_from_name("photo.JPG"), Some("JPG")); assert_eq!(extension_from_name("noext"), None); assert_eq!(extension_from_name("weird."), None); assert_eq!(extension_from_name("a.weird"), Some("weird")); } #[test] fn parse_timestamp_handles_rfc3339() { let ts = parse_timestamp("2025-01-02T03:04:05Z").expect("valid timestamp"); assert!(ts > 0); } #[test] fn strip_html_removes_tags() { assert_eq!(strip_html("

Hello world

"), "Hello world"); } #[test] fn build_video_item_basic() { let json = r#"{ "id": "1768608897", "user": "tabycatxoxo", "service": "onlyfans", "title": "First post", "published": "2025-01-02T03:04:05Z", "file": {"name": "clip.mp4", "path": "/abc/clip.mp4"}, "attachments": [] }"#; let post: ApiPost = serde_json::from_str(json).expect("parse"); let item = CoomerProvider::build_video_item(post).expect("item"); assert_eq!(item.id, "1768608897"); assert_eq!(item.title, "First post"); assert_eq!( item.url, "https://coomer.st/onlyfans/user/tabycatxoxo/post/1768608897" ); assert_eq!(item.uploader.as_deref(), Some("tabycatxoxo")); assert_eq!( item.uploaderId.as_deref(), Some("coomer:onlyfans:tabycatxoxo") ); assert_eq!(item.thumb, "https://coomer.st/data/abc/clip.mp4"); let formats = item.formats.as_ref().expect("formats present"); assert_eq!(formats.len(), 1); assert_eq!(formats[0].url, "https://coomer.st/data/abc/clip.mp4"); let headers = formats[0].http_headers_pairs(); assert!(headers.iter().any(|(k, v)| k == "Referer" && v == REFERER)); assert_eq!(item.uploadedAt, Some(1735787045)); } #[test] fn build_video_item_falls_back_to_substring_title() { let json = r#"{ "id": "42", "user": "ruby", "service": "fansly", "title": "", "substring": "

Hello there friends

", "file": {"name": "p.jpg", "path": "/x/p.jpg"} }"#; let post: ApiPost = serde_json::from_str(json).expect("parse"); // Image-only posts have nothing playable and are dropped. assert!(CoomerProvider::build_video_item(post).is_none()); } #[test] fn build_video_item_uses_video_attachment_when_file_is_image() { let json = r#"{ "id": "43", "user": "ruby", "service": "fansly", "title": "clip", "file": {"name": "cover.jpg", "path": "/c/cover.jpg"}, "attachments": [{"name": "a.jpg", "path": "/a/a.jpg"}, {"name": "v.MP4", "path": "/v/v.mp4"}] }"#; let post: ApiPost = serde_json::from_str(json).expect("parse"); let item = CoomerProvider::build_video_item(post).expect("item"); let formats = item.formats.expect("video attachment must yield a format"); assert_eq!(formats[0].url, "https://coomer.st/data/v/v.mp4"); assert_eq!(item.thumb, "https://coomer.st/data/c/cover.jpg"); } #[test] fn build_video_item_skips_when_id_missing() { let json = r#"{"user": "ruby", "service": "fansly"}"#; 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:?}"), } } }