Add ripnsfw provider (Doodstream/Lulustream leak aggregator)

ripnsfw.com serves its entire catalogue as a published-Google-Sheet CSV
with no native pagination/search API, so the provider fetches and
parses that CSV once (cached 180s) and does feed/search/pagination/sort
in memory. Each row's Doodstream/Lulustream embed links resolve to
formats[] via the existing (previously unused) doodstream/lulustream
redirect proxies.

Also fixes check.py's follow_proxy_redirect, which used HEAD even
though these redirect-proxy routes only accept GET/POST, so it never
actually resolved the redirect; extends the CF-protected host list
(suffix matching + ripnsfw.com's client-only-SPA 404 page, dood.video,
tnmr.org) so known sandbox/CDN-IP-reputation failures are reported as
warnings instead of errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTqf6orbHZ9rFFpVpcgzcR
This commit is contained in:
Simon
2026-09-18 19:22:49 +00:00
parent 9c078f1b60
commit 534ee4b5ad
4 changed files with 460 additions and 18 deletions

391
src/providers/ripnsfw.rs Normal file
View File

@@ -0,0 +1,391 @@
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use chrono::NaiveDate;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme,
};
use crate::status::*;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "onlyfans",
tags: &["onlyfans", "leaked", "amateur"],
};
const CHANNEL_ID: &str = "ripnsfw";
const BASE_URL: &str = "https://ripnsfw.com";
const CSV_URL: &str = "https://ripnsfw.com/data.csv";
const CDN_THUMB_BASE: &str = "https://cdn.ripnsfw.com/thumbs/";
const CATALOG_TTL: Duration = Duration::from_secs(180);
// data.csv columns (0-indexed): THUMB,NEW,NOME,DOODSTREAM,LULUSTREAM,<link3>,DOOD,
// <guests>,SITE(bunkr download mirrors),TITLE,ID,#(tags),POST(search text),<blank>,DATE,SIZE
#[derive(Debug, Clone)]
struct RipnsfwVideo {
id: String,
model_name: String,
guests: Vec<String>,
doodstream: Option<String>,
lulustream: Option<String>,
streamtape: Option<String>,
tags: Vec<String>,
thumb_file: String,
date: Option<String>,
uploaded_at: Option<u64>,
}
#[derive(Debug, Clone)]
enum Target {
Latest,
Search(String),
Tag(String),
Model(String),
}
pub struct RipnsfwProvider {
catalog: Arc<RwLock<Option<(Instant, Vec<RipnsfwVideo>)>>>,
}
impl RipnsfwProvider {
pub fn new() -> Self {
Self {
catalog: Arc::new(RwLock::new(None)),
}
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
Channel {
id: CHANNEL_ID.to_string(),
name: "RIPNSFW".to_string(),
description: "Leaked creator clips mirrored via Doodstream and Lulustream."
.to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=ripnsfw.com".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Newest or oldest leaks first.".to_string(),
systemImage: "list.number".to_string(),
colorName: "pink".to_string(),
options: vec![
FilterOption {
id: "new".to_string(),
title: "Newest".to_string(),
},
FilterOption {
id: "oldest".to_string(),
title: "Oldest".to_string(),
},
],
multiSelect: false,
}],
nsfw: true,
cacheDuration: Some(600),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn resolve_target(query: &str) -> Target {
let q = query.trim();
if let Some(value) = q
.strip_prefix("tag:")
.or_else(|| q.strip_prefix("category:"))
.or_else(|| q.strip_prefix("cat:"))
{
let value = value.trim();
if !value.is_empty() {
return Target::Tag(value.to_string());
}
}
if let Some(value) = q.strip_prefix("model:").or_else(|| q.strip_prefix("uploader:")) {
let value = value.trim();
if !value.is_empty() {
return Target::Model(value.to_string());
}
}
if !q.is_empty() {
return Target::Search(q.to_string());
}
Target::Latest
}
fn slugify(value: &str) -> String {
let mut slug = String::new();
let mut last_dash = false;
for ch in value.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_dash = false;
} else if !last_dash {
slug.push('-');
last_dash = true;
}
}
slug.trim_matches('-').to_string()
}
fn csv_split(line: &str) -> Vec<String> {
let mut cols = Vec::new();
let mut cur = String::new();
let mut in_quotes = false;
for ch in line.chars() {
match ch {
'"' => in_quotes = !in_quotes,
',' if !in_quotes => {
cols.push(cur.trim().to_string());
cur.clear();
}
_ => cur.push(ch),
}
}
cols.push(cur.trim().to_string());
cols
}
fn parse_date(raw: &str) -> Option<u64> {
let date = NaiveDate::parse_from_str(raw.trim(), "%Y-%m-%d").ok()?;
let dt = date.and_hms_opt(0, 0, 0)?;
u64::try_from(dt.and_utc().timestamp()).ok()
}
fn parse_tags(raw: &str) -> Vec<String> {
raw.split(',')
.map(|t| t.trim().trim_start_matches('#').trim().to_string())
.filter(|t| !t.is_empty())
.collect()
}
fn parse_guests(raw: &str, model_name: &str) -> Vec<String> {
let model_upper = model_name.trim().to_uppercase();
raw.split(|c| c == ',' || c == '&' || c == '/')
.map(|g| g.trim().to_string())
.filter(|g| !g.is_empty() && g.to_uppercase() != model_upper)
.collect()
}
fn parse_csv(text: &str) -> Vec<RipnsfwVideo> {
let normalized = text.replace('\r', "");
let mut out = Vec::new();
for (idx, line) in normalized.lines().enumerate() {
if idx == 0 || line.trim().is_empty() {
continue;
}
let cols = Self::csv_split(line);
if cols.len() < 16 {
continue;
}
let model_name = cols[2].trim().to_string();
if model_name.is_empty() {
continue;
}
let upper = model_name.to_uppercase();
if matches!(upper.as_str(), "NAME" | "TEST" | "MODEL" | "EMPTY") {
continue;
}
let id = cols[10].trim().to_string();
if id.is_empty() {
continue;
}
let opt = |s: &str| -> Option<String> {
let t = s.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
};
let date_raw = cols[14].trim().to_string();
out.push(RipnsfwVideo {
id,
guests: Self::parse_guests(&cols[7], &model_name),
doodstream: opt(&cols[3]),
lulustream: opt(&cols[4]),
streamtape: opt(&cols[5]),
tags: Self::parse_tags(&cols[11]),
thumb_file: cols[0].trim().to_string(),
uploaded_at: Self::parse_date(&date_raw),
date: if date_raw.is_empty() { None } else { Some(date_raw) },
model_name,
});
}
out
}
fn request_headers(&self) -> Vec<(String, String)> {
vec![
("accept".to_string(), "text/csv,*/*;q=0.8".to_string()),
(
"user-agent".to_string(),
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
.to_string(),
),
("referer".to_string(), format!("{BASE_URL}/")),
]
}
async fn fetch_catalog(&self, options: &ServerOptions) -> Vec<RipnsfwVideo> {
if let Ok(guard) = self.catalog.read() {
if let Some((fetched_at, rows)) = guard.as_ref() {
if fetched_at.elapsed() < CATALOG_TTL {
return rows.clone();
}
}
}
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_catalog");
match requester
.get_with_headers(CSV_URL, self.request_headers(), None)
.await
{
Ok(text) => {
let rows = Self::parse_csv(&text);
if let Ok(mut guard) = self.catalog.write() {
*guard = Some((Instant::now(), rows.clone()));
}
rows
}
Err(error) => {
report_provider_error(CHANNEL_ID, "fetch_catalog", &error.to_string()).await;
if let Ok(guard) = self.catalog.read() {
if let Some((_, rows)) = guard.as_ref() {
return rows.clone();
}
}
vec![]
}
}
}
fn matches_target(video: &RipnsfwVideo, target: &Target) -> bool {
match target {
Target::Latest => true,
Target::Tag(tag) => {
let needle = tag.trim().to_lowercase();
video.tags.iter().any(|t| t.to_lowercase() == needle)
}
Target::Model(name) => {
let needle_slug = Self::slugify(name);
video.model_name.trim().to_lowercase() == name.trim().to_lowercase()
|| Self::slugify(&video.model_name) == needle_slug
}
Target::Search(query) => {
let needle = query.trim().to_lowercase();
video.model_name.to_lowercase().contains(&needle)
|| video
.guests
.iter()
.any(|g| g.to_lowercase().contains(&needle))
|| video.tags.iter().any(|t| t.to_lowercase().contains(&needle))
}
}
}
fn build_item(&self, options: &ServerOptions, video: &RipnsfwVideo) -> VideoItem {
let slug = Self::slugify(&video.model_name);
let page_url = format!("{BASE_URL}/model/{slug}/{}", video.id);
let thumb = format!(
"{CDN_THUMB_BASE}{}",
utf8_percent_encode(&video.thumb_file, NON_ALPHANUMERIC)
);
let title = if video.guests.is_empty() {
video.model_name.trim().to_string()
} else {
format!("{} & {}", video.model_name.trim(), video.guests.join(", "))
};
let mut item = VideoItem::new(
video.id.clone(),
title,
page_url,
CHANNEL_ID.to_string(),
thumb,
0,
);
item = item.uploader(video.model_name.trim().to_string());
item = item.uploader_url(format!("{BASE_URL}/model/{slug}"));
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
if !video.tags.is_empty() {
item = item.tags(video.tags.clone());
}
if let Some(ts) = video.uploaded_at {
item = item.uploaded_at(ts);
}
let mut formats = Vec::new();
if let Some(url) = &video.lulustream {
let proxy_url = build_proxy_url(options, "lulustream", &strip_url_scheme(url));
formats.push(
VideoFormat::m3u8(proxy_url, "high".to_string(), "hls".to_string())
.format_note("Lulustream".to_string()),
);
}
if let Some(url) = &video.doodstream {
let proxy_url = build_proxy_url(options, "doodstream", &strip_url_scheme(url));
formats.push(
VideoFormat::new(proxy_url, "high".to_string(), "mp4".to_string())
.format_note("Doodstream".to_string()),
);
}
// Streamtape rows are rare in this feed and have no local resolver proxy
// wired up yet, so they are intentionally not surfaced as a format.
let _ = &video.streamtape;
if !formats.is_empty() {
item = item.formats(formats);
}
item
}
}
#[async_trait]
impl Provider for RipnsfwProvider {
async fn get_videos(
&self,
_cache: crate::util::cache::VideoCache,
_pool: DbPool,
sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page_num = page.parse::<usize>().unwrap_or(1).max(1);
let per_page_num = per_page.parse::<usize>().unwrap_or(24).clamp(1, 60);
let sort_value = if sort.trim().is_empty() {
options.sort.clone().unwrap_or_else(|| "new".to_string())
} else {
sort
};
let query_value = query.unwrap_or_default();
let target = Self::resolve_target(&query_value);
let mut rows = self.fetch_catalog(&options).await;
rows.retain(|video| Self::matches_target(video, &target));
rows.sort_by(|a, b| b.date.cmp(&a.date));
if sort_value == "oldest" {
rows.reverse();
}
let start = (page_num - 1) * per_page_num;
rows.into_iter()
.skip(start)
.take(per_page_num)
.map(|video| self.build_item(&options, &video))
.collect()
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}