fikfap
This commit is contained in:
5
build.rs
5
build.rs
@@ -286,6 +286,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
||||
module: "hentaihaven",
|
||||
ty: "HentaihavenProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "fikfap",
|
||||
module: "fikfap",
|
||||
ty: "FikfapProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "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. |
|
||||
| `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>`). |
|
||||
| `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. |
|
||||
| `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. |
|
||||
| `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-thumb/{endpoint}*`
|
||||
- `/proxy/hanime-cdn/{endpoint}*`
|
||||
- `/proxy/fikfap-thumb/{endpoint}*`
|
||||
- `/proxy/hqporner-thumb/{endpoint}*`
|
||||
- `/proxy/porndish-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 clapdat;
|
||||
pub mod doodstream;
|
||||
pub mod fikfapthumb;
|
||||
pub mod hanimecdn;
|
||||
pub mod hanimethumb;
|
||||
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::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(
|
||||
web::resource("/hanime-cdn/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
||||
|
||||
Reference in New Issue
Block a user