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:
5
build.rs
5
build.rs
@@ -391,6 +391,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
||||
module: "kwiky",
|
||||
ty: "KwikyProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "hotbunny",
|
||||
module: "hotbunny",
|
||||
ty: "HotbunnyProvider",
|
||||
},
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
9
check.py
9
check.py
@@ -69,6 +69,7 @@ _BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/
|
||||
_CF_PROTECTED_HOSTS = {
|
||||
"www.camsoda.com",
|
||||
"camsoda.com",
|
||||
"assets.hotbunny.ai",
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +278,14 @@ def check_video(video: dict, channel_id: str, results: Results, run_ytdlp: bool)
|
||||
continue
|
||||
ok, code = http_ok(furl, headers=fheaders)
|
||||
if not ok:
|
||||
if _is_cf_protected(furl):
|
||||
results.warn(
|
||||
channel_id,
|
||||
f"{label} format[{j}]: unreachable HTTP={code} (CF-protected host, expected)"
|
||||
+ (f" (headers={list(fheaders.keys())})" if fheaders else "")
|
||||
+ f": {furl}",
|
||||
)
|
||||
else:
|
||||
results.err(
|
||||
channel_id,
|
||||
f"{label} format[{j}]: unreachable HTTP={code}"
|
||||
|
||||
@@ -24,6 +24,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
||||
| `hentaihaven` | `hentai-animation` | no | no | HTML scraper for hentaihaven.xxx (WordPress/Madara theme), Cloudflare-protected so the provider is gated behind `FLARE_URL` in `skip_reason_for_provider` (mod.rs); the shared requester clears CF directly (wreq Firefox136 emulation currently passes for the listing/search/watch/episode/`player.php` GETs) and falls back to Jina/FlareSolverr. Latest feed `/hentai/page/{N}/`, search `/?s={query}` (search is single-page — page>1 returns empty); listing/search cards link to series watch pages `https://hentaihaven.xxx/watch/{slug}/`. Per-series media resolution (the UUID exists nowhere in page HTML, so enrichment is unavoidable): watch page → episode links `…/watch/{slug}/episode-K` (in `manga-chapters-holder`) → episode page → `<iframe src="…/wp-content/plugins/player-logic/player.php?data=…">` → `player.php` → `<meta name="x-secure-token" content="sha512-…">` → decode token (strip `sha512-`, then 3× of rot13→base64-decode, then `JSON.parse`) → `{en, iv, uri, hot_domains, …}` → POST `…/wp-content/plugins/player-logic/api.php` with `action=zarat_get_data_player_ajax&a={en}&b={iv}` (urlencoded; this one POST uses a dedicated `wreq` Chrome137 client, not the shared requester) → `{"status":true,"data":{"sources":[{"src":"…m3u8"}],"isOctopus":bool}}`. A multi-episode series collapses into one `VideoItem` titled `"… (N Episodes)"` with one `m3u8` `VideoFormat` per episode (`format_note`/`format_id` = "Episode K"); each format carries `Referer`/`Origin: https://hentaihaven.xxx` + a Firefox `User-Agent`. `video.url` is the `watch/{slug}/` page (no yt-dlp extractor exists for the site, so `formats` are populated rather than relying on `video.url`). Two CDN shapes are returned: newer content-addressed `octopusmanifest.org/{uuid}/playlist.m3u8` (no token, portable across IPs) and older signed `master-lengs.org/api/v3/hh/{slug}/master.m3u8?hash=…` (~2.5h). **Gotcha:** both CDNs (same IP) aggressively per-IP rate-limit/ban with a TCP RST on 80/443 once tripped — looks like "host down" but is an IP ban; browsers play fine over HTTP/3 (QUIC) while TCP clients (curl/yt-dlp/wreq) get refused, so segment fetches can fail from a tripped IP even though the manifest URL is valid. Tags from the series "Genre(s)" block; `views` from the "Viewed … Total" counter; thumbnails (`img.hentaihaven.xxx`) load directly (no proxy/referer). Resolution is slow (each listing page = ~25 series × multi-episode player-API calls), so the provider is DB-first: it fetches the listing once for the ordered watch URLs, serves already-resolved `VideoItem`s from the `videos` SQLite table instantly (`db::upsert_video` to avoid duplicate-row staleness), and `spawn_refresh`es the whole page in the background (in-memory `VideoCache` soft-TTL 1h / hard-TTL 24h, per-listing in-flight guard). No `/api/uploaders` (no uploader identity), no proxy. |
|
||||
| `hentaitv` | `hentai-animation` | no | yes | Next.js hentai site (hentai.tv) backed by a clean JSON API: `GET /api/browse?page=N&sort=<Label>&genres=<ExactName>` (`{videos:[28],total,pages}`, real pagination) and `GET /api/search?q=Q` (`{videos:[...]}`, single-page — `page` is ignored, so page>1 returns empty). Unlike `animeidhentai`, browse honors both `sort` (labels `Most Recent`/`Most Viewed`/`Trending`, mapped from option ids `new`/`views`/`trending`) and `genres` (the **exact case-sensitive** stored genre name, e.g. `Big Boobs`, `incest`), so genre archives go through `/api/browse?genres=` and paginate. The 68-genre catalogue (exact names) is background-loaded from the `/browse` page HTML (`"genres":[{"name","count"}]`, not exposed by the JSON API) and powers the `categories` filter plus keyword→genre routing. Each episode JSON has `slug`, `title`/`ep`, `tags[]`, `views`, `rating` (0-10 → ×10), `duration` ("MM:SS"), `brand` (studio → `uploader`), `thumb`/`backdrop`/`cover` (relative, served from `hentai.tv/uploads/...`, no referer), and `embedUrl=https://nhplayer.com/v/{embedId}/`. `video.url` is the reachable watch page `https://hentai.tv/hentai/{slug}`; `genre:`/`cat:`/`category:` prefixes and bare keywords that exactly match a genre route to the genre archive, everything else to search. Playback shares the **same nhplayer→`r2.1hanime.com` signed-CDN backend as `animeidhentai`**: `/proxy/hentaitv/{embedId}.mp4` is a redirect proxy that replicates nhplayer's PoW+DOM challenge (`player.php`→`player-core-v2.php`→`get-video-url-v2.php`, SHA-256-first-byte-zero PoW, ≥700ms dwell, fixed fingerprint) to mint a signed `?verify=<ts>-<sig>` URL — HEAD→200, GET→302 to the CDN URL (cached 150s). The CF wall is JA3-based not IP-based, so the signed URL is verifiable from anywhere with `yt-dlp --impersonate chrome` even though plain `curl`/`wreq` get 403. `src/proxies/hentaitv.rs` is a near-copy of `src/proxies/animeidhentai.rs` (only `SITE_REFERER` differs). No `/api/uploaders` (brand is studio-only). |
|
||||
| `homoxxx` | `gay-male` | no | no | Gay category grouping example. |
|
||||
| `hotbunny` | `ai` | no | yes (thumbs only) | JSON-API provider for hotbunny.ai (AI-generated hentai community). Feeds: `GET /api/post/feed?limit=N` (hot), `/api/post/feed/popular`, `/api/post/feed/recents`, `/api/post/category/{cuid}?limit=N`, `/api/post/search?query=Q&limit=N`, `/api/post/author/{name}/all?limit=N`. Pagination: fetch `page × per_page` items in one call and slice (cursor-based API, but limit works up to 200 total). Key fields: `id`, `imageUrl` (relative thumbnail path on CDN), `authorName`, `likes` (→ `views`), `sharedVideos[].videoUrl` (first video path, relative to CDN), `width`/`height`, `galleries[].label` (used as title prefix). 15 hardcoded categories from `/api/categories` (stable CUIDs). `video.url` is the post page `https://hotbunny.ai/post/{id}` (React SPA, not yt-dlp-resolvable); `formats[0].url` = `https://assets.hotbunny.ai/{sharedVideos[0].videoUrl}` with `Referer: https://hotbunny.ai/` header. `assets.hotbunny.ai` is Cloudflare bot-management protected (returns 404 to plain curl/yt-dlp; iOS AVFoundation passes with real Apple TLS — no proxy wrapping needed for video). Thumbnails proxied via `/proxy/hotbunny-thumb/...` to inject the required `Referer`. Supports `uploader:`/`author:`, `cat:`/`category:`/`#tag` query shortcuts, `categories` filter option, and `sort` option (feed/popular/recent). |
|
||||
| `hqporner` | `studio-network` | no | yes | Uses thumb and redirect proxy helpers. |
|
||||
| `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. |
|
||||
| `hypnotube` | `fetish-kink` | no | no | Fetish/tube hybrid. |
|
||||
@@ -117,6 +118,7 @@ These return binary media or images, sometimes rewriting manifests or forwarding
|
||||
- `/proxy/fikfap-thumb/{endpoint}*`
|
||||
- `/proxy/hqporner-thumb/{endpoint}*`
|
||||
- `/proxy/porndish-thumb/{endpoint}*`
|
||||
- `/proxy/hotbunny-thumb/{endpoint}*`
|
||||
- `/proxy/pornhub-thumb/{endpoint}*`
|
||||
|
||||
## Best Copy Sources By Problem
|
||||
|
||||
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() {
|
||||
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