fikfap
This commit is contained in:
5
build.rs
5
build.rs
@@ -286,6 +286,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
|||||||
module: "hentaihaven",
|
module: "hentaihaven",
|
||||||
ty: "HentaihavenProvider",
|
ty: "HentaihavenProvider",
|
||||||
},
|
},
|
||||||
|
ProviderDef {
|
||||||
|
id: "fikfap",
|
||||||
|
module: "fikfap",
|
||||||
|
ty: "FikfapProvider",
|
||||||
|
},
|
||||||
ProviderDef {
|
ProviderDef {
|
||||||
id: "chaturbate",
|
id: "chaturbate",
|
||||||
module: "chaturbate",
|
module: "chaturbate",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
|||||||
| `chaturbate` | `live-cams` | no | no | Live cam channel. |
|
| `chaturbate` | `live-cams` | no | no | Live cam channel. |
|
||||||
| `clapdat` | `amateur-homemade` | no | yes | Svelte/JSON-hydrated provider using home/recent/trending routes, Meilisearch keyword search, and `/proxy/clapdat/...` redirect playback resolution. |
|
| `clapdat` | `amateur-homemade` | no | yes | Svelte/JSON-hydrated provider using home/recent/trending routes, Meilisearch keyword search, and `/proxy/clapdat/...` redirect playback resolution. |
|
||||||
| `erome` | `amateur-homemade` | no | no | HTML album scraper with hot/new feeds, keyword search, and uploader-slug shortcuts (`uploader:<name>`). |
|
| `erome` | `amateur-homemade` | no | no | HTML album scraper with hot/new feeds, keyword search, and uploader-slug shortcuts (`uploader:<name>`). |
|
||||||
|
| `fikfap` | `tiktok` | yes | yes | JSON-API provider for fikfap.com (TikTok-style swipe short clips); anonymous auth via a client-generated `Authorization-Anonymous` UUID header (no real login needed); listing via `GET api.fikfap.com/posts?sort=new\|trending\|random&amount=N&afterId=<lastPostId>` (cursor pagination — page N costs N sequential requests); search via `GET search?q=` (single fixed-size batch, no pagination — page 2+ returns empty); hashtag feeds via `GET hashtags/label/{label}/posts` and creator feeds via `GET profile/username/{user}/posts`, both also cursor-paginated; `tag:`/`hashtag:`/`#` and `user:`/`uploader:` query prefixes route directly; `categories` option exposes a small curated static hashtag list (no full catalog endpoint exists anonymously); `video.url` is the `fikfap.com/post/{id}` page (a client-rendered SPA, not yt-dlp-resolvable on its own); `formats[0].url` is a directly playable signed Bunny CDN HLS `.m3u8` with a `Referer: https://fikfap.com/` HTTP header (required, ~24h token expiry); thumbnails are also signed Bunny CDN URLs requiring the same Referer, so they're proxied via `/proxy/fikfap-thumb/...`; `get_uploader` implemented (`fikfap:<username>` IDs) using `GET profile/username/{user}`. |
|
||||||
| `freepornvideosxxx` | `studio-network` | no | no | Studio-style scraper. |
|
| `freepornvideosxxx` | `studio-network` | no | no | Studio-style scraper. |
|
||||||
| `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. |
|
| `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. |
|
||||||
| `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. |
|
| `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. |
|
||||||
@@ -106,6 +107,7 @@ These return binary media or images, sometimes rewriting manifests or forwarding
|
|||||||
- `/proxy/noodlemagazine/{endpoint}*`
|
- `/proxy/noodlemagazine/{endpoint}*`
|
||||||
- `/proxy/noodlemagazine-thumb/{endpoint}*`
|
- `/proxy/noodlemagazine-thumb/{endpoint}*`
|
||||||
- `/proxy/hanime-cdn/{endpoint}*`
|
- `/proxy/hanime-cdn/{endpoint}*`
|
||||||
|
- `/proxy/fikfap-thumb/{endpoint}*`
|
||||||
- `/proxy/hqporner-thumb/{endpoint}*`
|
- `/proxy/hqporner-thumb/{endpoint}*`
|
||||||
- `/proxy/porndish-thumb/{endpoint}*`
|
- `/proxy/porndish-thumb/{endpoint}*`
|
||||||
- `/proxy/pornhub-thumb/{endpoint}*`
|
- `/proxy/pornhub-thumb/{endpoint}*`
|
||||||
|
|||||||
743
src/providers/fikfap.rs
Normal file
743
src/providers/fikfap.rs
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
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::uploaders::{UploaderChannelStat, UploaderLayoutRow, UploaderProfile, UploaderVideoRef};
|
||||||
|
use crate::util::cache::VideoCache;
|
||||||
|
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::DateTime;
|
||||||
|
use error_chain::error_chain;
|
||||||
|
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||||
|
crate::providers::ProviderChannelMetadata {
|
||||||
|
group_id: "tiktok",
|
||||||
|
tags: &["shortform", "onlyfans", "swipe", "amateur"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://fikfap.com";
|
||||||
|
const API_BASE: &str = "https://api.fikfap.com";
|
||||||
|
const CHANNEL_ID: &str = "fikfap";
|
||||||
|
const DEFAULT_PER_PAGE: usize = 20;
|
||||||
|
const MAX_PER_PAGE: usize = 40;
|
||||||
|
// FikFap pagination is cursor-based (`afterId`), not page-number based, so reaching
|
||||||
|
// page N requires walking N sequential requests from the start of the feed.
|
||||||
|
const MAX_PAGE_WALK: u16 = 25;
|
||||||
|
|
||||||
|
// A small curated set of well-known FikFap hashtags. The site exposes a hashtag
|
||||||
|
// catalog only through a randomized "discover" sample, so there is no stable full
|
||||||
|
// catalog to background-load; any hashtag label works directly as a routing target.
|
||||||
|
const CURATED_HASHTAGS: &[&str] = &[
|
||||||
|
"anal",
|
||||||
|
"ass",
|
||||||
|
"milf",
|
||||||
|
"threesome",
|
||||||
|
"blonde",
|
||||||
|
"brunette",
|
||||||
|
"redhead",
|
||||||
|
"natural",
|
||||||
|
"hardcore",
|
||||||
|
"lingerie",
|
||||||
|
"masturbation",
|
||||||
|
"cumshot",
|
||||||
|
"squirting",
|
||||||
|
"creampie",
|
||||||
|
"bbc",
|
||||||
|
"gonewild",
|
||||||
|
"blowjob",
|
||||||
|
"doggystyle",
|
||||||
|
"lesbian",
|
||||||
|
"deepthroat",
|
||||||
|
];
|
||||||
|
|
||||||
|
error_chain! {
|
||||||
|
foreign_links {
|
||||||
|
Json(serde_json::Error);
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
Request(msg: String) {
|
||||||
|
description("request error")
|
||||||
|
display("request error: {}", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FeedSort {
|
||||||
|
New,
|
||||||
|
Trending,
|
||||||
|
Random,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedSort {
|
||||||
|
fn api_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FeedSort::New => "new",
|
||||||
|
FeedSort::Trending => "trending",
|
||||||
|
FeedSort::Random => "random",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_sort_id(value: &str) -> Self {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"trending" | "hot" => FeedSort::Trending,
|
||||||
|
"random" | "foryou" | "for_you" => FeedSort::Random,
|
||||||
|
_ => FeedSort::New,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Target {
|
||||||
|
Feed(FeedSort),
|
||||||
|
Hashtag(String),
|
||||||
|
User(String),
|
||||||
|
Search(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Post {
|
||||||
|
post_id: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
views_count: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
duration: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
video_stream_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
thumbnail_stream_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
published_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
created_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
author: Author,
|
||||||
|
#[serde(default)]
|
||||||
|
hashtags: Vec<HashtagEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Author {
|
||||||
|
#[serde(default)]
|
||||||
|
username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
is_verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
struct HashtagEntry {
|
||||||
|
#[serde(default)]
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
struct SearchResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
posts: Vec<Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct UserProfile {
|
||||||
|
#[serde(default)]
|
||||||
|
username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
count_posts: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
count_total_views: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
is_verified: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
thumbnail_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FikfapProvider {
|
||||||
|
anon_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FikfapProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
anon_id: Self::generate_anon_id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_anon_id() -> String {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
rand::fill(&mut bytes);
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
format!(
|
||||||
|
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||||
|
bytes[0],
|
||||||
|
bytes[1],
|
||||||
|
bytes[2],
|
||||||
|
bytes[3],
|
||||||
|
bytes[4],
|
||||||
|
bytes[5],
|
||||||
|
bytes[6],
|
||||||
|
bytes[7],
|
||||||
|
bytes[8],
|
||||||
|
bytes[9],
|
||||||
|
bytes[10],
|
||||||
|
bytes[11],
|
||||||
|
bytes[12],
|
||||||
|
bytes[13],
|
||||||
|
bytes[14],
|
||||||
|
bytes[15]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anon_headers(&self) -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
("Referer".to_string(), format!("{BASE_URL}/")),
|
||||||
|
("Authorization-Anonymous".to_string(), self.anon_id.clone()),
|
||||||
|
("IsLoggedIn".to_string(), "false".to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||||
|
let category_titles = CURATED_HASHTAGS
|
||||||
|
.iter()
|
||||||
|
.map(|label| Self::title_case(label))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let category_options = CURATED_HASHTAGS
|
||||||
|
.iter()
|
||||||
|
.map(|label| FilterOption {
|
||||||
|
id: label.to_string(),
|
||||||
|
title: Self::title_case(label),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Channel {
|
||||||
|
id: CHANNEL_ID.to_string(),
|
||||||
|
name: "FikFap".to_string(),
|
||||||
|
description:
|
||||||
|
"FikFap swipe-style short clips with direct HLS playback, hashtag browsing, and creator pages."
|
||||||
|
.to_string(),
|
||||||
|
premium: false,
|
||||||
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=fikfap.com".to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
categories: category_titles,
|
||||||
|
options: vec![
|
||||||
|
ChannelOption {
|
||||||
|
id: "sort".to_string(),
|
||||||
|
title: "Sort".to_string(),
|
||||||
|
description: "Browse FikFap by latest, trending, or a randomized For You feed."
|
||||||
|
.to_string(),
|
||||||
|
systemImage: "arrow.up.arrow.down".to_string(),
|
||||||
|
colorName: "blue".to_string(),
|
||||||
|
options: vec![
|
||||||
|
FilterOption {
|
||||||
|
id: "new".to_string(),
|
||||||
|
title: "Latest".to_string(),
|
||||||
|
},
|
||||||
|
FilterOption {
|
||||||
|
id: "trending".to_string(),
|
||||||
|
title: "Trending".to_string(),
|
||||||
|
},
|
||||||
|
FilterOption {
|
||||||
|
id: "random".to_string(),
|
||||||
|
title: "For You".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
ChannelOption {
|
||||||
|
id: "categories".to_string(),
|
||||||
|
title: "Hashtags".to_string(),
|
||||||
|
description: "Open a FikFap hashtag feed directly.".to_string(),
|
||||||
|
systemImage: "tag".to_string(),
|
||||||
|
colorName: "orange".to_string(),
|
||||||
|
options: category_options,
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nsfw: true,
|
||||||
|
cacheDuration: Some(60),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title_case(label: &str) -> String {
|
||||||
|
let mut chars = label.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_hashtag(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('#')
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.replace(' ', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_query_target(query: &str) -> Target {
|
||||||
|
let trimmed = query.trim();
|
||||||
|
|
||||||
|
if let Some((kind, value)) = trimmed.split_once(':') {
|
||||||
|
let value = value.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
match kind.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"tag" | "hashtag" | "hash" => {
|
||||||
|
return Target::Hashtag(Self::normalize_hashtag(value));
|
||||||
|
}
|
||||||
|
"user" | "uploader" | "creator" => {
|
||||||
|
return Target::User(value.to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(hashtag) = trimmed.strip_prefix('#') {
|
||||||
|
if !hashtag.trim().is_empty() {
|
||||||
|
return Target::Hashtag(Self::normalize_hashtag(hashtag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Target::Search(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_option_target(options: &ServerOptions) -> Option<Target> {
|
||||||
|
if let Some(category) = options.categories.as_deref() {
|
||||||
|
if category != "all" && !category.trim().is_empty() {
|
||||||
|
return Some(Target::Hashtag(Self::normalize_hashtag(category)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_target(query: Option<&str>, sort: &str, options: &ServerOptions) -> Target {
|
||||||
|
if let Some(query) = query {
|
||||||
|
if !query.trim().is_empty() {
|
||||||
|
return Self::resolve_query_target(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(target) = Self::resolve_option_target(options) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
Target::Feed(FeedSort::from_sort_id(sort))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timestamp(value: &str) -> Option<u64> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
DateTime::parse_from_rfc3339(value)
|
||||||
|
.ok()
|
||||||
|
.map(|parsed| parsed.timestamp().max(0) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxied_thumb(thumb_url: &str, options: &ServerOptions) -> Option<String> {
|
||||||
|
let trimmed = thumb_url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let stripped = strip_url_scheme(trimmed);
|
||||||
|
Some(build_proxy_url(options, "fikfap-thumb", &stripped))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_video_item(&self, post: Post, options: &ServerOptions) -> Option<VideoItem> {
|
||||||
|
if post.video_stream_url.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = post.post_id.to_string();
|
||||||
|
let title = if post.label.trim().is_empty() {
|
||||||
|
format!("FikFap post {}", post.post_id)
|
||||||
|
} else {
|
||||||
|
post.label.trim().to_string()
|
||||||
|
};
|
||||||
|
let url = format!("{BASE_URL}/post/{}", post.post_id);
|
||||||
|
let thumb = Self::proxied_thumb(&post.thumbnail_stream_url, options)
|
||||||
|
.unwrap_or_else(|| post.thumbnail_stream_url.clone());
|
||||||
|
let duration = post.duration.unwrap_or(0);
|
||||||
|
|
||||||
|
let format = VideoFormat::m3u8(
|
||||||
|
post.video_stream_url.clone(),
|
||||||
|
"auto".to_string(),
|
||||||
|
"hls".to_string(),
|
||||||
|
)
|
||||||
|
.http_header("Referer".to_string(), format!("{BASE_URL}/"));
|
||||||
|
|
||||||
|
let mut item = VideoItem::new(id, title, url, CHANNEL_ID.to_string(), thumb, duration);
|
||||||
|
item.views = Some(post.views_count);
|
||||||
|
item.uploadedAt = post
|
||||||
|
.published_at
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.and_then(Self::parse_timestamp)
|
||||||
|
.or_else(|| Self::parse_timestamp(&post.created_at));
|
||||||
|
|
||||||
|
if !post.author.username.trim().is_empty() {
|
||||||
|
item.uploader = Some(post.author.username.clone());
|
||||||
|
item.uploaderUrl = Some(format!("{BASE_URL}/user/{}", post.author.username));
|
||||||
|
item.uploaderId = Some(format!("{CHANNEL_ID}:{}", post.author.username));
|
||||||
|
}
|
||||||
|
item.verified = Some(post.author.is_verified);
|
||||||
|
|
||||||
|
let tags = post
|
||||||
|
.hashtags
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.label.clone())
|
||||||
|
.filter(|label| !label.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
item.tags = (!tags.is_empty()).then_some(tags);
|
||||||
|
item.formats = Some(vec![format]);
|
||||||
|
|
||||||
|
Some(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_posts_page(
|
||||||
|
&self,
|
||||||
|
target_path: &str,
|
||||||
|
sort: Option<&str>,
|
||||||
|
after_id: Option<i64>,
|
||||||
|
amount: usize,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Result<Vec<Post>> {
|
||||||
|
let mut url = format!("{API_BASE}/{target_path}?amount={amount}");
|
||||||
|
if let Some(sort) = sort {
|
||||||
|
url.push_str(&format!("&sort={sort}"));
|
||||||
|
}
|
||||||
|
if let Some(after_id) = after_id {
|
||||||
|
url.push_str(&format!("&afterId={after_id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_posts_page");
|
||||||
|
let text = requester
|
||||||
|
.get_with_headers(&url, self.anon_headers(), None)
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))?;
|
||||||
|
Ok(serde_json::from_str(&text)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_cursor_page(
|
||||||
|
&self,
|
||||||
|
target_path: &str,
|
||||||
|
sort: Option<&str>,
|
||||||
|
page: u16,
|
||||||
|
per_page: usize,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Result<Vec<Post>> {
|
||||||
|
let page = page.max(1).min(MAX_PAGE_WALK);
|
||||||
|
let mut after_id: Option<i64> = None;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..page {
|
||||||
|
let batch = self
|
||||||
|
.fetch_posts_page(target_path, sort, after_id, per_page, options)
|
||||||
|
.await?;
|
||||||
|
if batch.is_empty() {
|
||||||
|
items = Vec::new();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
after_id = batch.last().map(|post| post.post_id);
|
||||||
|
items = batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_search(&self, query: &str, options: &ServerOptions) -> Result<Vec<Post>> {
|
||||||
|
let encoded_query: String = url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
|
||||||
|
let url = format!("{API_BASE}/search?q={encoded_query}");
|
||||||
|
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_search");
|
||||||
|
let text = requester
|
||||||
|
.get_with_headers(&url, self.anon_headers(), None)
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))?;
|
||||||
|
let response: SearchResponse = serde_json::from_str(&text)?;
|
||||||
|
Ok(response.posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_target_posts(
|
||||||
|
&self,
|
||||||
|
target: &Target,
|
||||||
|
page: u16,
|
||||||
|
per_page: usize,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Result<Vec<Post>> {
|
||||||
|
match target {
|
||||||
|
Target::Feed(feed_sort) => {
|
||||||
|
self.fetch_cursor_page("posts", Some(feed_sort.api_value()), page, per_page, options)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Target::Hashtag(label) => {
|
||||||
|
let encoded = utf8_percent_encode(label, NON_ALPHANUMERIC).to_string();
|
||||||
|
let path = format!("hashtags/label/{encoded}/posts");
|
||||||
|
self.fetch_cursor_page(&path, Some("new"), page, per_page, options)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Target::User(username) => {
|
||||||
|
let encoded = utf8_percent_encode(username, NON_ALPHANUMERIC).to_string();
|
||||||
|
let path = format!("profile/username/{encoded}/posts");
|
||||||
|
self.fetch_cursor_page(&path, None, page, per_page, options)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Target::Search(query) => {
|
||||||
|
// FikFap's search endpoint returns a single fixed-size batch with no
|
||||||
|
// cursor or page parameter, so later pages have nothing new to offer.
|
||||||
|
if page > 1 {
|
||||||
|
Ok(Vec::new())
|
||||||
|
} else {
|
||||||
|
self.fetch_search(query, options).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_target_items(
|
||||||
|
&self,
|
||||||
|
target: Target,
|
||||||
|
page: u16,
|
||||||
|
per_page: usize,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Result<Vec<VideoItem>> {
|
||||||
|
let posts = self.fetch_target_posts(&target, page, per_page, options).await?;
|
||||||
|
Ok(posts
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|post| self.build_video_item(post, options))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user_profile(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Result<Option<UserProfile>> {
|
||||||
|
let encoded = utf8_percent_encode(username, NON_ALPHANUMERIC).to_string();
|
||||||
|
let url = format!("{API_BASE}/profile/username/{encoded}");
|
||||||
|
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_user_profile");
|
||||||
|
let text = match requester.get_with_headers(&url, self.anon_headers(), None).await {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
match serde_json::from_str::<UserProfile>(&text) {
|
||||||
|
Ok(profile) if !profile.username.trim().is_empty() => Ok(Some(profile)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_uploader_profile(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
query: Option<&str>,
|
||||||
|
profile_content: bool,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Result<Option<UploaderProfile>> {
|
||||||
|
let Some(profile) = self.fetch_user_profile(username, options).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let canonical_id = format!("{CHANNEL_ID}:{}", profile.username);
|
||||||
|
let mut videos = None;
|
||||||
|
|
||||||
|
if profile_content {
|
||||||
|
let items = self
|
||||||
|
.fetch_target_items(Target::User(profile.username.clone()), 1, 24, options)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let filtered_items = if let Some(query) = query.filter(|value| !value.trim().is_empty()) {
|
||||||
|
let normalized_query = query.to_ascii_lowercase();
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| {
|
||||||
|
let haystack = format!(
|
||||||
|
"{} {}",
|
||||||
|
item.title,
|
||||||
|
item.tags.as_ref().map(|values| values.join(" ")).unwrap_or_default()
|
||||||
|
)
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
haystack.contains(&normalized_query)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
};
|
||||||
|
|
||||||
|
let refs = filtered_items
|
||||||
|
.iter()
|
||||||
|
.map(|item| UploaderVideoRef::from_video_item(item, &profile.username, &canonical_id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
videos = Some(refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(UploaderProfile {
|
||||||
|
id: canonical_id,
|
||||||
|
name: profile.username.clone(),
|
||||||
|
url: Some(format!("{BASE_URL}/user/{}", profile.username)),
|
||||||
|
channel: Some(CHANNEL_ID.to_string()),
|
||||||
|
verified: profile.is_verified,
|
||||||
|
videoCount: profile.count_posts,
|
||||||
|
totalViews: profile.count_total_views,
|
||||||
|
channels: Some(vec![UploaderChannelStat {
|
||||||
|
channel: CHANNEL_ID.to_string(),
|
||||||
|
videoCount: profile.count_posts,
|
||||||
|
firstSeenAt: None,
|
||||||
|
lastSeenAt: None,
|
||||||
|
}]),
|
||||||
|
avatar: Self::proxied_thumb(&profile.thumbnail_url, options),
|
||||||
|
description: profile.description.clone(),
|
||||||
|
bio: profile.description,
|
||||||
|
videos,
|
||||||
|
tapes: Some(vec![]),
|
||||||
|
playlists: Some(vec![]),
|
||||||
|
layout: Some(vec![UploaderLayoutRow::videos(Some("Posts".to_string()))]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for FikfapProvider {
|
||||||
|
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::<u16>().unwrap_or(1).max(1);
|
||||||
|
let per_page = per_page
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap_or(DEFAULT_PER_PAGE)
|
||||||
|
.clamp(1, MAX_PER_PAGE);
|
||||||
|
let normalized_query = query
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let target = Self::pick_target(normalized_query, &sort, &options);
|
||||||
|
|
||||||
|
match self.fetch_target_items(target, page, per_page, &options).await {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(error) => {
|
||||||
|
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||||
|
Some(self.build_channel(clientversion))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_uploader(
|
||||||
|
&self,
|
||||||
|
cache: VideoCache,
|
||||||
|
pool: DbPool,
|
||||||
|
uploader_id: Option<String>,
|
||||||
|
uploader_name: Option<String>,
|
||||||
|
query: Option<String>,
|
||||||
|
profile_content: bool,
|
||||||
|
options: ServerOptions,
|
||||||
|
) -> std::result::Result<Option<UploaderProfile>, String> {
|
||||||
|
let _ = cache;
|
||||||
|
let _ = pool;
|
||||||
|
|
||||||
|
let username = uploader_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| id.strip_prefix(&format!("{CHANNEL_ID}:")))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or(uploader_name.clone())
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
|
let Some(username) = username else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.build_uploader_profile(&username, query.as_deref(), profile_content, &options)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_query_shortcuts() {
|
||||||
|
match FikfapProvider::resolve_query_target("tag:Big Tits") {
|
||||||
|
Target::Hashtag(label) => assert_eq!(label, "bigtits"),
|
||||||
|
other => panic!("expected hashtag target, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match FikfapProvider::resolve_query_target("#blonde") {
|
||||||
|
Target::Hashtag(label) => assert_eq!(label, "blonde"),
|
||||||
|
other => panic!("expected hashtag target, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match FikfapProvider::resolve_query_target("user:AdultPrime") {
|
||||||
|
Target::User(username) => assert_eq!(username, "AdultPrime"),
|
||||||
|
other => panic!("expected user target, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match FikfapProvider::resolve_query_target("blonde teen") {
|
||||||
|
Target::Search(query) => assert_eq!(query, "blonde teen"),
|
||||||
|
other => panic!("expected search target, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_sort_ids() {
|
||||||
|
assert_eq!(FeedSort::from_sort_id("new"), FeedSort::New);
|
||||||
|
assert_eq!(FeedSort::from_sort_id("trending"), FeedSort::Trending);
|
||||||
|
assert_eq!(FeedSort::from_sort_id("random"), FeedSort::Random);
|
||||||
|
assert_eq!(FeedSort::from_sort_id("unknown"), FeedSort::New);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_post_payload() {
|
||||||
|
let json = r#"{
|
||||||
|
"postId": 123,
|
||||||
|
"label": "Test post",
|
||||||
|
"viewsCount": 42,
|
||||||
|
"duration": 12,
|
||||||
|
"videoStreamUrl": "https://vz-x.b-cdn.net/abc/playlist.m3u8",
|
||||||
|
"thumbnailStreamUrl": "https://vz-x.b-cdn.net/abc/thumbnail.jpg",
|
||||||
|
"publishedAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"author": {
|
||||||
|
"username": "creator",
|
||||||
|
"isVerified": true,
|
||||||
|
"countPosts": 10,
|
||||||
|
"countTotalViews": 100,
|
||||||
|
"thumbnailUrl": "https://example.com/avatar.jpg",
|
||||||
|
"description": null
|
||||||
|
},
|
||||||
|
"hashtags": [{"label": "blonde"}]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let post: Post = serde_json::from_str(json).expect("parses");
|
||||||
|
assert_eq!(post.post_id, 123);
|
||||||
|
assert_eq!(post.author.username, "creator");
|
||||||
|
assert_eq!(post.hashtags.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/proxies/fikfapthumb.rs
Normal file
65
src/proxies/fikfapthumb.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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://fikfap.com/";
|
||||||
|
|
||||||
|
fn endpoint_to_image_url(req: &HttpRequest) -> String {
|
||||||
|
let endpoint = req.match_info().query("endpoint").trim_start_matches('/');
|
||||||
|
let mut image_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||||
|
endpoint.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{endpoint}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = req.query_string();
|
||||||
|
if !query.is_empty() && !image_url.contains('?') {
|
||||||
|
image_url.push('?');
|
||||||
|
image_url.push_str(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
image_url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_image(
|
||||||
|
req: HttpRequest,
|
||||||
|
requester: web::types::State<Requester>,
|
||||||
|
) -> Result<impl web::Responder, web::Error> {
|
||||||
|
let image_url = endpoint_to_image_url(&req);
|
||||||
|
|
||||||
|
let upstream = match requester
|
||||||
|
.get_ref()
|
||||||
|
.clone()
|
||||||
|
.get_raw_with_headers(
|
||||||
|
image_url.as_str(),
|
||||||
|
vec![("Referer".to_string(), REFERER.to_string())],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => response,
|
||||||
|
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(ct_str) = ct.to_str() {
|
||||||
|
resp.set_header(CONTENT_TYPE, ct_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cl) = headers.get(CONTENT_LENGTH) {
|
||||||
|
if let Ok(cl_str) = cl.to_str() {
|
||||||
|
resp.set_header(CONTENT_LENGTH, cl_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp.body(bytes.to_vec()))
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ pub mod allpornstream;
|
|||||||
pub mod archivebate;
|
pub mod archivebate;
|
||||||
pub mod clapdat;
|
pub mod clapdat;
|
||||||
pub mod doodstream;
|
pub mod doodstream;
|
||||||
|
pub mod fikfapthumb;
|
||||||
pub mod hanimecdn;
|
pub mod hanimecdn;
|
||||||
pub mod hanimethumb;
|
pub mod hanimethumb;
|
||||||
pub mod heavyfetish;
|
pub mod heavyfetish;
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::post().to(crate::proxies::noodlemagazine::get_image))
|
.route(web::post().to(crate::proxies::noodlemagazine::get_image))
|
||||||
.route(web::get().to(crate::proxies::noodlemagazine::get_image)),
|
.route(web::get().to(crate::proxies::noodlemagazine::get_image)),
|
||||||
)
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/fikfap-thumb/{endpoint}*")
|
||||||
|
.route(web::post().to(crate::proxies::fikfapthumb::get_image))
|
||||||
|
.route(web::get().to(crate::proxies::fikfapthumb::get_image)),
|
||||||
|
)
|
||||||
.service(
|
.service(
|
||||||
web::resource("/hanime-cdn/{endpoint}*")
|
web::resource("/hanime-cdn/{endpoint}*")
|
||||||
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
||||||
|
|||||||
Reference in New Issue
Block a user