diff --git a/build.rs b/build.rs index 9df09e0..54a262b 100644 --- a/build.rs +++ b/build.rs @@ -356,6 +356,11 @@ const PROVIDERS: &[ProviderDef] = &[ module: "xhamster", ty: "XhamsterProvider", }, + ProviderDef { + id: "xgroovy", + module: "xgroovy", + ty: "XgroovyProvider", + }, ProviderDef { id: "xvideos", module: "xvideos", diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index 5ab1e42..1c3e016 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -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 `
`, 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. |
diff --git a/src/providers/all.rs b/src/providers/all.rs
index 544530c..dab0e51 100644
--- a/src/providers/all.rs
+++ b/src/providers/all.rs
@@ -177,6 +177,7 @@ impl Provider for AllProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         })
     }
 }
diff --git a/src/providers/allpornstream.rs b/src/providers/allpornstream.rs
index 2a53625..5c41b1f 100644
--- a/src/providers/allpornstream.rs
+++ b/src/providers/allpornstream.rs
@@ -86,6 +86,7 @@ impl AllPornStreamProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/animeidhentai.rs b/src/providers/animeidhentai.rs
index ef2a5a4..1609283 100644
--- a/src/providers/animeidhentai.rs
+++ b/src/providers/animeidhentai.rs
@@ -129,6 +129,7 @@ impl AnimeidhentaiProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/archivebate.rs b/src/providers/archivebate.rs
index c1d6da6..a5b5d1f 100644
--- a/src/providers/archivebate.rs
+++ b/src/providers/archivebate.rs
@@ -244,6 +244,7 @@ impl ArchivebateProvider {
             ],
             nsfw: true,
             cacheDuration: Some(900),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/beeg.rs b/src/providers/beeg.rs
index 90ad723..8bfc71c 100644
--- a/src/providers/beeg.rs
+++ b/src/providers/beeg.rs
@@ -240,6 +240,7 @@ impl BeegProvider {
             ],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/blowjobspro.rs b/src/providers/blowjobspro.rs
index cc0f5ba..547df4d 100644
--- a/src/providers/blowjobspro.rs
+++ b/src/providers/blowjobspro.rs
@@ -154,6 +154,7 @@ impl BlowjobsproProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/camsoda.rs b/src/providers/camsoda.rs
index 6217233..60e2625 100644
--- a/src/providers/camsoda.rs
+++ b/src/providers/camsoda.rs
@@ -90,6 +90,7 @@ impl CamsodaProvider {
             }],
             nsfw: true,
             cacheDuration: Some(60),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/chaturbate.rs b/src/providers/chaturbate.rs
index ee0151c..92c740d 100644
--- a/src/providers/chaturbate.rs
+++ b/src/providers/chaturbate.rs
@@ -68,6 +68,7 @@ impl ChaturbateProvider {
             }],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/clapdat.rs b/src/providers/clapdat.rs
index 29f573e..c696fa1 100644
--- a/src/providers/clapdat.rs
+++ b/src/providers/clapdat.rs
@@ -114,6 +114,7 @@ impl ClapdatProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/eporner.rs b/src/providers/eporner.rs
index 768e6a2..b7d4404 100644
--- a/src/providers/eporner.rs
+++ b/src/providers/eporner.rs
@@ -225,6 +225,7 @@ impl EpornerProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/erome.rs b/src/providers/erome.rs
index 8c8f80e..38293dc 100644
--- a/src/providers/erome.rs
+++ b/src/providers/erome.rs
@@ -94,6 +94,7 @@ impl EromeProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/fikfap.rs b/src/providers/fikfap.rs
index fd3e67e..ad22ffd 100644
--- a/src/providers/fikfap.rs
+++ b/src/providers/fikfap.rs
@@ -266,6 +266,7 @@ impl FikfapProvider {
             ],
             nsfw: true,
             cacheDuration: Some(60),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/freepornvideosxxx.rs b/src/providers/freepornvideosxxx.rs
index 1ae5895..8dd7eff 100644
--- a/src/providers/freepornvideosxxx.rs
+++ b/src/providers/freepornvideosxxx.rs
@@ -415,6 +415,7 @@ impl FreepornvideosxxxProvider {
             ],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/freeuseporn.rs b/src/providers/freeuseporn.rs
index cd7dcbc..77a3106 100644
--- a/src/providers/freeuseporn.rs
+++ b/src/providers/freeuseporn.rs
@@ -128,6 +128,7 @@ impl FreeusepornProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/fullporner.rs b/src/providers/fullporner.rs
index 3c21922..b2e440a 100644
--- a/src/providers/fullporner.rs
+++ b/src/providers/fullporner.rs
@@ -88,6 +88,7 @@ impl FullpornerProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/fyptt.rs b/src/providers/fyptt.rs
index 26619b5..6df599a 100644
--- a/src/providers/fyptt.rs
+++ b/src/providers/fyptt.rs
@@ -101,6 +101,7 @@ impl FypttProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/hanime.rs b/src/providers/hanime.rs
index f43ae6c..244fcb5 100644
--- a/src/providers/hanime.rs
+++ b/src/providers/hanime.rs
@@ -171,6 +171,7 @@ impl HanimeProvider {
             }],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/heavyfetish.rs b/src/providers/heavyfetish.rs
index 64c6d1c..6a500ba 100644
--- a/src/providers/heavyfetish.rs
+++ b/src/providers/heavyfetish.rs
@@ -249,6 +249,7 @@ impl HeavyfetishProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/hentaihaven.rs b/src/providers/hentaihaven.rs
index 7a30411..4aae6b8 100644
--- a/src/providers/hentaihaven.rs
+++ b/src/providers/hentaihaven.rs
@@ -111,6 +111,7 @@ impl HentaihavenProvider {
             options: vec![],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/hentaitv.rs b/src/providers/hentaitv.rs
index 3279a60..986e38a 100644
--- a/src/providers/hentaitv.rs
+++ b/src/providers/hentaitv.rs
@@ -150,6 +150,7 @@ impl HentaitvProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/homoxxx.rs b/src/providers/homoxxx.rs
index f73fb03..212a1da 100644
--- a/src/providers/homoxxx.rs
+++ b/src/providers/homoxxx.rs
@@ -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> {
diff --git a/src/providers/hotbunny.rs b/src/providers/hotbunny.rs
index 6e77304..c9cafdd 100644
--- a/src/providers/hotbunny.rs
+++ b/src/providers/hotbunny.rs
@@ -304,6 +304,7 @@ impl HotbunnyProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 }
diff --git a/src/providers/hqporner.rs b/src/providers/hqporner.rs
index bba506d..cf38156 100644
--- a/src/providers/hqporner.rs
+++ b/src/providers/hqporner.rs
@@ -162,6 +162,7 @@ impl HqpornerProvider {
             options: vec![],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/hsex.rs b/src/providers/hsex.rs
index 1c38936..775ead1 100644
--- a/src/providers/hsex.rs
+++ b/src/providers/hsex.rs
@@ -222,6 +222,7 @@ impl HsexProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/hypnotube.rs b/src/providers/hypnotube.rs
index c449b62..d544d27 100644
--- a/src/providers/hypnotube.rs
+++ b/src/providers/hypnotube.rs
@@ -181,6 +181,7 @@ impl HypnotubeProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/jable.rs b/src/providers/jable.rs
index 707bb93..95a4ac6 100644
--- a/src/providers/jable.rs
+++ b/src/providers/jable.rs
@@ -119,6 +119,7 @@ impl JableProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/javtiful.rs b/src/providers/javtiful.rs
index 161d05e..41c1769 100644
--- a/src/providers/javtiful.rs
+++ b/src/providers/javtiful.rs
@@ -97,6 +97,7 @@ impl JavtifulProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/kwiky.rs b/src/providers/kwiky.rs
index bbceb1c..b5f176d 100644
--- a/src/providers/kwiky.rs
+++ b/src/providers/kwiky.rs
@@ -383,6 +383,7 @@ impl KwikyProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 }
diff --git a/src/providers/missav.rs b/src/providers/missav.rs
index 75f7734..41fed91 100644
--- a/src/providers/missav.rs
+++ b/src/providers/missav.rs
@@ -226,6 +226,7 @@ impl MissavProvider {
             ],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/mod.rs b/src/providers/mod.rs
index fc93f15..768bf96 100644
--- a/src/providers/mod.rs
+++ b/src/providers/mod.rs
@@ -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::::new(),
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/noodlemagazine.rs b/src/providers/noodlemagazine.rs
index c3aa7ef..5588664 100644
--- a/src/providers/noodlemagazine.rs
+++ b/src/providers/noodlemagazine.rs
@@ -120,6 +120,7 @@ impl NoodlemagazineProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/okporn.rs b/src/providers/okporn.rs
index 5f0c8cc..cd56756 100644
--- a/src/providers/okporn.rs
+++ b/src/providers/okporn.rs
@@ -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> {
diff --git a/src/providers/okxxx.rs b/src/providers/okxxx.rs
index 8bad79d..9a6e1c5 100644
--- a/src/providers/okxxx.rs
+++ b/src/providers/okxxx.rs
@@ -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> {
diff --git a/src/providers/omgxxx.rs b/src/providers/omgxxx.rs
index 166a3be..a054df2 100644
--- a/src/providers/omgxxx.rs
+++ b/src/providers/omgxxx.rs
@@ -437,6 +437,7 @@ impl OmgxxxProvider {
             ],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/paradisehill.rs b/src/providers/paradisehill.rs
index 3514b7e..4cd7d83 100644
--- a/src/providers/paradisehill.rs
+++ b/src/providers/paradisehill.rs
@@ -52,6 +52,7 @@ impl ParadisehillProvider {
             options: vec![],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/perfectgirls.rs b/src/providers/perfectgirls.rs
index 769adf0..e608b98 100644
--- a/src/providers/perfectgirls.rs
+++ b/src/providers/perfectgirls.rs
@@ -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> {
diff --git a/src/providers/perverzija.rs b/src/providers/perverzija.rs
index f2cc405..f4b84a6 100644
--- a/src/providers/perverzija.rs
+++ b/src/providers/perverzija.rs
@@ -84,6 +84,7 @@ impl PerverzijaProvider {
             }],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pimpbunny.rs b/src/providers/pimpbunny.rs
index d4f01fe..28e0e26 100644
--- a/src/providers/pimpbunny.rs
+++ b/src/providers/pimpbunny.rs
@@ -109,6 +109,7 @@ impl PimpbunnyProvider {
             }],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pmvhaven.rs b/src/providers/pmvhaven.rs
index 46b2993..eff046e 100644
--- a/src/providers/pmvhaven.rs
+++ b/src/providers/pmvhaven.rs
@@ -135,6 +135,7 @@ impl PmvhavenProvider {
             ],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/porn00.rs b/src/providers/porn00.rs
index d9ca84c..72679d6 100644
--- a/src/providers/porn00.rs
+++ b/src/providers/porn00.rs
@@ -68,6 +68,7 @@ impl Porn00Provider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
     async fn get(
diff --git a/src/providers/porn4fans.rs b/src/providers/porn4fans.rs
index 8c06c06..d1f25df 100644
--- a/src/providers/porn4fans.rs
+++ b/src/providers/porn4fans.rs
@@ -147,6 +147,7 @@ impl Porn4fansProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/porndish.rs b/src/providers/porndish.rs
index 476ea11..7c41dd5 100644
--- a/src/providers/porndish.rs
+++ b/src/providers/porndish.rs
@@ -154,6 +154,7 @@ impl PorndishProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pornhat.rs b/src/providers/pornhat.rs
index fe26f68..8701844 100644
--- a/src/providers/pornhat.rs
+++ b/src/providers/pornhat.rs
@@ -122,6 +122,7 @@ impl PornhatProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
     async fn get(
diff --git a/src/providers/pornhd3x.rs b/src/providers/pornhd3x.rs
index 8fa1593..42a44f1 100644
--- a/src/providers/pornhd3x.rs
+++ b/src/providers/pornhd3x.rs
@@ -223,6 +223,7 @@ impl Pornhd3xProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pornhub.rs b/src/providers/pornhub.rs
index 7daa764..ed07d9a 100644
--- a/src/providers/pornhub.rs
+++ b/src/providers/pornhub.rs
@@ -211,6 +211,7 @@ impl PornhubProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pornhub_shorties.rs b/src/providers/pornhub_shorties.rs
index 356444e..9764119 100644
--- a/src/providers/pornhub_shorties.rs
+++ b/src/providers/pornhub_shorties.rs
@@ -338,6 +338,7 @@ impl PornhubShortiesProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pornmz.rs b/src/providers/pornmz.rs
index 59175d1..039ed5f 100644
--- a/src/providers/pornmz.rs
+++ b/src/providers/pornmz.rs
@@ -210,6 +210,7 @@ impl PornmzProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/porntrex.rs b/src/providers/porntrex.rs
index a49e90f..2503be3 100644
--- a/src/providers/porntrex.rs
+++ b/src/providers/porntrex.rs
@@ -173,6 +173,7 @@ impl PorntrexProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pornxp.rs b/src/providers/pornxp.rs
index 6946d61..7ec5c74 100644
--- a/src/providers/pornxp.rs
+++ b/src/providers/pornxp.rs
@@ -67,6 +67,7 @@ impl PornxpProvider {
             ],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/pornzog.rs b/src/providers/pornzog.rs
index 92def24..a6cb9cd 100644
--- a/src/providers/pornzog.rs
+++ b/src/providers/pornzog.rs
@@ -77,6 +77,7 @@ impl PornzogProvider {
             }],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/redgifs.rs b/src/providers/redgifs.rs
index 4c9878e..c948bd7 100644
--- a/src/providers/redgifs.rs
+++ b/src/providers/redgifs.rs
@@ -220,6 +220,7 @@ impl RedgifsProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/redtube.rs b/src/providers/redtube.rs
index 4ed95ab..f940f47 100644
--- a/src/providers/redtube.rs
+++ b/src/providers/redtube.rs
@@ -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(
diff --git a/src/providers/rule34gen.rs b/src/providers/rule34gen.rs
index e6c3f90..2769438 100644
--- a/src/providers/rule34gen.rs
+++ b/src/providers/rule34gen.rs
@@ -77,6 +77,7 @@ impl Rule34genProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/rule34video.rs b/src/providers/rule34video.rs
index 1bb33eb..05f7252 100644
--- a/src/providers/rule34video.rs
+++ b/src/providers/rule34video.rs
@@ -85,6 +85,7 @@ impl Rule34videoProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/sextb.rs b/src/providers/sextb.rs
index bc52b8e..f6f49a6 100644
--- a/src/providers/sextb.rs
+++ b/src/providers/sextb.rs
@@ -223,6 +223,7 @@ impl SextbProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/shooshtime.rs b/src/providers/shooshtime.rs
index f8556e4..2e8d3cc 100644
--- a/src/providers/shooshtime.rs
+++ b/src/providers/shooshtime.rs
@@ -232,6 +232,7 @@ impl ShooshtimeProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/spankbang.rs b/src/providers/spankbang.rs
index 74d693e..18a2c83 100644
--- a/src/providers/spankbang.rs
+++ b/src/providers/spankbang.rs
@@ -80,6 +80,7 @@ impl SpankbangProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/supjav.rs b/src/providers/supjav.rs
index 482370d..c9ef606 100644
--- a/src/providers/supjav.rs
+++ b/src/providers/supjav.rs
@@ -229,6 +229,7 @@ impl SupjavProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/sxyprn.rs b/src/providers/sxyprn.rs
index 5637cf9..e7eefcd 100644
--- a/src/providers/sxyprn.rs
+++ b/src/providers/sxyprn.rs
@@ -107,6 +107,7 @@ impl SxyprnProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
     async fn get(
diff --git a/src/providers/thaiporntv.rs b/src/providers/thaiporntv.rs
index 63082c2..5bd7004 100644
--- a/src/providers/thaiporntv.rs
+++ b/src/providers/thaiporntv.rs
@@ -169,6 +169,7 @@ impl ThaipornTvProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/thepornbunny.rs b/src/providers/thepornbunny.rs
index 97436ef..397921c 100644
--- a/src/providers/thepornbunny.rs
+++ b/src/providers/thepornbunny.rs
@@ -135,6 +135,7 @@ impl ThepornbunnyProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/tikporn.rs b/src/providers/tikporn.rs
index 0066aa3..c9fd985 100644
--- a/src/providers/tikporn.rs
+++ b/src/providers/tikporn.rs
@@ -326,6 +326,7 @@ impl TikpornProvider {
             }],
             nsfw: true,
             cacheDuration: Some(900),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/tnaflix.rs b/src/providers/tnaflix.rs
index c103eb4..5fd656c 100644
--- a/src/providers/tnaflix.rs
+++ b/src/providers/tnaflix.rs
@@ -102,6 +102,7 @@ impl TnaflixProvider {
             ],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/tokyomotion.rs b/src/providers/tokyomotion.rs
index 3fe485a..5a2be17 100644
--- a/src/providers/tokyomotion.rs
+++ b/src/providers/tokyomotion.rs
@@ -87,6 +87,7 @@ impl TokyomotionProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/tube8.rs b/src/providers/tube8.rs
index 0ae9957..fb03acf 100644
--- a/src/providers/tube8.rs
+++ b/src/providers/tube8.rs
@@ -80,6 +80,7 @@ impl Tube8Provider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/viralxxxporn.rs b/src/providers/viralxxxporn.rs
index d71d863..cc0918a 100644
--- a/src/providers/viralxxxporn.rs
+++ b/src/providers/viralxxxporn.rs
@@ -49,6 +49,7 @@ impl ViralxxxpornProvider {
             options: vec![],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/vjav.rs b/src/providers/vjav.rs
index 4c7351d..04711b0 100644
--- a/src/providers/vjav.rs
+++ b/src/providers/vjav.rs
@@ -499,6 +499,7 @@ impl VjavProvider {
             ],
             nsfw: true,
             cacheDuration: Some(60),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/vrporn.rs b/src/providers/vrporn.rs
index 12f6c61..b06e69c 100644
--- a/src/providers/vrporn.rs
+++ b/src/providers/vrporn.rs
@@ -205,6 +205,7 @@ impl VrpornProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/wowxxx.rs b/src/providers/wowxxx.rs
index 4ecd109..dd15c84 100644
--- a/src/providers/wowxxx.rs
+++ b/src/providers/wowxxx.rs
@@ -90,6 +90,7 @@ impl WowxxxProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/xfree.rs b/src/providers/xfree.rs
index 8aa7d26..be60456 100644
--- a/src/providers/xfree.rs
+++ b/src/providers/xfree.rs
@@ -92,6 +92,7 @@ impl XfreeProvider {
             }],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/xgroovy.rs b/src/providers/xgroovy.rs
new file mode 100644
index 0000000..a748376
--- /dev/null
+++ b/src/providers/xgroovy.rs
@@ -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::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::>()
+            .join(" ")
+            .split_whitespace()
+            .collect::>()
+            .join(" ")
+    }
+
+    fn normalize_key(s: &str) -> String {
+        s.trim()
+            .replace(['-', '_'], " ")
+            .split_whitespace()
+            .collect::>()
+            .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 {
+        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> {
+        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 = card
+                .value()
+                .attr("data-views")
+                .and_then(|v| v.parse::().ok());
+
+            let rating: Option = card
+                .value()
+                .attr("data-rating")
+                .and_then(|v| v.parse::().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> {
+        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 = 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,
+        page: String,
+        per_page: String,
+        options: ServerOptions,
+    ) -> Vec {
+        let page = page.parse::().unwrap_or(1).max(1);
+        let per_page = per_page.parse::().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 {
+        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#"
+        
+        
+        
+        "#;
+
+        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")
+        );
+    }
+}
diff --git a/src/providers/xhamster.rs b/src/providers/xhamster.rs
index be5337d..eb092f3 100644
--- a/src/providers/xhamster.rs
+++ b/src/providers/xhamster.rs
@@ -164,6 +164,7 @@ impl XhamsterProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/xnxx.rs b/src/providers/xnxx.rs
index 6889f06..365f578 100644
--- a/src/providers/xnxx.rs
+++ b/src/providers/xnxx.rs
@@ -86,6 +86,7 @@ impl XnxxProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/xvideos.rs b/src/providers/xvideos.rs
index af7ec83..63b565d 100644
--- a/src/providers/xvideos.rs
+++ b/src/providers/xvideos.rs
@@ -149,6 +149,7 @@ impl XvideosProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/xxdbx.rs b/src/providers/xxdbx.rs
index 8c76d64..cbb1dd8 100644
--- a/src/providers/xxdbx.rs
+++ b/src/providers/xxdbx.rs
@@ -82,6 +82,7 @@ impl XxdbxProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/xxthots.rs b/src/providers/xxthots.rs
index ad0fb1c..2b00107 100644
--- a/src/providers/xxthots.rs
+++ b/src/providers/xxthots.rs
@@ -68,6 +68,7 @@ impl XxthotsProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
     async fn get(
diff --git a/src/providers/xxxtik.rs b/src/providers/xxxtik.rs
index 210d632..c85dc95 100644
--- a/src/providers/xxxtik.rs
+++ b/src/providers/xxxtik.rs
@@ -307,6 +307,7 @@ impl XxxtikProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/yesporn.rs b/src/providers/yesporn.rs
index bcb8856..4f5cc62 100644
--- a/src/providers/yesporn.rs
+++ b/src/providers/yesporn.rs
@@ -238,6 +238,7 @@ impl YespornProvider {
             ],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/providers/youjizz.rs b/src/providers/youjizz.rs
index 9d3de74..351511f 100644
--- a/src/providers/youjizz.rs
+++ b/src/providers/youjizz.rs
@@ -84,6 +84,7 @@ impl YoujizzProvider {
             }],
             nsfw: true,
             cacheDuration: None,
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
     async fn get(
diff --git a/src/providers/youporn.rs b/src/providers/youporn.rs
index f40ba7b..ceabd65 100644
--- a/src/providers/youporn.rs
+++ b/src/providers/youporn.rs
@@ -106,6 +106,7 @@ impl YoupornProvider {
             }],
             nsfw: true,
             cacheDuration: Some(1800),
+            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
         }
     }
 
diff --git a/src/status.rs b/src/status.rs
index 4074a11..947e255 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -20,6 +20,8 @@ pub struct Channel {
     pub nsfw: bool, //true
     #[serde(skip_serializing_if = "Option::is_none")]
     pub cacheDuration: Option, //Some(86400)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub ytdlpCommand: Option, //Some("yt-dlp --impersonate chrome-120")
 }
 
 #[derive(serde::Serialize, Debug, Clone)]