750 lines
24 KiB
Rust
750 lines
24 KiB
Rust
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),
|
|
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::<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);
|
|
|
|
// 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::<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);
|
|
}
|
|
}
|