357 lines
13 KiB
Rust
357 lines
13 KiB
Rust
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),
|
|
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<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())
|
|
}
|
|
}
|