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:
5
build.rs
5
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
621
src/providers/txxx.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user