920 lines
32 KiB
Rust
920 lines
32 KiB
Rust
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<Target> {
|
|
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<String> = 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<Service> {
|
|
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
|
|
// "<service>:<user>" 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<Vec<ApiPost>> {
|
|
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::<ApiError>(&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::<String>()
|
|
))
|
|
})?;
|
|
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<Vec<VideoItem>> {
|
|
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<VideoItem> = 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<VideoItem> {
|
|
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<String> = 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<String> = video_file
|
|
.path
|
|
.as_deref()
|
|
.map(|path| format!("{}/data/{}", BASE_URL, path.trim_start_matches('/')));
|
|
let ext_hint: Option<String> = 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<u64> {
|
|
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<ApiPost>),
|
|
Wrapped(ApiPostsResponse),
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ApiPostsResponse {
|
|
#[serde(default)]
|
|
posts: Vec<ApiPost>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ApiPost {
|
|
#[serde(default)]
|
|
id: Option<String>,
|
|
#[serde(default)]
|
|
user: Option<String>,
|
|
#[serde(default)]
|
|
service: Option<String>,
|
|
#[serde(default)]
|
|
title: Option<String>,
|
|
#[serde(default)]
|
|
substring: Option<String>,
|
|
#[serde(default)]
|
|
published: Option<String>,
|
|
#[serde(default)]
|
|
file: Option<ApiFile>,
|
|
#[serde(default)]
|
|
attachments: Option<Vec<ApiFile>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ApiFile {
|
|
#[serde(default)]
|
|
name: Option<String>,
|
|
#[serde(default)]
|
|
path: Option<String>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for CoomerProvider {
|
|
async fn get_videos(
|
|
&self,
|
|
cache: VideoCache,
|
|
_pool: DbPool,
|
|
sort: String,
|
|
query: Option<String>,
|
|
page: String,
|
|
per_page: String,
|
|
options: ServerOptions,
|
|
) -> Vec<VideoItem> {
|
|
let sort_value = if sort.is_empty() {
|
|
options.sort.as_deref().unwrap_or("new").to_string()
|
|
} else {
|
|
sort
|
|
};
|
|
let page_num = page.parse::<u16>().unwrap_or(1).max(1);
|
|
let per_page_limit = per_page
|
|
.parse::<usize>()
|
|
.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<Channel> {
|
|
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("<p>Hello <b>world</b></p>"), "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": "<p>Hello there friends</p>",
|
|
"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::<ApiPostsPayload>(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::<ApiPostsPayload>(wrapped).expect("wrapped") {
|
|
ApiPostsPayload::Wrapped(w) => assert_eq!(w.posts.len(), 1),
|
|
other => panic!("expected wrapped, got {other:?}"),
|
|
}
|
|
}
|
|
}
|