diff --git a/build.rs b/build.rs index 165d54b..687ef56 100644 --- a/build.rs +++ b/build.rs @@ -361,6 +361,11 @@ const PROVIDERS: &[ProviderDef] = &[ module: "cumlouder", ty: "CumlouderProvider", }, + ProviderDef { + id: "txxx", + module: "txxx", + ty: "TxxxProvider", + }, ProviderDef { id: "fullporner", module: "fullporner", diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index b2d9693..7ebcd40 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -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 = ''` injected by `/api/state/v1/preload`, cached in an `Arc>>` 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=` (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=…` ``, 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=…` ``, 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=` 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>>` 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 diff --git a/src/providers/cumlouder.rs b/src/providers/cumlouder.rs index 975d12a..ac4aec9 100644 --- a/src/providers/cumlouder.rs +++ b/src/providers/cumlouder.rs @@ -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::().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), diff --git a/src/providers/txxx.rs b/src/providers/txxx.rs new file mode 100644 index 0000000..6dba0db --- /dev/null +++ b/src/providers/txxx.rs @@ -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, +} + +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 { + 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::().ok()) { + heights.insert(suffix.to_string(), h); + } + } + heights + } + + fn parse_listing(json: &str) -> Result> { + 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::().ok(); + if let Ok(rating) = Self::str_field(video, "rating").parse::() { + 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 = 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 { + 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) -> Vec { + let Ok(Value::Array(entries)) = serde_json::from_str::(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, requester: Requester) -> Vec { + 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::>() + .await + } + + async fn fetch( + target: &Target, + page: u32, + count: usize, + options: &ServerOptions, + ) -> Result> { + 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, + page: String, + per_page: String, + options: ServerOptions, + ) -> Vec { + let page = page.parse::().unwrap_or(1).max(1); + let count = per_page.parse::().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 { + 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)); + } +}