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:
@@ -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
355
src/providers/hotbunny.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
52
src/proxies/hotbunnythumb.rs
Normal file
52
src/proxies/hotbunnythumb.rs
Normal 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()))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}*")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user