Add txxx provider; cumlouder: route keywords to pornstar /girl/ pages before tags

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
Simon
2026-10-10 15:47:05 +00:00
parent af6674b4ed
commit 6d7b16842d
4 changed files with 668 additions and 6 deletions

View File

@@ -361,6 +361,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "cumlouder",
ty: "CumlouderProvider",
},
ProviderDef {
id: "txxx",
module: "txxx",
ty: "TxxxProvider",
},
ProviderDef {
id: "fullporner",
module: "fullporner",

View File

@@ -81,7 +81,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `jable` | `jav` | no | yes | HTML JAV archive scraper; extracts `var hlsUrl` from detail pages; m3u8 format requires Referer + browser User-Agent; proxy route handles HEAD (200 OK) and GET (redirect to watch page) since yt-dlp blocks jable.tv; tag/category/model shortcut queries. |
| `kwiky` | `tiktok` | no | no | Short-form cam clip provider for kwiky.com (ICF/StreamMate platform). Two-step fetch: ID list then metadata batch. Auth: XSRF token extracted from `meta.content = '<TOKEN>'` injected by `/api/state/v1/preload`, cached in an `Arc<RwLock<Option<String>>>` with double-checked locking (same pattern as `xxxtik` bearer token); all API calls also require `x-gateway: 76d59e04-97fa-4ced-aa99-b86ffaf756a2` (platform-identifying fixed UUID observed in browser JS, not stored in cookies or localStorage). Default feed: `GET /api/v1/gateway/v1/quickies/recommended?source=<base64-hotlist>` (cursor-paged, walks up to 5 cursors for higher page numbers). All tag/keyword queries go through `keywordSearch?keyword=...` (offset-paged: `pageToken=(page-1)*perPage`) — the recommended endpoint's `&tag=` param does not reliably filter, so even curated-tag requests use keywordSearch. Media: `media.url` = direct `media.icfcdn.com/*.mp4` (publicly accessible, no Referer/auth), `media.thumbUrl` thumbnail (same CDN, direct), `media.previewUrl` preview clip; both kwiky page URLs (`kwiky.com/quickies/{id}`) and the media CDN URLs pass direct `curl -I` health checks but the page URL is NOT yt-dlp-resolvable (React SPA, generic extractor fails), so `formats` are populated with the direct MP4 URL and `video.url` is the page. Aspect ratio set from `media.width`/`media.height`; `uploadedAt` from RFC3339 `created` field; uploader name/URL/ID from `creator.name` and `creator.id`. 48 curated tags from `/api/v1/gateway/v1/keywords/populartags?gender=f` exposed via `categories` filter option. No proxy needed. |
| `fullporner` | `mainstream-tube` | no | no | HTML scraper for fullporner.com; thumbnail IDs derived from `/thumb/{id}.jpg` URLs and used to build direct `xiaoshenke.net/vid/{id}/720` media redirect URLs (Referer + User-Agent headers required); supports cat:/category:/pornstar:/star: shortcut queries; no proxy needed. |
| `cumlouder` | `mainstream-tube` | no | no | HTML scraper for cumlouder.com (Spanish/amateur tube, same platform family as superporn). Cards are `a.muestra-escena[href^="/porn-video/"]` with lazy thumb in `img[data-src]` (protocol-relative `//th0cdn.cumlouder.com/...`, direct, no Referer/proxy), `h2` title, `.vistas` views, `.minutos` duration (`06:14 m`); relative date only ("2 years ago"), so `uploadedAt` is not set. Default feed `/porn/` (page N≥2 is the bare `/N/` path, not `/porn/N/`); category `/porn-videos/{slug}/{N}/`; tag `/tag/{slug}/{N}/`; series `/series/{slug}/{N}/`. **The site's own `/search/` (POST `form[search]` or `?q=`) is behind a Cloudflare managed challenge — 403 even in a real browser — so keyword queries are routed instead: a query matching a hardcoded category (72, scraped from `/categories/`) goes to the category archive, anything else to `/tag/{slug}/` (unknown tags 404 → empty list).** Supports `cat:`/`category:`, `tag:`, `series:` prefixes and the `categories` option. `video.url` is the detail page, which yt-dlp resolves natively via the generic html5 extractor (the page embeds a signed `m5cdnst.cumlouder.com/*.mp4?secure=…` `<source>`, no Referer needed), so no `formats`/enrichment. The origin intermittently answers a first, uncached request with a Cloudflare 522 (a retry succeeds) — the listing fetch retries up to 3×, and `check.py`/yt-dlp may show a one-off `HTTP=-1`/522 on a cold detail page that is not a provider fault. No proxy needed. |
| `cumlouder` | `mainstream-tube` | no | no | HTML scraper for cumlouder.com (Spanish/amateur tube, same platform family as superporn). Cards are `a.muestra-escena[href^="/porn-video/"]` with lazy thumb in `img[data-src]` (protocol-relative `//th0cdn.cumlouder.com/...`, direct, no Referer/proxy), `h2` title, `.vistas` views, `.minutos` duration (`06:14 m`); relative date only ("2 years ago"), so `uploadedAt` is not set. Default feed `/porn/` (page N≥2 is the bare `/N/` path, not `/porn/N/`); category `/porn-videos/{slug}/{N}/`; tag `/tag/{slug}/{N}/`; series `/series/{slug}/{N}/`. **The site's own `/search/` (POST `form[search]` or `?q=`) is behind a Cloudflare managed challenge — 403 even in a real browser — so keyword queries are routed instead: a query matching a hardcoded category (72, scraped from `/categories/`) goes to the category archive, anything else to `/tag/{slug}/` (unknown tags 404 → empty list).** Pornstar pages live at `/girl/{slug}/{N}/` (same cards/pagination; unknown slug = fast 404). A free-form keyword that is not a category is tried as a pornstar page first, then as a tag archive; `girl:`/`pornstar:`/`star:`/`model:` force the pornstar page. Supports `cat:`/`category:`, `tag:`, `series:` prefixes and the `categories` option. A Cloudflare challenge on the raw listing fetch is handed to the requester's FlareSolverr chain. `video.url` is the detail page, which yt-dlp resolves natively via the generic html5 extractor (the page embeds a signed `m5cdnst.cumlouder.com/*.mp4?secure=…` `<source>`, no Referer needed), so no `formats`/enrichment. The origin intermittently answers a first, uncached request with a Cloudflare 522 (a retry succeeds) — the listing fetch retries up to 3×, and `check.py`/yt-dlp may show a one-off `HTTP=-1`/522 on a cold detail page that is not a provider fault. No proxy needed. |
| `thepornbunny` | `mainstream-tube` | no | yes | KVS-style HTML scraper for thepornbunny.com; 24 items per site page; thumbnails at `https://www.thepornbunny.com/images/thumb/{id}.webp` from `data-original` attribute (no proxy needed); studio exposed as uploader; pornstar names in tags; `/proxy/thepornbunny/{slug}` fetches the video page, extracts `generate_mp4(enc_data, key, rnd, video_id)` args, decrypts `enc_data` via PBKDF2-HMAC-SHA512+AES-256-CBC to get an OK.ru session key, calls `api.ok.ru/fb.do?method=video.get&session_key=KEY&vids=RND` to get signed CDN URLs, and returns 302 to the best-quality okcdn.ru/vkuser.net MP4 URL (no special client headers needed); supports sort: new/popular/rated, 20 hardcoded categories via `categories` option, and tag:/category:/studio:/pornstar: query shortcuts. |
| `eporner` | `mainstream-tube` | no | no | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); supports sort: new/popular/rated/best; 65 hardcoded categories, plus `cat:`/`category:`, `tag:`, `pornstar:`/`star:`, `uploader:`/`profile:` query-prefix routing to the matching archive, a bare-keyword category-name match, and a background-loaded pornstar name→URL map from `/pornstar-list/` (falls back to `/tag/{slug}` keyword search otherwise). `video.url` is the detail page — **not** yt-dlp-resolvable and not directly playable on its own: opening it bare serves a small restricted/preview clip. Real playback needs per-card enrichment (bounded `buffer_unordered(6)`): fetch the detail page for its 32-hex `hash`, derive the site's own hashed token (`calc_hash` — split into four 8-hex chunks, each parsed as u32 and base36-encoded, concatenated; reverse-engineered from the player JS), then call the site's own `GET /xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false` JSON API for direct per-resolution mp4 URLs into `formats[]`. Separately from the hash dance, the resolved CDN mp4 URLs enforce their own Referer check: a request with no Referer or one pointing at eporner.com gets the real file, but any other non-empty Referer gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the exact "this video is only available at eporner.com"-style short clip a client sees if its default (or app-injected) Referer doesn't match, so every format explicitly declares `http_headers: {Referer: "https://www.eporner.com/"}` rather than relying on the client's default. No proxy needed (formats are direct CDN URLs, just header-gated). **2026-09-21:** the `calc_hash`/hash-regex/`alnum_id_from_href`/`resolve_formats` logic moved to `src/util/eporner_resolve.rs` so `melonstube` (a meta-search aggregator that redirects out to eporner.com for ~34% of its own cards) can reuse the exact same resolution without a cross-provider `use`, which would break either provider's `HOT_TUB_PROVIDER=<id>` single-provider build (only the one selected provider module gets compiled; `src/util/*` always does). `eporner.rs` itself now just calls `crate::util::eporner_resolve::resolve_formats(...)`. |
| `xnxx` | `mainstream-tube` | no | no | HTML scraper for xnxx.com (10M+ videos); unified card parser handles two formats: `div.thumb-block[data-eid]` (search) and `div.thumb-block.video[data-video='{"id":...}']` (hits); eid extracted from `/video-{eid}/{slug}` URL path; thumbnails at `thumb-cdn77.xnxx-cdn.com` and `thumbs-gcore.xnxx-cdn.com` (no proxy, no Referer needed); 0-indexed pagination (page 1 = `/hits`, page N = `/hits/{N-1}`); default feed is `/hits` (most-viewed — xnxx has no chronological listing); search via `/search/{slug}` (works for keywords and tags); supports `tag:`, `cat:`, `category:` query shortcuts; yt-dlp resolves `video.url` natively (XNXX extractor, returns 4-7 HLS formats); no proxy needed. |
@@ -92,6 +92,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `wowxxx` | `studio-network` | no | no | HTML scraper for the wowxxx.to premium aggregator (wow.xxx 301-redirects here now; `BASE_URL` points at the canonical domain directly). The site is Cloudflare-protected — `fetch_html` must NOT pin `Version::HTTP_11` (removed; now `None`), since forcing h1 while the TLS handshake still advertises the browser emulation's h2 ALPN is exactly the mismatch documented for jable.tv's CF gate. Default feed `/latest-updates/`, page 2 `/{N}/` suffix (for example `/latest-updates/2/`), search `/search/{query}/relevance/` with the same page suffix; supports `site:`/`studio:`/`network:`/`model:`/`pornstar:`/`tag:`/`cat:` query shortcuts to direct archive routes; list cards expose preview clips (`cast.wowxxx.to/preview/*.mp4`), thumbnails (`img.wowxxx.to/.../medium@2x/1.jpg`), duration, rating, views, site (as uploader), and model tags; `video.url` is the detail page URL and yt-dlp resolves HTML5 MP4 formats dynamically; no proxy needed. `www.wowxxx.to`/`img.wowxxx.to` are in `check.py`'s CF-protected allowlist since a plain probe still 403s even though the app's own client succeeds. |
| `xlecx` | `hentai-animation` | no | no | DLE (DataLife Engine)-style HTML scraper for xlecx.one, following the `thaiporntv` template. Latest feed `/f/cat={id}/sort={mode}/order=desc/` (page N≥2 appends `page/{N}/`); `sort` options new/popular/commented/rated map to DLE's `date`/`news_read`/`comm_num`/`rating` sort params; `category` option is the 2 hardcoded top-level archives (`videos`, `hentai-video`). Search via `/index.php?do=search&subaction=search&story={query}` (page N adds `search_start={N}`). Tag shortcut (`#slug` query) routes to `/tags/{slug}/` (paginated via `/tags/{slug}/page/{N}/`); the ~1,044-entry tag catalog is background-loaded once at startup from `/tags/` (`.tags-list1 a[href*='/tags/']`, absolute-URL anchors) into an `Arc<RwLock<Vec<FilterOption>>>` and exposed as the `filter`/"Tags" `ChannelOption` — note this option (and any `categories`) is deliberately stripped from the live `/api/status` response by `should_hide_status_option`/`decorate_channel` in `providers/mod.rs` (any `id=="filter"` option titled "Tags" is hidden site-wide), so its absence there is expected, not a bug; it is still honored by `/api/videos`. Cards parsed from `h3.thumb__title` + `img` inside standard DLE thumb blocks; id extracted from the `/{id}-{slug}.html` URL suffix. Thumbnails are direct `xlecx.one/uploads/posts/...` webp (no proxy/Referer needed). `video.url` is the detail page URL, which yt-dlp resolves natively via the generic HTML5 embed extractor; no `formats`/proxy needed. No `/api/uploaders` (no stable uploader identity on the site). |
| `xxxtik` | `tiktok` | yes | no | JSON-API short-form aggregator for xxxtik.com — every post is a moderated repost of a RedGifs clip, so the real media backend is the public **RedGifs v2 API**, not xxxtik itself (new pattern; no other provider currently resolves through a third-party media API). Listing API (`xxxtik-api-iw98m.ondigitalocean.app`, found by grepping the Angular `main-es2015.js` bundle): `GET /post/new`, `/post/top/{week,month,year,all}`, `/post/tag/{name}`, `/post/creator/{username}` — all **cursor**-paginated (`?cursor={lastItemId}&limit=N`; the `cursor` is the numeric `id` of the last item from the previous batch, not an offset/count — reaching page N walks N sequential requests, mirroring `fikfap`'s `fetch_cursor_page`). `GET /search?query=Q` returns tag/profile autocomplete suggestions only (no posts), so free-text search is routed through it to resolve a `Tag`/`Creator` target before a second listing call; `tag:`/`category:`/`cat:` and `user:`/`uploader:`/`creator:` query prefixes skip that lookup. Each post's `source` field is a `redgifs.com/watch/{id}` URL; the provider fetches a short-lived anonymous bearer token from `POST api.redgifs.com/v2/auth/temporary` (cached, refreshed once on a 401) and resolves `GET api.redgifs.com/v2/gifs/{id}` (bounded to 8-way concurrency via `buffer_unordered`) for the real `media.redgifs.com/*.mp4` + poster, both fetchable with zero auth/Referer. `video.url` is the xxxtik page (`https://xxxtik.com/feed/{uuid}`, not yt-dlp-resolvable — Angular SPA, generic extractor fails), with `formats` populated from the resolved redgifs mp4; tags merge xxxtik's own tags with RedGifs' tags. 20 curated tags exposed via `categories` (xxxtik has ~62k tags total, too many to background-load). `/api/uploaders` works via `GET /user/by-username/{name}` + `/post/creator/{name}`, but xxxtik's "creator" accounts are inconsistent: some (e.g. `dlhoodninja`, `besttits`) return real posts matching their profile `_count.posts`; others with a nonzero `_count.posts` (e.g. `bigboobsgw`, count 472) return an empty `/post/creator/` list — likely synthetic curation accounts (`name@default` emails) rather than real uploaders. The provider degrades gracefully (returns the profile with `videos: []`, no error) rather than guessing which accounts are "real". No proxy needed — all media/thumb URLs are publicly fetchable with no Referer or auth. |
| `txxx` | `mainstream-tube` | no | no | JSON-API scraper for txxx.me (mirror of txxx.com; Vue SPA, same KVS-based API family as the hclips/upornia/hdzog network). Listing: `GET /api/json/videos2/86400/str/{sort}/{count}/{section}.{object}.{page}.all...jsond` with sort `latest-updates`/`most-popular`/`top-rated`/`longest`; **the `.json` variant ignores the page number, `jsond` does not**. Category archive = section `categories`, object = category dir slug (numeric ids return `category_not_found`); ~80 top categories hardcoded as the `categories` option (225 exist at `/api/json/categories/86400/str.all.en.jsond`). Search: `GET /api/videos2.php?params=86400/str/relevance/{count}/search..{page}.all..&s={query}` (relevance only; sort is ignored; page works although `params.page` echoes 1). `count` follows `perPage` (max 60). The page URL `/videos/{id}/{dir}/` is **not** yt-dlp-resolvable, so every card is enriched (bounded `buffered(8)`) via `GET /api/videofile.php?video_id={id}&lifetime=8640000`, whose `video_url` is base64 of `/get_file/...` with Cyrillic look-alike letters (А Е М С …) substituted and `~` for `=`; the decoded `https://txxx.me/get_file/...` 302s to a per-client signed `*.ahcdn.com` URL (IP-pinned, so it must NOT be resolved server-side; no Referer needed). Items without formats are dropped. Heights come from the listing `file_formats` (`||_hq.mp4|1278x720|…||_tr.mp4|…`; `_tr.mp4` is the trailer and becomes `preview` via `pv`). Rating 0..5 → ×20; `likes`/`dislikes` ignored; category titles become tags; uploader = content source else member `display_name` (`txxx:{user_id}`; no profile endpoint). Thumbs `tn.txxx.tube` direct. Queries matching a category name route to its archive; `cat:`/`category:` prefix. No models/channels archives (sections `models`/`channels` are rejected by the API). No proxy needed. |
## Proxy Routes

View File

@@ -119,6 +119,10 @@ enum Target {
Category(String),
Tag(String),
Series(String),
/// Pornstar page `/girl/{slug}/`.
Girl(String),
/// Free-form keyword: a pornstar page if one exists, else a tag archive.
Keyword(String),
}
impl CumlouderProvider {
@@ -210,12 +214,17 @@ impl CumlouderProvider {
if let Some(rest) = lower.strip_prefix("series:") {
return Target::Series(Self::slugify(rest));
}
for prefix in ["girl:", "pornstar:", "star:", "model:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
return Target::Girl(Self::slugify(rest));
}
}
// The site search is behind a Cloudflare challenge: fall back to a
// category archive, then to the free-form tag archive.
// category archive, then to a pornstar page, then to a tag archive.
if let Some(slug) = Self::category_slug(q) {
return Target::Category(slug.to_string());
}
return Target::Tag(Self::slugify(q));
return Target::Keyword(Self::slugify(q));
}
if let Some(cat) = options.categories.as_deref() {
@@ -244,6 +253,9 @@ impl CumlouderProvider {
Target::Category(slug) => format!("{BASE_URL}/porn-videos/{slug}/{suffix}"),
Target::Tag(slug) => format!("{BASE_URL}/tag/{slug}/{suffix}"),
Target::Series(slug) => format!("{BASE_URL}/series/{slug}/{suffix}"),
Target::Girl(slug) | Target::Keyword(slug) => {
format!("{BASE_URL}/girl/{slug}/{suffix}")
}
}
}
@@ -435,7 +447,22 @@ impl Provider for CumlouderProvider {
let per_page = per_page.parse::<usize>().unwrap_or(30).clamp(1, 60);
let target = Self::pick_target(query.as_deref(), &options);
match Self::fetch_listing(&target, page, per_page, &options).await {
let result = match &target {
Target::Keyword(slug) => {
// A name matches a pornstar page (`/girl/{slug}/`); anything
// else is a tag. An unknown girl is a fast 404 -> empty.
match Self::fetch_listing(&target, page, per_page, &options).await {
Ok(items) if !items.is_empty() => Ok(items),
Ok(_) if page > 1 => Ok(vec![]),
Ok(_) | Err(_) => {
let tag = Target::Tag(slug.clone());
Self::fetch_listing(&tag, page, per_page, &options).await
}
}
}
_ => Self::fetch_listing(&target, page, per_page, &options).await,
};
match result {
Ok(items) => items,
Err(e) => {
report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
@@ -486,6 +513,10 @@ mod tests {
CumlouderProvider::build_listing_url(&Target::Tag("deepthroat".into()), 2),
"https://www.cumlouder.com/tag/deepthroat/2/"
);
assert_eq!(
CumlouderProvider::build_listing_url(&Target::Girl("karyn".into()), 2),
"https://www.cumlouder.com/girl/karyn/2/"
);
assert_eq!(
CumlouderProvider::build_listing_url(&Target::Category("anal".into()), 1),
"https://www.cumlouder.com/porn-videos/anal/"
@@ -500,8 +531,12 @@ mod tests {
Target::Category("big-tits".into())
);
assert_eq!(
CumlouderProvider::pick_target(Some("deepthroat"), &o),
Target::Tag("deepthroat".into())
CumlouderProvider::pick_target(Some("Karyn"), &o),
Target::Keyword("karyn".into())
);
assert_eq!(
CumlouderProvider::pick_target(Some("girl:Taylor Sands"), &o),
Target::Girl("taylor-sands".into())
);
assert_eq!(
CumlouderProvider::pick_target(Some("series:Nacho Vidal"), &o),

621
src/providers/txxx.rs Normal file
View File

@@ -0,0 +1,621 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::requester::Requester;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use base64::Engine;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde_json::Value;
use std::collections::HashMap;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["tube", "hd", "amateur", "free"],
};
const BASE_URL: &str = "https://txxx.me";
const CHANNEL_ID: &str = "txxx";
const ENRICH_CONCURRENCY: usize = 8;
const MAX_COUNT: usize = 60;
// Top categories by video count from `/api/json/categories/`. The `dir` slug
// is the `object_id` of the `categories` section of the videos API.
const CATEGORIES: &[(&str, &str)] = &[
("hd", "HD"),
("big-tits", "Big Tits"),
("brunette", "Brunette"),
("blowjob", "Blowjob"),
("teens", "Teens (18+)"),
("amateur", "Amateur"),
("blonde", "Blonde"),
("hardcore", "Hardcore"),
("milf", "MILF"),
("anal", "Anal"),
("big-dick", "Big Cock"),
("asian", "Asian"),
("cumshots", "Cumshot"),
("big-butt", "Big Ass"),
("japanese", "Japanese"),
("fetish", "Fetish"),
("dildos-toys", "Toys"),
("small-tits", "Small Tits"),
("pov", "POV"),
("masturbate", "Masturbation"),
("babes", "Babe"),
("lesbian", "Lesbian"),
("deep-throat", "Deepthroat"),
("threesomes", "Threesome"),
("facial", "Facial"),
("interracial", "Interracial"),
("pornstars", "Pornstar"),
("solo-girl", "Solo Female"),
("pantyhose", "Stockings"),
("cunnilingus", "Cunnilingus"),
("mature", "Mature"),
("handjob", "Handjob"),
("tattoos", "Tattoo"),
("doggy-style", "Doggystyle"),
("fingering", "Fingering"),
("hairy", "Hairy"),
("jav-censored", "JAV Censored"),
("creampie", "Creampie"),
("lingerie", "Lingerie"),
("bdsm", "BDSM"),
("ebony", "Ebony"),
("redhead", "Red Head"),
("group-sex", "Group Sex"),
("latina", "Latina"),
("foot-fetish", "Foot Fetish"),
("bbw", "BBW"),
("outdoor", "Outdoor"),
("webcam", "Webcam"),
("european", "European"),
("dp", "Double Penetration"),
("cowgirl", "Cowgirl"),
("skinny", "Skinny"),
("gangbang", "Gangbang"),
("wife", "Wife"),
("german", "German"),
("jav-uncensored", "JAV Uncensored"),
("step-fantasy", "Step Fantasy"),
("couple", "Couple"),
("vintage", "Vintage"),
("female-orgasm", "Female Orgasm"),
("femdom", "Femdom"),
("squirting", "Squirt"),
("casting", "Casting"),
("compilation", "Compilation"),
("russian", "Russian"),
("shaved", "Shaved"),
("public", "Public"),
("massage", "Massage"),
("french", "French"),
("british", "British"),
("high-heels", "High Heels"),
("rimming", "Rimming"),
("swallow", "Swallow Cum"),
("cuckold", "Cuckold"),
("striptease", "Striptease"),
("strapon", "Strapon"),
("close-up", "Close-up"),
("american", "American"),
("grannies", "Granny"),
];
error_chain! {
foreign_links {
Io(std::io::Error);
Json(serde_json::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct TxxxProvider;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Latest { sort: &'static str },
Category { dir: String, sort: &'static str },
Search { query: String },
}
/// A listing card plus the pixel heights the site advertises per file suffix.
struct Stub {
item: VideoItem,
heights: HashMap<String, u32>,
}
impl TxxxProvider {
pub fn new() -> Self {
Self
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let mut category_options = vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}];
category_options.extend(CATEGORIES.iter().map(|(dir, title)| FilterOption {
id: (*dir).to_string(),
title: (*title).to_string(),
}));
Channel {
id: CHANNEL_ID.to_string(),
name: "TXXX".to_string(),
description: "Large free tube with HD videos, categories and search.".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=txxx.com".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Sort the TXXX feed or category archive.".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: "popular".to_string(), title: "Most Viewed".to_string() },
FilterOption { id: "rated".to_string(), title: "Top Rated".to_string() },
FilterOption { id: "longest".to_string(), title: "Longest".to_string() },
],
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Browse a TXXX category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: category_options,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: None,
}
}
fn slugify(value: &str) -> String {
let mut out = String::new();
let mut last_dash = true;
for c in value.trim().chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn category_dir(value: &str) -> Option<&'static str> {
let slug = Self::slugify(value);
if slug.is_empty() {
return None;
}
CATEGORIES
.iter()
.find_map(|(dir, title)| (*dir == slug || Self::slugify(title) == slug).then_some(*dir))
}
fn sort_key(sort: Option<&str>) -> &'static str {
match sort.map(|s| s.trim().to_ascii_lowercase()).as_deref() {
Some("popular") | Some("most viewed") | Some("most-popular") | Some("views") => {
"most-popular"
}
Some("rated") | Some("top rated") | Some("top-rated") | Some("rating") => "top-rated",
Some("longest") | Some("duration") => "longest",
_ => "latest-updates",
}
}
fn pick_target(query: Option<&str>, options: &ServerOptions) -> Target {
let sort = Self::sort_key(options.sort.as_deref());
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
let lower = q.to_ascii_lowercase();
for prefix in ["cat:", "category:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
let dir = Self::category_dir(rest)
.map(str::to_string)
.unwrap_or_else(|| Self::slugify(rest));
return Target::Category { dir, sort };
}
}
if let Some(dir) = Self::category_dir(q) {
return Target::Category { dir: dir.to_string(), sort };
}
return Target::Search { query: q.to_string() };
}
if let Some(cat) = options.categories.as_deref() {
let cat = cat.trim();
if !cat.is_empty() && !cat.eq_ignore_ascii_case("all") {
let dir = Self::category_dir(cat)
.map(str::to_string)
.unwrap_or_else(|| Self::slugify(cat));
return Target::Category { dir, sort };
}
}
Target::Latest { sort }
}
fn build_listing_url(target: &Target, page: u32, count: usize) -> String {
match target {
Target::Latest { sort } => format!(
"{BASE_URL}/api/json/videos2/86400/str/{sort}/{count}/..{page}.all...jsond"
),
Target::Category { dir, sort } => format!(
"{BASE_URL}/api/json/videos2/86400/str/{sort}/{count}/categories.{dir}.{page}.all...jsond"
),
Target::Search { query } => format!(
"{BASE_URL}/api/videos2.php?params=86400/str/relevance/{count}/search..{page}.all..&s={}",
utf8_percent_encode(query, NON_ALPHANUMERIC)
),
}
}
fn str_field(value: &Value, key: &str) -> String {
match value.get(key) {
Some(Value::String(s)) => s.trim().to_string(),
Some(Value::Number(n)) => n.to_string(),
_ => String::new(),
}
}
/// `||_hq.mp4|1278x720|3485|...||_tr.mp4|382x216|...` -> {"_hq.mp4": 720, ...}
fn parse_file_heights(file_formats: &str) -> HashMap<String, u32> {
let mut heights = HashMap::new();
for entry in file_formats.split("||").filter(|e| !e.is_empty()) {
let mut parts = entry.split('|');
let (Some(suffix), Some(dims)) = (parts.next(), parts.next()) else {
continue;
};
if let Some(h) = dims.split('x').nth(1).and_then(|h| h.parse::<u32>().ok()) {
heights.insert(suffix.to_string(), h);
}
}
heights
}
fn parse_listing(json: &str) -> Result<Vec<Stub>> {
let root: Value = serde_json::from_str(json)?;
if root.get("error").is_some() {
return Ok(vec![]);
}
let Some(videos) = root.get("videos").and_then(Value::as_array) else {
return Ok(vec![]);
};
let mut out = Vec::new();
for video in videos {
let id = Self::str_field(video, "video_id");
let dir = Self::str_field(video, "dir");
let title = Self::str_field(video, "title");
if id.is_empty() || dir.is_empty() || title.is_empty() {
continue;
}
if Self::str_field(video, "is_private") == "1" {
continue;
}
let url = format!("{BASE_URL}/videos/{id}/{dir}/");
let duration = parse_time_to_seconds(&Self::str_field(video, "duration"))
.and_then(|s| u32::try_from(s).ok())
.unwrap_or(0);
let thumb = Self::str_field(video, "scr");
let mut item = VideoItem::new(
id,
title,
url,
CHANNEL_ID.to_string(),
thumb,
duration,
);
item.views = Self::str_field(video, "video_viewed").parse::<u32>().ok();
if let Ok(rating) = Self::str_field(video, "rating").parse::<f32>() {
if rating > 0.0 {
// The site rates 0..5.
item.rating = Some((rating * 20.0).min(100.0));
}
}
item.aspectRatio = Some(16.0 / 9.0);
let preview = Self::str_field(video, "pv");
if !preview.is_empty() {
item.preview = Some(if preview.starts_with("http") {
preview
} else {
format!("https://{}", preview.trim_start_matches("//"))
});
}
let uploader = {
let channel = Self::str_field(video, "content_source_name");
if channel.is_empty() {
let name = Self::str_field(video, "display_name");
if name.is_empty() { Self::str_field(video, "username") } else { name }
} else {
channel
}
};
if !uploader.is_empty() {
item.uploader = Some(uploader);
let user_id = Self::str_field(video, "user_id");
if !user_id.is_empty() && user_id != "0" {
item.uploaderId = Some(format!("{CHANNEL_ID}:{user_id}"));
}
}
let tags: Vec<String> = Self::str_field(video, "categories")
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if !tags.is_empty() {
item.tags = Some(tags);
}
let posted = Self::str_field(video, "post_date");
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&posted, "%Y-%m-%d %H:%M:%S") {
item.uploadedAt = u64::try_from(dt.and_utc().timestamp()).ok();
}
out.push(Stub {
item,
heights: Self::parse_file_heights(&Self::str_field(video, "file_formats")),
});
}
Ok(out)
}
/// `video_url` in `/api/videofile.php` is base64 of the `/get_file/...`
/// path with Cyrillic look-alike letters swapped in and `~` for `=`.
fn decode_video_url(raw: &str) -> Option<String> {
let mapped: String = raw
.chars()
.map(|c| match c {
'А' => 'A', 'В' => 'B', 'С' => 'C', 'Е' => 'E', 'М' => 'M', 'Н' => 'H',
'К' => 'K', 'О' => 'O', 'Р' => 'P', 'Т' => 'T', 'Х' => 'X',
'а' => 'a', 'с' => 'c', 'е' => 'e', 'о' => 'o', 'р' => 'p', 'х' => 'x',
'у' => 'y', 'і' => 'i', 'ј' => 'j',
'~' => '=',
other => other,
})
.collect();
let trimmed = mapped.trim_end_matches('=');
let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(trimmed)
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(trimmed))
.ok()?;
let path = String::from_utf8(bytes).ok()?;
if path.starts_with("http") {
Some(path)
} else if path.starts_with("/get_file/") {
Some(format!("{BASE_URL}{path}"))
} else {
None
}
}
/// Builds the playable formats (best first) from the videofile response.
fn parse_formats(json: &str, heights: &HashMap<String, u32>) -> Vec<VideoFormat> {
let Ok(Value::Array(entries)) = serde_json::from_str::<Value>(json) else {
return vec![];
};
let mut formats: Vec<(u32, VideoFormat)> = Vec::new();
for entry in entries {
let suffix = Self::str_field(&entry, "format");
if suffix.is_empty() || suffix == "_tr.mp4" {
continue;
}
let Some(url) = Self::decode_video_url(&Self::str_field(&entry, "video_url")) else {
continue;
};
let height = heights.get(&suffix).copied().unwrap_or(0);
let quality = if height > 0 { format!("{height}p") } else { "auto".to_string() };
let mut format = VideoFormat::new(url, quality.clone(), "mp4".to_string())
.format_id(format!("mp4-{}", suffix.trim_end_matches(".mp4").trim_start_matches('_')));
if height > 0 {
format = format.height(height);
}
formats.push((height, format));
}
formats.sort_by(|a, b| b.0.cmp(&a.0));
formats.into_iter().map(|(_, f)| f).collect()
}
async fn enrich(stubs: Vec<Stub>, requester: Requester) -> Vec<VideoItem> {
let referer = format!("{BASE_URL}/");
stream::iter(stubs.into_iter().map(|stub| {
let mut requester = requester.clone();
let referer = referer.clone();
async move {
let Stub { mut item, heights } = stub;
let url = format!(
"{BASE_URL}/api/videofile.php?video_id={}&lifetime=8640000",
item.id
);
let body = requester
.get_with_headers(&url, vec![("Referer".to_string(), referer)], None)
.await
.unwrap_or_default();
let formats = Self::parse_formats(&body, &heights);
if formats.is_empty() {
// The page URL is not yt-dlp-resolvable, so an item without
// formats could never play.
return None;
}
item.formats = Some(formats);
Some(item)
}
}))
.buffered(ENRICH_CONCURRENCY)
.filter_map(|item| async move { item })
.collect::<Vec<_>>()
.await
}
async fn fetch(
target: &Target,
page: u32,
count: usize,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
let url = Self::build_listing_url(target, page, count);
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch");
let body = requester
.get_with_headers(
&url,
vec![("Referer".to_string(), format!("{BASE_URL}/"))],
None,
)
.await
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))?;
let stubs = Self::parse_listing(&body)?;
Ok(Self::enrich(stubs, requester).await)
}
}
#[async_trait]
impl Provider for TxxxProvider {
async fn get_videos(
&self,
_cache: VideoCache,
_pool: DbPool,
_sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u32>().unwrap_or(1).max(1);
let count = per_page.parse::<usize>().unwrap_or(30).clamp(1, MAX_COUNT);
let target = Self::pick_target(query.as_deref(), &options);
match Self::fetch(&target, page, count, &options).await {
Ok(items) => items,
Err(e) => {
report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> ServerOptions {
ServerOptions {
featured: None,
category: None,
sites: None,
filter: None,
language: None,
public_url_base: None,
requester: None,
network: None,
stars: None,
categories: None,
duration: None,
sort: None,
sexuality: None,
client_version: None,
}
}
#[test]
fn builds_urls() {
assert_eq!(
TxxxProvider::build_listing_url(&Target::Latest { sort: "latest-updates" }, 2, 30),
"https://txxx.me/api/json/videos2/86400/str/latest-updates/30/..2.all...jsond"
);
assert_eq!(
TxxxProvider::build_listing_url(
&Target::Category { dir: "milf".into(), sort: "most-popular" },
1,
60
),
"https://txxx.me/api/json/videos2/86400/str/most-popular/60/categories.milf.1.all...jsond"
);
assert_eq!(
TxxxProvider::build_listing_url(&Target::Search { query: "big tits".into() }, 3, 60),
"https://txxx.me/api/videos2.php?params=86400/str/relevance/60/search..3.all..&s=big%20tits"
);
}
#[test]
fn routes_queries() {
let o = opts();
assert_eq!(
TxxxProvider::pick_target(Some("Big Tits"), &o),
Target::Category { dir: "big-tits".into(), sort: "latest-updates" }
);
assert_eq!(
TxxxProvider::pick_target(Some("cat:Big Cock"), &o),
Target::Category { dir: "big-dick".into(), sort: "latest-updates" }
);
assert_eq!(
TxxxProvider::pick_target(Some("mia khalifa"), &o),
Target::Search { query: "mia khalifa".into() }
);
assert_eq!(
TxxxProvider::pick_target(None, &o),
Target::Latest { sort: "latest-updates" }
);
}
#[test]
fn decodes_cyrillic_obfuscated_video_url() {
let raw = "L2dldF9maWxlLzI1Lzg2\u{041c}D\u{0415}zNWFkND\u{0415}zODNm\u{041c}zI0ZmJhYjJlZTg3\u{041c}jRkYm\u{041c}zOWNiZTIwNjJl\u{041c}ci8y\u{041c}TgwOD\u{0410}w\u{041c}\u{0421}8y\u{041c}TgwODcwNS8y\u{041c}TgwODcwNV9ocS5tcDQvP2Q9\u{041c}czQ4NSZicj0y\u{041c}TgmdGk9\u{041c}Tc5\u{041c}TY0NjYyOQ~~";
assert_eq!(
TxxxProvider::decode_video_url(raw).as_deref(),
Some("https://txxx.me/get_file/25/860135ad41383f324fbab2ee8724dbc39cbe2062e2/21808000/21808705/21808705_hq.mp4/?d=3485&br=218&ti=1791646629")
);
}
#[test]
fn parses_listing_and_formats() {
let json = r#"{"videos":[{"video_id":"21808705","title":"A title","dir":"a-title",
"duration":"58:05","video_viewed":"1200","rating":"3.9000","post_date":"2026-10-09 20:34:06",
"file_formats":"||_hq.mp4|1278x720|3485|507453306|117|30|0||_tr.mp4|382x216|7|202168|0|0|0",
"categories":"American,MILF","display_name":"someone","user_id":"6119563",
"scr":"https://tn.txxx.tube/a.jpg","pv":"vp2.txxx.com/c14/a_tr.mp4","is_private":"0"},
{"video_id":"2","title":"Private","dir":"p","duration":"1:00","is_private":"1"}]}"#;
let stubs = TxxxProvider::parse_listing(json).unwrap();
assert_eq!(stubs.len(), 1);
let item = &stubs[0].item;
assert_eq!(item.url, "https://txxx.me/videos/21808705/a-title/");
assert_eq!(item.duration, 58 * 60 + 5);
assert_eq!(item.views, Some(1200));
assert_eq!(item.uploaderId.as_deref(), Some("txxx:6119563"));
assert_eq!(item.preview.as_deref(), Some("https://vp2.txxx.com/c14/a_tr.mp4"));
assert_eq!(item.tags.as_ref().unwrap(), &vec!["American".to_string(), "MILF".to_string()]);
assert_eq!(stubs[0].heights.get("_hq.mp4"), Some(&720));
}
}