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, #[serde(default)] video_stream_url: String, #[serde(default)] thumbnail_stream_url: String, #[serde(default)] published_at: Option, #[serde(default)] created_at: String, #[serde(default)] author: Author, #[serde(default)] hashtags: Vec, } #[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, } #[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, #[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::>(); let category_options = CURATED_HASHTAGS .iter() .map(|label| FilterOption { id: label.to_string(), title: Self::title_case(label), }) .collect::>(); 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), ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()) } } fn title_case(label: &str) -> String { let mut chars = label.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + 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 { 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 { 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 { 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 { 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); // The CDN requires a Referer on every request in the HLS session (master // playlist, sub-playlists, and segments). Hot Tub clients apply a format's // `http_headers` to the whole HLS playback session, not just the initial // manifest fetch, so the documented `httpHeaders` mechanism is sufficient // here without needing to reverse-proxy the media ourselves. 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::>(); 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, amount: usize, options: &ServerOptions, ) -> Result> { 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> { let page = page.max(1).min(MAX_PAGE_WALK); let mut after_id: Option = 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> { 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> { 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> { 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> { 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::(&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> { 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::>() } else { items }; let refs = filtered_items .iter() .map(|item| UploaderVideoRef::from_video_item(item, &profile.username, &canonical_id)) .collect::>(); 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, page: String, per_page: String, options: ServerOptions, ) -> Vec { let _ = cache; let _ = pool; let page = page.parse::().unwrap_or(1).max(1); let per_page = per_page .parse::() .unwrap_or(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 { Some(self.build_channel(clientversion)) } async fn get_uploader( &self, cache: VideoCache, pool: DbPool, uploader_id: Option, uploader_name: Option, query: Option, profile_content: bool, options: ServerOptions, ) -> std::result::Result, 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); } }