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, #[serde(rename = "nextCursor", default)] _next_cursor: Option, } #[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, #[serde(default)] width: u32, #[serde(default)] height: u32, #[serde(default)] galleries: Vec, } #[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, Option) { 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::(&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 = 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), ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()) } } } #[async_trait] impl Provider for HotbunnyProvider { async fn get_videos( &self, cache: VideoCache, pool: DbPool, sort: String, query: Option, page: String, per_page: String, options: ServerOptions, ) -> Vec { let _ = cache; let _ = pool; let page = page.parse::().unwrap_or(1).max(1); let per_page = per_page.parse::().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 { Some(self.build_channel()) } }