add hotbunny provider + fix domain cookie scoping in requester

New channel: hotbunny (AI-generated hentai, hotbunny.ai JSON API).
Includes thumbnail proxy (/proxy/hotbunny-thumb/) since assets.hotbunny.ai
is CF bot-managed. check.py updated to treat CF-protected format URLs as
warnings rather than errors.

requester.rs: store_response_cookies now honours the Domain attribute in
Set-Cookie headers — cookies scoped to .domain.com are registered against
the parent domain so the wreq Jar returns them for sub.domain.com requests
automatically, without per-provider workarounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon
2026-06-24 18:40:57 +00:00
parent 8373d49593
commit 6d5771a038
9 changed files with 464 additions and 10 deletions

View File

@@ -660,7 +660,7 @@ async fn videos_post(
video_items.len()
);
for video in video_items.iter_mut() {
video.id = format!("{}:{}", channel, video.id);
}

355
src/providers/hotbunny.rs Normal file
View File

@@ -0,0 +1,355 @@
use async_trait::async_trait;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::Deserialize;
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::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "ai",
tags: &["ai", "hentai", "generated", "anime", "community"],
};
const CHANNEL_ID: &str = "hotbunny";
const BASE_URL: &str = "https://hotbunny.ai";
const CDN_BASE: &str = "https://assets.hotbunny.ai";
const REFERER: &str = "https://hotbunny.ai/";
// Fetch at most this many posts per call so pagination stays cheap.
const MAX_FETCH: usize = 200;
// Category label → API CUID, hardcoded from GET /api/categories (stable IDs).
const CATEGORIES: &[(&str, &str)] = &[
("cmiys6yak0b1dpb0q3a9ijs8z", "Anal"),
("cmir7ft9r00bonq0ryhbjj0zf", "BDSM"),
("cmiyslxmt0b1gpb0qjbjiu1my", "Big Breasts"),
("cmiyszx1l0b1kpb0qxk71aql9", "Blondes"),
("cmiysraox0b1ipb0q4plrax1b", "Exhibitionism"),
("cmiyt45ce0b1mpb0qgb2s3q4c", "Feet"),
("cmiysyf0q0b1jpb0q6f41m9l2", "Furry"),
("cmiysfqo10b1epb0qczzjshi7", "Futanari"),
("cmiydl9et0000om0q9768i2ta", "Gay"),
("cmiysoegl0b1hpb0qd9vzsn7q", "Group"),
("cmiys0rlx0b19pb0q2f7iis14", "Hardcore"),
("cmiys3wix0b1bpb0qsmiud4bu", "Lesbian"),
("cmiye8qa40001om0qasn0d2iu", "Monsters"),
("cmiys1rcy0b1apb0qfb4lx9ib", "Oral"),
("cmiyepzos0000pb0qx74evwyx", "Soft"),
];
// ---------------------------------------------------------------------------
// API response types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct FeedResponse {
posts: Vec<HotbunnyPost>,
#[serde(rename = "nextCursor", default)]
_next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct HotbunnyPost {
id: String,
#[serde(rename = "imageUrl", default)]
image_url: String,
#[serde(rename = "authorName", default)]
author_name: String,
#[serde(default)]
likes: u64,
#[serde(rename = "sharedVideos", default)]
shared_videos: Vec<SharedVideo>,
#[serde(default)]
width: u32,
#[serde(default)]
height: u32,
#[serde(default)]
galleries: Vec<Gallery>,
}
#[derive(Debug, Deserialize)]
struct SharedVideo {
#[serde(rename = "videoUrl", default)]
video_url: String,
}
#[derive(Debug, Deserialize)]
struct Gallery {
#[serde(default)]
label: String,
}
// ---------------------------------------------------------------------------
// Target routing
// ---------------------------------------------------------------------------
enum Target {
Hot,
Popular,
Recent,
Category(String),
Search(String),
Author(String),
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
pub struct HotbunnyProvider;
impl HotbunnyProvider {
pub fn new() -> Self {
HotbunnyProvider
}
fn resolve_target(sort: &str, query: Option<&str>, options: &ServerOptions) -> Target {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
let lower = q.to_ascii_lowercase();
// uploader:/author: prefix
let suffix_after_colon = |prefix: &str| -> Option<&str> {
lower.strip_prefix(prefix).map(|_| {
let colon = q.find(':').unwrap_or(0);
q[colon + 1..].trim()
})
};
if suffix_after_colon("uploader:").is_some() || suffix_after_colon("author:").is_some() {
let colon = q.find(':').unwrap_or(0);
return Target::Author(q[colon + 1..].trim().to_string());
}
// cat:/category: prefix or #tag
let keyword = if lower.starts_with("cat:") || lower.starts_with("category:") {
let colon = q.find(':').unwrap_or(0);
q[colon + 1..].trim()
} else {
q.trim_start_matches('#')
};
// Match keyword against a known category label
if let Some((cat_id, _)) = CATEGORIES
.iter()
.find(|(_, label)| label.eq_ignore_ascii_case(keyword))
{
return Target::Category(cat_id.to_string());
}
return Target::Search(keyword.to_string());
}
// categories filter option
if let Some(cat) = options.categories.as_deref().filter(|c| *c != "all" && !c.trim().is_empty()) {
if let Some((cat_id, _)) = CATEGORIES
.iter()
.find(|(_, label)| label.eq_ignore_ascii_case(cat))
{
return Target::Category(cat_id.to_string());
}
}
match sort {
"popular" => Target::Popular,
"recent" | "new" => Target::Recent,
_ => Target::Hot,
}
}
fn api_url(target: &Target, limit: usize) -> String {
match target {
Target::Hot => format!("{BASE_URL}/api/post/feed?limit={limit}"),
Target::Popular => format!("{BASE_URL}/api/post/feed/popular?limit={limit}"),
Target::Recent => format!("{BASE_URL}/api/post/feed/recents?limit={limit}"),
Target::Category(id) => format!("{BASE_URL}/api/post/category/{id}?limit={limit}"),
Target::Search(q) => {
let encoded = utf8_percent_encode(q, NON_ALPHANUMERIC).to_string();
format!("{BASE_URL}/api/post/search?query={encoded}&limit={limit}")
}
Target::Author(name) => {
let encoded = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string();
format!("{BASE_URL}/api/post/author/{encoded}/all?limit={limit}")
}
}
}
// Returns (posts, cdn_cookie) — cdn_cookie is the img_auth JWT the API
// sets via Set-Cookie for Domain=.hotbunny.ai; clients need it in the
// Cookie header when fetching from assets.hotbunny.ai directly.
async fn fetch_posts(&self, url: &str, options: &ServerOptions) -> (Vec<HotbunnyPost>, Option<String>) {
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_posts");
let headers = vec![
("Referer".to_string(), REFERER.to_string()),
("Accept".to_string(), "application/json".to_string()),
];
let text = match requester.get_with_headers(url, headers, None).await {
Ok(t) => t,
Err(e) => {
report_provider_error(CHANNEL_ID, "fetch_posts", &format!("{url}: {e}")).await;
return (vec![], None);
}
};
// The domain cookie fix in store_response_cookies now scopes img_auth to
// .hotbunny.ai, so this lookup also covers assets.hotbunny.ai.
let cdn_cookie = requester.cookie_header_for_url(CDN_BASE);
match serde_json::from_str::<FeedResponse>(&text) {
Ok(resp) => (resp.posts, cdn_cookie),
Err(e) => {
report_provider_error(CHANNEL_ID, "fetch_posts", &format!("parse: {e}")).await;
(vec![], cdn_cookie)
}
}
}
fn build_item(post: HotbunnyPost, options: &ServerOptions, cdn_cookie: Option<&str>) -> VideoItem {
let title = match post.galleries.first().map(|g| g.label.trim()) {
Some(label) if !label.is_empty() => format!("{label} by {}", post.author_name),
_ => format!("by {}", post.author_name),
};
let page_url = format!("{BASE_URL}/post/{}", post.id);
let thumb = if !post.image_url.is_empty() {
let cdn_path = strip_url_scheme(&format!("{CDN_BASE}/{}", post.image_url));
build_proxy_url(options, "hotbunny-thumb", &cdn_path)
} else {
String::new()
};
let mut item = VideoItem::new(
post.id,
title,
page_url,
CHANNEL_ID.to_string(),
thumb,
0,
);
if post.likes > 0 {
item.views = Some(post.likes as u32);
}
if post.width > 0 && post.height > 0 {
item.aspectRatio = Some(post.width as f32 / post.height as f32);
}
if !post.author_name.is_empty() {
let slug = post.author_name.to_ascii_lowercase();
item.uploader = Some(post.author_name.clone());
item.uploaderUrl = Some(format!("{BASE_URL}/u/{slug}"));
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
}
// First shared video becomes a playable format
if let Some(sv) = post.shared_videos.into_iter().find(|v| !v.video_url.is_empty()) {
let cdn_url = format!("{CDN_BASE}/{}", sv.video_url);
let mut fmt = VideoFormat::new(cdn_url, "auto".to_string(), "mp4".to_string());
fmt.add_http_header("Referer".to_string(), REFERER.to_string());
if let Some(cookie) = cdn_cookie {
fmt.add_http_header("Cookie".to_string(), cookie.to_string());
}
item.formats = Some(vec![fmt]);
}
item
}
fn build_channel(&self) -> Channel {
let sort_options = vec![
FilterOption { id: "feed".to_string(), title: "Hot".to_string() },
FilterOption { id: "popular".to_string(), title: "Most Liked".to_string() },
FilterOption { id: "recent".to_string(), title: "Recent".to_string() },
];
let category_options: Vec<FilterOption> = CATEGORIES
.iter()
.map(|(_, label)| FilterOption {
id: label.to_string(),
title: label.to_string(),
})
.collect();
Channel {
id: CHANNEL_ID.to_string(),
name: "HotBunny".to_string(),
description: "AI-generated hentai images and short clips from HotBunny.ai community creators.".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hotbunny.ai".to_string(),
status: "active".to_string(),
categories: CATEGORIES.iter().map(|(_, l)| l.to_string()).collect(),
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Feed ordering".to_string(),
systemImage: "arrow.up.arrow.down".to_string(),
colorName: "blue".to_string(),
options: sort_options,
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Category".to_string(),
description: "Browse by content category".to_string(),
systemImage: "tag".to_string(),
colorName: "orange".to_string(),
options: category_options,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
}
}
}
#[async_trait]
impl Provider for HotbunnyProvider {
async fn get_videos(
&self,
cache: VideoCache,
pool: DbPool,
sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let _ = cache;
let _ = pool;
let page = page.parse::<usize>().unwrap_or(1).max(1);
let per_page = per_page.parse::<usize>().unwrap_or(20).clamp(1, 50);
let total_limit = (page * per_page).min(MAX_FETCH);
let normalized = query.as_deref().map(str::trim).filter(|q| !q.is_empty());
let target = Self::resolve_target(&sort, normalized, &options);
let url = Self::api_url(&target, total_limit);
let (posts, cdn_cookie) = self.fetch_posts(&url, &options).await;
if posts.is_empty() {
return vec![];
}
let start = (page - 1) * per_page;
if start >= posts.len() {
return vec![];
}
posts
.into_iter()
.skip(start)
.take(per_page)
.map(|p| Self::build_item(p, &options, cdn_cookie.as_deref()))
.collect()
}
fn get_channel(&self, _cv: ClientVersion) -> Option<Channel> {
Some(self.build_channel())
}
}

View File

@@ -0,0 +1,52 @@
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use ntex::{
http::Response,
web::{self, HttpRequest, error},
};
use crate::util::requester::Requester;
const REFERER: &str = "https://hotbunny.ai/";
pub async fn get_image(
req: HttpRequest,
requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let endpoint = req.match_info().query("endpoint").trim_start_matches('/');
let image_url = if endpoint.starts_with("https://") || endpoint.starts_with("http://") {
endpoint.to_string()
} else {
format!("https://{endpoint}")
};
let upstream = match requester
.get_ref()
.clone()
.get_raw_with_headers(
&image_url,
vec![("Referer".to_string(), REFERER.to_string())],
)
.await
{
Ok(r) => r,
Err(_) => return Ok(web::HttpResponse::NotFound().finish()),
};
let status = upstream.status();
let headers = upstream.headers().clone();
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
let mut resp = Response::build(status);
if let Some(ct) = headers.get(CONTENT_TYPE) {
if let Ok(s) = ct.to_str() {
resp.set_header(CONTENT_TYPE, s);
}
}
if let Some(cl) = headers.get(CONTENT_LENGTH) {
if let Ok(s) = cl.to_str() {
resp.set_header(CONTENT_LENGTH, s);
}
}
Ok(resp.body(bytes.to_vec()))
}

View File

@@ -42,6 +42,7 @@ pub mod spankbang;
pub mod supjav;
pub mod sxyprn;
pub mod thaiporntv;
pub mod hotbunnythumb;
pub mod jable;
pub mod tube8;
pub mod thepornbunny;

View File

@@ -142,6 +142,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
web::resource("/porndish-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::porndishthumb::get_image))
.route(web::get().to(crate::proxies::porndishthumb::get_image)),
)
.service(
web::resource("/hotbunny-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::hotbunnythumb::get_image))
.route(web::get().to(crate::proxies::hotbunnythumb::get_image)),
);
cfg.service(
web::resource("/proxy/pornhub-thumb/{endpoint}*")

View File

@@ -61,15 +61,40 @@ impl Requester {
}
fn store_response_cookies(&self, url: &str, response: &Response) {
let Some(origin) = Self::origin_url_for_cookie_scope(url) else {
let Some(default_origin) = Self::origin_url_for_cookie_scope(url) else {
return;
};
for value in response.headers().get_all(SET_COOKIE).iter() {
if let Ok(cookie) = value.to_str() {
self.cookie_jar.add_cookie_str(cookie, &origin);
let Ok(cookie) = value.to_str() else {
continue;
};
// Honour the Domain attribute: if Set-Cookie specifies Domain=.foo.com,
// register the cookie against https://foo.com/ so the Jar returns it for
// sub.foo.com requests as well.
let origin = Self::cookie_domain_origin(cookie, &default_origin);
self.cookie_jar.add_cookie_str(cookie, &origin);
}
}
/// Derive the registration origin for a cookie string.
/// If the string contains `Domain=<d>`, strips the leading dot and builds a
/// URL from `<d>` so the jar scopes the cookie to the whole domain tree.
/// Falls back to `default` when no Domain attribute is present.
fn cookie_domain_origin(cookie_str: &str, default: &url::Url) -> url::Url {
for attr in cookie_str.split(';').skip(1) {
let attr = attr.trim();
if attr.len() >= 7 && attr[..7].eq_ignore_ascii_case("domain=") {
let domain = attr[7..].trim().trim_start_matches('.');
if !domain.is_empty() {
let scheme = default.scheme();
if let Ok(u) = url::Url::parse(&format!("{scheme}://{domain}/")) {
return u;
}
}
}
}
default.clone()
}
fn store_flaresolverr_cookies(