ytdlp command and xgroovy

This commit is contained in:
Simon
2026-07-06 16:07:54 +00:00
parent 55d687c361
commit d1a05de50e
83 changed files with 703 additions and 0 deletions

View File

@@ -356,6 +356,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "xhamster",
ty: "XhamsterProvider",
},
ProviderDef {
id: "xgroovy",
module: "xgroovy",
ty: "XgroovyProvider",
},
ProviderDef {
id: "xvideos",
module: "xvideos",

View File

@@ -77,6 +77,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `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/`); search queries map to `/tag/{slug}/` (eporner redirects all keyword searches to tag pages — 404 tag pages still return related content); supports sort: new/popular/rated/best; 65 hardcoded categories via `cat:`, `tag:`, `pornstar:`, `uploader:` query shortcuts; background-loads pornstar name→URL map from `/pornstar-list/`; yt-dlp resolves `video.url` natively (Eporner extractor); no proxy needed. |
| `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. |
| `xgroovy` | `mainstream-tube` | no | no | KVS (Kernel Video Sharing)-style HTML scraper for xgroovy.com; card selector `div.item[data-video-id]` with `data-views`/`data-rating` as clean numeric attributes directly on the card (no text-parsing needed, unlike xhamster); title/url from `a.popito[href] > strong.title`; thumbnails at `i.xgroovy.com` (no proxy, no Referer needed) from `img.thumb[data-jpg|src]`, preview clip from `img[data-preview]`; duration rendered as free text ("N min"/"N sec", not colon-separated) via a bespoke `parse_duration` helper; path-segment pagination (`/new/` = page 1, `/new/2/` = page 2, unlike xhamster's `?page=N` query string); feeds: `/new/` (default), categories via `/categories/{slug}/` (73 hardcoded categories as `categories` option), pornstars via `/pornstars/{slug}/`, channels via `/channels/{slug}/`; search via `/search/{dashed-query}/` (multi-word queries are lowercased and dash-joined); uploader kind inferred from URL path (`/pornstars/` vs `/channels/`) via `div.author-link > a`; supports `cat:`/`category:` query shortcuts plus static category name matching; yt-dlp resolves `video.url` via the generic html5-embed extractor (no dedicated xgroovy extractor, but resolves cleanly to a 1080p format); no proxy needed. |
| `xhamster` | `mainstream-tube` | no | no | HTML scraper for xhamster.com; card selector `div[data-video-type="video"]` with `data-video-id`; thumbnails via `img[data-role="thumb-preview-img"]` at `ic-vt-nss.xhcdn.com` (no proxy, no Referer needed); pagination via `?page=N` query param (browse feeds use infinite-scroll so only search reliably returns different content per page); feeds: `/newest` (default), `/most-viewed`, `/best`; categories via `/categories/{slug}`; channels via `/channels/{slug}`; 43 hardcoded categories as `categories` option; uploader type inferred from URL path (`/channels/` → channel, `/creators/` → creator, `/pornstars/` → pornstar); supports `cat:`/`category:` and `channel:` query shortcuts, plus static category name matching; preview mp4 clips from `data-previewvideo` attribute; yt-dlp resolves `video.url` natively (xHamster extractor, 28 formats); no proxy needed. |
| `camsoda` | `live-cams` | no | no | Live-cam provider for camsoda.com (chaturbate-style — `live` performers streaming now, `video.url` = the room page, `is_live=true`, no `formats`). camsoda.com is hard Cloudflare-protected: direct requests and yt-dlp both get HTTP 403, and FlareSolverr was unreliable during development, so the live-browse API is reached through the shared requester's Jina mirror fallback (`r.jina.ai/http://...`, `X-Return-Format: html`); Jina rate-limits per IP, so the provider caches each fetched feed URL for 60s (and serves stale items on a 429 rather than emptying the feed), and a single-provider build (`HOT_TUB_PROVIDER=camsoda`) validates most cleanly (one fetch at a time). Endpoint (found in the non-CF static `main.js` bundle): `GET https://www.camsoda.com/api/v1/browse/react{route}?p=N` returning a body with a top-level `userList` array (Jina wraps it in `<pre>`, so the provider slices out the `{...}` and parses it with `serde_json::Value`, like the chaturbate provider). Per-cam fields: `username`→id + room URL (`/{username}`), `subjectText`→title (html-decoded, falls back to `displayName`), `displayName`→uploader, `connectionCount`→views (string or number tolerated), `thumbUrl`→thumb (direct `media.livemediahost.com` CDN, no proxy/referer), `status` (skip `offline`), `vr`/`private` surfaced as tags. Category option `category` uses verified `browse/react` routes — `all`(featured)/`girls`/`trans`/`couples`/`voyeur-cams`/`new` (`/male` is NOT a path route, camsoda gates male via `gender-hide`); `cat:`/`category:` prefixes and a bare keyword matching a category id route there too. Search: `GET browse/react/search/{dashed-query}?sortByConnection=1` (single connection-sorted result set, no real paging). Playback: `video.url` is the live room page; the room and the token-gated edge HLS (`*.livemediahost.com`) are both Cloudflare-protected, so HLS can't be resolved server-side and no `formats` are populated — yt-dlp has a `Camsoda` live extractor that resolves the room on a non-CF-blocked client, and `check.py` reports the sandbox's CF 403s as expected warnings (`www.camsoda.com` is in its CF allowlist), not errors. The earlier recorded-`/media` JSON scrape was replaced because clips were token-gated/non-playable; live cams are the site's actual product. No proxy needed. |
| `xvideos` | `mainstream-tube` | no | no | HTML scraper for xvideos.com; handles two card formats: homepage (`div.thumb-block[data-id][data-eid]`) uses `p.title a[title]` + `data-pvv` on img, best-of-month page uses `div.thumb-block.video[data-video=JSON]` with `div.title a` text + `previewVideo` JSON key; thumbnails at `thumb-cdn77.xvideos-cdn.com` / `thumbs-gcore.xvideos-cdn.com` (no proxy needed); latest: `/` (page 1) / `/new/{N-1}` (page N≥2); best-of-month: `/best/{YYYY-MM}` (previous calendar month), page N: `/best/{YYYY-MM}/{N-1}`; search: `/?k={query}` / `/?k={query}&p={N-1}` (0-indexed); tag shortcuts: `/tags/{slug}/{N-1}`; category shortcuts: `/c/{Name}-{ID}/{N-1}` (38 hardcoded categories); `cat:`, `tag:`, `uploader:` query prefix routing; yt-dlp resolves `video.url` natively (XVideos extractor → HLS formats); CDN preview mp4 in `preview` field; no proxy needed. |

View File

@@ -177,6 +177,7 @@ impl Provider for AllProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
})
}
}

View File

@@ -86,6 +86,7 @@ impl AllPornStreamProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -129,6 +129,7 @@ impl AnimeidhentaiProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -244,6 +244,7 @@ impl ArchivebateProvider {
],
nsfw: true,
cacheDuration: Some(900),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -240,6 +240,7 @@ impl BeegProvider {
],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -154,6 +154,7 @@ impl BlowjobsproProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -90,6 +90,7 @@ impl CamsodaProvider {
}],
nsfw: true,
cacheDuration: Some(60),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -68,6 +68,7 @@ impl ChaturbateProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -114,6 +114,7 @@ impl ClapdatProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -225,6 +225,7 @@ impl EpornerProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -94,6 +94,7 @@ impl EromeProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -266,6 +266,7 @@ impl FikfapProvider {
],
nsfw: true,
cacheDuration: Some(60),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -415,6 +415,7 @@ impl FreepornvideosxxxProvider {
],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -128,6 +128,7 @@ impl FreeusepornProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -88,6 +88,7 @@ impl FullpornerProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -101,6 +101,7 @@ impl FypttProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -171,6 +171,7 @@ impl HanimeProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -249,6 +249,7 @@ impl HeavyfetishProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -111,6 +111,7 @@ impl HentaihavenProvider {
options: vec![],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -150,6 +150,7 @@ impl HentaitvProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -71,6 +71,7 @@ impl HomoxxxProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(&self, cache: VideoCache, page: u8, sort: &str) -> Result<Vec<VideoItem>> {

View File

@@ -304,6 +304,7 @@ impl HotbunnyProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
}

View File

@@ -162,6 +162,7 @@ impl HqpornerProvider {
options: vec![],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -222,6 +222,7 @@ impl HsexProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -181,6 +181,7 @@ impl HypnotubeProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -119,6 +119,7 @@ impl JableProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -97,6 +97,7 @@ impl JavtifulProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -383,6 +383,7 @@ impl KwikyProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
}

View File

@@ -226,6 +226,7 @@ impl MissavProvider {
],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -922,6 +922,7 @@ pub trait Provider: Send + Sync {
options: vec![],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
})
}
@@ -988,6 +989,7 @@ mod tests {
options: Vec::<ChannelOption>::new(),
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -120,6 +120,7 @@ impl NoodlemagazineProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -71,6 +71,7 @@ impl OkpornProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(&self, cache: VideoCache, page: u8, sort: &str) -> Result<Vec<VideoItem>> {

View File

@@ -126,6 +126,7 @@ impl OkxxxProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(&self, cache: VideoCache, page: u8, sort: &str) -> Result<Vec<VideoItem>> {

View File

@@ -437,6 +437,7 @@ impl OmgxxxProvider {
],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -52,6 +52,7 @@ impl ParadisehillProvider {
options: vec![],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -126,6 +126,7 @@ impl PerfectgirlsProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(&self, cache: VideoCache, page: u8, sort: &str) -> Result<Vec<VideoItem>> {

View File

@@ -84,6 +84,7 @@ impl PerverzijaProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -109,6 +109,7 @@ impl PimpbunnyProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -135,6 +135,7 @@ impl PmvhavenProvider {
],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -68,6 +68,7 @@ impl Porn00Provider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(

View File

@@ -147,6 +147,7 @@ impl Porn4fansProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -154,6 +154,7 @@ impl PorndishProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -122,6 +122,7 @@ impl PornhatProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(

View File

@@ -223,6 +223,7 @@ impl Pornhd3xProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -211,6 +211,7 @@ impl PornhubProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -338,6 +338,7 @@ impl PornhubShortiesProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -210,6 +210,7 @@ impl PornmzProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -173,6 +173,7 @@ impl PorntrexProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -67,6 +67,7 @@ impl PornxpProvider {
],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -77,6 +77,7 @@ impl PornzogProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -220,6 +220,7 @@ impl RedgifsProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -48,6 +48,7 @@ impl RedtubeProvider {
options: vec![],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(

View File

@@ -77,6 +77,7 @@ impl Rule34genProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -85,6 +85,7 @@ impl Rule34videoProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -223,6 +223,7 @@ impl SextbProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -232,6 +232,7 @@ impl ShooshtimeProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -80,6 +80,7 @@ impl SpankbangProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -229,6 +229,7 @@ impl SupjavProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -107,6 +107,7 @@ impl SxyprnProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(

View File

@@ -169,6 +169,7 @@ impl ThaipornTvProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -135,6 +135,7 @@ impl ThepornbunnyProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -326,6 +326,7 @@ impl TikpornProvider {
}],
nsfw: true,
cacheDuration: Some(900),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -102,6 +102,7 @@ impl TnaflixProvider {
],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -87,6 +87,7 @@ impl TokyomotionProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -80,6 +80,7 @@ impl Tube8Provider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -49,6 +49,7 @@ impl ViralxxxpornProvider {
options: vec![],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -499,6 +499,7 @@ impl VjavProvider {
],
nsfw: true,
cacheDuration: Some(60),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -205,6 +205,7 @@ impl VrpornProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -90,6 +90,7 @@ impl WowxxxProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -92,6 +92,7 @@ impl XfreeProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

615
src/providers/xgroovy.rs Normal file
View File

@@ -0,0 +1,615 @@
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::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use htmlentity::entity::{ICodedDataTrait, decode};
use scraper::{ElementRef, Html, Selector};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["mainstream", "tube", "hd", "general"],
};
const BASE_URL: &str = "https://xgroovy.com";
const CHANNEL_ID: &str = "xgroovy";
const FIREFOX_UA: &str =
"Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
const HTML_ACCEPT: &str =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
error_chain! {
foreign_links {
Io(std::io::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
// Static category list scraped from https://xgroovy.com/tags/ (73 categories)
const CATEGORIES: &[(&str, &str)] = &[
("ai", "AI"),
("amateur", "Amateur"),
("anal", "Anal"),
("arab", "Arab"),
("asian", "Asian"),
("bbc", "BBC"),
("bbw", "BBW"),
("bdsm", "BDSM"),
("beautiful-girl", "Beautiful Girl"),
("big-ass", "Big Ass"),
("big-cock", "Big Cock"),
("big-tits", "Big Tits"),
("bisexual", "Bisexual"),
("blowjob", "Blowjob"),
("brazilian", "Brazilian"),
("british", "British"),
("bukkake", "Bukkake"),
("cartoon", "Cartoon"),
("casting", "Casting"),
("celebrity", "Celebrity"),
("cheating", "Cheating"),
("chinese", "Chinese"),
("compilation", "Compilation"),
("cosplay", "Cosplay"),
("creampie", "Creampie"),
("cuckold", "Cuckold"),
("cumshot", "Cumshot"),
("double-penetration", "Double Penetration"),
("ebony", "Ebony"),
("erotic", "Erotic"),
("family", "Family"),
("femdom", "Femdom"),
("first-time", "First Time"),
("fisting", "Fisting"),
("french", "French"),
("gangbang", "Gangbang"),
("german", "German"),
("groupsex", "Groupsex"),
("hairy", "Hairy"),
("handjob", "Handjob"),
("hentai", "Hentai"),
("indian", "Indian"),
("interracial", "Interracial"),
("italian", "Italian"),
("japanese", "Japanese"),
("latina", "Latina"),
("lesbians", "Lesbians"),
("massage", "Massage"),
("mature", "Mature"),
("milf", "MILF"),
("mom", "Mom"),
("office", "Office"),
("old-young", "Old & Young"),
("orgasm", "Orgasm"),
("outdoor", "Outdoor"),
("petite", "Petite"),
("pov", "POV"),
("public", "Public"),
("reality", "Reality"),
("rough", "Rough"),
("russian", "Russian"),
("school", "School"),
("small-tits", "Small Tits"),
("solo", "Solo"),
("squirt", "Squirt"),
("stockings", "Stockings"),
("teens", "Teens"),
("threesome", "Threesome"),
("toys", "Toys"),
("uniform", "Uniform"),
("vintage", "Vintage"),
("webcam", "Webcam"),
("young", "Young"),
];
#[derive(Debug, Clone)]
enum Target {
New,
Search(String),
Category(String),
Pornstar(String),
Channel(String),
}
#[derive(Debug, Clone)]
pub struct XgroovyProvider;
impl XgroovyProvider {
pub fn new() -> Self {
Self
}
fn build_channel(&self, _cv: ClientVersion) -> Channel {
let mut cat_options = vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}];
for (slug, label) in CATEGORIES {
cat_options.push(FilterOption {
id: slug.to_string(),
title: label.to_string(),
});
}
Channel {
id: CHANNEL_ID.to_string(),
name: "XGroovy".to_string(),
description:
"XGroovy — free porn tube with newest, category, pornstar, channel, and search routing."
.to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=xgroovy.com".to_string(),
status: "active".to_string(),
categories: CATEGORIES
.iter()
.map(|(_, label)| label.to_string())
.collect(),
options: vec![ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Browse an XGroovy category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: cat_options,
multiSelect: false,
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value)
.map_err(|e| Error::from(format!("selector `{value}` parse failed: {e}")))
}
fn decode_html(text: &str) -> String {
decode(text.as_bytes())
.to_string()
.unwrap_or_else(|_| text.to_string())
}
fn text_of(el: &ElementRef<'_>) -> String {
el.text()
.collect::<Vec<_>>()
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn normalize_key(s: &str) -> String {
s.trim()
.replace(['-', '_'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
/// xgroovy uses path-segment pagination: page 1 has no trailing segment,
/// page N>=2 appends `/{N}/` to the base path (e.g. `/new/`, `/new/2/`).
fn target_url(target: &Target, page: u16) -> String {
let base = match target {
Target::New => format!("{BASE_URL}/new/"),
Target::Search(q) => {
let encoded = q.trim().replace(' ', "-").to_ascii_lowercase();
format!("{BASE_URL}/search/{encoded}/")
}
Target::Category(slug) => format!("{BASE_URL}/categories/{slug}/"),
Target::Pornstar(slug) => format!("{BASE_URL}/pornstars/{slug}/"),
Target::Channel(slug) => format!("{BASE_URL}/channels/{slug}/"),
};
if page <= 1 {
base
} else {
format!("{base}{page}/")
}
}
/// xgroovy renders duration as free text like "12 min" or "45 sec"
/// rather than a colon-separated timestamp.
fn parse_duration(text: &str) -> Option<u32> {
let text = text.trim().to_ascii_lowercase();
let (num, unit) = text.split_once(char::is_whitespace)?;
let value: u32 = num.parse().ok()?;
match unit {
"min" | "mins" | "minute" | "minutes" => Some(value * 60),
"sec" | "secs" | "second" | "seconds" => Some(value),
"hour" | "hours" | "hr" | "hrs" => Some(value * 3600),
_ => None,
}
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![
("User-Agent".to_string(), FIREFOX_UA.to_string()),
("Accept".to_string(), HTML_ACCEPT.to_string()),
("Referer".to_string(), referer.to_string()),
]
}
fn parse_list_page(html: &str) -> Result<Vec<VideoItem>> {
let document = Html::parse_document(html);
let card_sel = Self::selector("div.item[data-video-id]")?;
let link_sel = Self::selector("a.popito")?;
let img_sel = Self::selector("img.thumb")?;
let title_sel = Self::selector("strong.title")?;
let author_link_sel = Self::selector("div.author-link a")?;
let duration_sel = Self::selector("div.duration")?;
let mut items = Vec::new();
for card in document.select(&card_sel) {
let id = match card.value().attr("data-video-id") {
Some(v) if !v.is_empty() => v.to_string(),
_ => continue,
};
let link = match card.select(&link_sel).next() {
Some(el) => el,
None => continue,
};
let href = link.value().attr("href").unwrap_or_default();
if href.is_empty() {
continue;
}
let page_url = if href.starts_with("https://") {
href.to_string()
} else {
format!("{BASE_URL}{href}")
};
let img = card.select(&img_sel).next();
let thumb = img
.as_ref()
.and_then(|el| {
el.value()
.attr("data-jpg")
.or_else(|| el.value().attr("src"))
})
.unwrap_or_default()
.to_string();
let preview = img
.as_ref()
.and_then(|el| el.value().attr("data-preview"))
.map(str::to_string);
let title = card
.select(&title_sel)
.next()
.map(|el| Self::decode_html(&Self::text_of(&el)))
.filter(|v| !v.is_empty())
.unwrap_or_default();
if title.is_empty() {
continue;
}
let views: Option<u32> = card
.value()
.attr("data-views")
.and_then(|v| v.parse::<u32>().ok());
let rating: Option<f32> = card
.value()
.attr("data-rating")
.and_then(|v| v.parse::<f32>().ok())
.map(|v| v / 100.0);
let duration = card
.select(&duration_sel)
.next()
.map(|el| Self::text_of(&el))
.and_then(|text| Self::parse_duration(&text))
.unwrap_or(0);
let author_el = card.select(&author_link_sel).next();
let uploader = author_el
.as_ref()
.map(|el| Self::decode_html(&Self::text_of(el)))
.filter(|v| !v.is_empty());
let uploader_url = author_el
.and_then(|el| el.value().attr("href"))
.map(|v| {
if v.starts_with("https://") {
v.to_string()
} else {
format!("{BASE_URL}{v}")
}
});
let mut item = VideoItem::new(
id,
title,
page_url,
CHANNEL_ID.to_string(),
thumb,
duration,
);
item.views = views;
item.rating = rating;
item.preview = preview;
item.uploader = uploader;
item.uploaderUrl = uploader_url.clone();
if let Some(url) = &uploader_url {
let slug = url
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or_default();
if !slug.is_empty() {
let kind = if url.contains("/channels/") {
"channel"
} else if url.contains("/pornstars/") {
"pornstar"
} else {
"creator"
};
item.uploaderId = Some(format!("{CHANNEL_ID}:{kind}:{slug}"));
}
}
items.push(item);
}
Ok(items)
}
fn resolve_query_target(&self, query: &str) -> Target {
let trimmed = query.trim();
if let Some((kind, value)) = trimmed.split_once(':') {
let slug = value.trim().replace(' ', "-").to_ascii_lowercase();
if !slug.is_empty() {
match kind.trim().to_ascii_lowercase().as_str() {
"cat" | "category" => return Target::Category(slug),
"pornstar" | "pornstars" | "star" => return Target::Pornstar(slug),
"channel" | "channels" => return Target::Channel(slug),
_ => {}
}
}
}
// Check static category list by label or slug
let normalized = Self::normalize_key(trimmed);
for (slug, label) in CATEGORIES {
if Self::normalize_key(label) == normalized || Self::normalize_key(slug) == normalized
{
return Target::Category(slug.to_string());
}
}
Target::Search(trimmed.to_string())
}
fn resolve_option_target(&self, options: &ServerOptions) -> Target {
if let Some(cat) = options.categories.as_deref() {
if cat != "all" && !cat.is_empty() {
return Target::Category(cat.to_string());
}
}
Target::New
}
async fn fetch_target(
&self,
cache: VideoCache,
target: Target,
page: u16,
per_page: usize,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let url = Self::target_url(&target, page);
let cache_key = format!("{url}#per={per_page}");
if let Some((ts, cached)) = cache.get(&cache_key) {
if ts.elapsed().unwrap_or_default().as_secs() < 300 {
return Ok(cached.clone());
}
}
let mut requester = requester_or_default(&options, CHANNEL_ID, "xgroovy.fetch_target");
let html = requester
.get_with_headers(&url, Self::html_headers(&url), None)
.await
.map_err(|e| Error::from(format!("request failed for {url}: {e}")))?;
if html.trim().is_empty() {
return Err(Error::from(format!("empty response for {url}")));
}
let all = Self::parse_list_page(&html)?;
let items: Vec<VideoItem> = all.into_iter().take(per_page.max(1)).collect();
if !items.is_empty() {
cache.insert(cache_key, items.clone());
}
Ok(items)
}
}
#[async_trait]
impl Provider for XgroovyProvider {
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::<u16>().unwrap_or(1).max(1);
let per_page = per_page.parse::<usize>().unwrap_or(10).clamp(1, 60);
let target = match query {
Some(q) if !q.trim().is_empty() => self.resolve_query_target(q.trim()),
_ => self.resolve_option_target(&options),
};
match self
.fetch_target(cache, target, page, per_page, options)
.await
{
Ok(items) => items,
Err(e) => {
report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, cv: ClientVersion) -> Option<Channel> {
Some(self.build_channel(cv))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_target_url_pagination() {
assert_eq!(
XgroovyProvider::target_url(&Target::New, 1),
"https://xgroovy.com/new/"
);
assert_eq!(
XgroovyProvider::target_url(&Target::New, 2),
"https://xgroovy.com/new/2/"
);
assert_eq!(
XgroovyProvider::target_url(&Target::Search("hot blonde".to_string()), 1),
"https://xgroovy.com/search/hot-blonde/"
);
assert_eq!(
XgroovyProvider::target_url(&Target::Category("amateur".to_string()), 2),
"https://xgroovy.com/categories/amateur/2/"
);
assert_eq!(
XgroovyProvider::target_url(&Target::Pornstar("abella-danger".to_string()), 1),
"https://xgroovy.com/pornstars/abella-danger/"
);
assert_eq!(
XgroovyProvider::target_url(&Target::Channel("brazzers".to_string()), 3),
"https://xgroovy.com/channels/brazzers/3/"
);
}
#[test]
fn resolves_category_by_label_and_slug() {
let p = XgroovyProvider::new();
assert!(matches!(
p.resolve_query_target("amateur"),
Target::Category(s) if s == "amateur"
));
assert!(matches!(
p.resolve_query_target("Big Ass"),
Target::Category(s) if s == "big-ass"
));
assert!(matches!(
p.resolve_query_target("Old & Young"),
Target::Category(s) if s == "old-young"
));
}
#[test]
fn resolves_explicit_shortcuts() {
let p = XgroovyProvider::new();
assert!(matches!(
p.resolve_query_target("cat:milf"),
Target::Category(s) if s == "milf"
));
assert!(matches!(
p.resolve_query_target("channel:brazzers"),
Target::Channel(s) if s == "brazzers"
));
assert!(matches!(
p.resolve_query_target("pornstar:abella-danger"),
Target::Pornstar(s) if s == "abella-danger"
));
}
#[test]
fn falls_through_to_search() {
let p = XgroovyProvider::new();
assert!(matches!(
p.resolve_query_target("some unknown query"),
Target::Search(_)
));
}
#[test]
fn parses_listing_card() {
let html = r#"
<html><body>
<div class="item " data-video-id="337143" data-history-id="337143" data-views="330079" data-rating="75.942">
<a href="https://xgroovy.com/videos/337143/turkish-18-yo-girl-from-college-gives-a-blowjob-and-gets-fucked-pov/" class="popito">
<div class="img">
<img fetchpriority="high" class="thumb " src="https://i.xgroovy.com/contents/videos_screenshots/337000/337143/608x342/1.jpg" data-jpg="https://i.xgroovy.com/contents/videos_screenshots/337000/337143/640x360/1.jpg" alt="Turkish 18 yo girl" data-cnt="28" data-preview="https://preview.xgroovy.com/videos/337000/337143/337143_pr640.mp4" width="640" height="360"/>
</div>
<strong class="title">
Turkish 18 yo girl from college gives a blowjob and gets fucked POV
</strong>
</a>
<div class="wrap">
<div class="author-link"><a href="https://xgroovy.com/pornstars/assspanker/"><i class="mi star"></i>AssSpanker</a></div>
<div class="views">330k views</div>
<div class="duration">12 min</div>
<div class="rating positive"> <i class="mi thumb_up"></i>75%</div>
</div>
</div>
</body></html>
"#;
let items = XgroovyProvider::parse_list_page(html).expect("parse should succeed");
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.id, "337143");
assert_eq!(
item.title,
"Turkish 18 yo girl from college gives a blowjob and gets fucked POV"
);
assert_eq!(
item.url,
"https://xgroovy.com/videos/337143/turkish-18-yo-girl-from-college-gives-a-blowjob-and-gets-fucked-pov/"
);
assert_eq!(
item.thumb,
"https://i.xgroovy.com/contents/videos_screenshots/337000/337143/640x360/1.jpg"
);
assert_eq!(item.duration, 720);
assert_eq!(item.views, Some(330079));
assert_eq!(item.rating, Some(75.942 / 100.0));
assert_eq!(item.uploader.as_deref(), Some("AssSpanker"));
assert_eq!(
item.uploaderUrl.as_deref(),
Some("https://xgroovy.com/pornstars/assspanker/")
);
assert_eq!(
item.uploaderId.as_deref(),
Some("xgroovy:pornstar:assspanker")
);
assert_eq!(
item.preview.as_deref(),
Some("https://preview.xgroovy.com/videos/337000/337143/337143_pr640.mp4")
);
}
}

View File

@@ -164,6 +164,7 @@ impl XhamsterProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -86,6 +86,7 @@ impl XnxxProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -149,6 +149,7 @@ impl XvideosProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -82,6 +82,7 @@ impl XxdbxProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -68,6 +68,7 @@ impl XxthotsProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(

View File

@@ -307,6 +307,7 @@ impl XxxtikProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -238,6 +238,7 @@ impl YespornProvider {
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -84,6 +84,7 @@ impl YoujizzProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
async fn get(

View File

@@ -106,6 +106,7 @@ impl YoupornProvider {
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}

View File

@@ -20,6 +20,8 @@ pub struct Channel {
pub nsfw: bool, //true
#[serde(skip_serializing_if = "Option::is_none")]
pub cacheDuration: Option<u32>, //Some(86400)
#[serde(skip_serializing_if = "Option::is_none")]
pub ytdlpCommand: Option<String>, //Some("yt-dlp --impersonate chrome-120")
}
#[derive(serde::Serialize, Debug, Clone)]