Compare commits

...

55 Commits

Author SHA1 Message Date
Simon
cf0ae15f93 hanime: play through a redirect proxy; the signed stream links expire
Items now carry /proxy/hanime/{slug}/{height}.m3u8, which does a fresh
handshake per play and 302s to the current playlist. The handshake moved
to util/hanime_resolve.rs so single-provider builds keep the route.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-11-01 07:48:35 +00:00
Simon
aa00abdd5b rule34video: probe the space-less artist slug (e.g. /models/biggreen/) for multi-word queries
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-27 21:45:09 +00:00
Simon
e5a2afcc70 eporner: stream through the server; the signed CDN links are IP-locked
The redirect proxy from 6ba65f1 still played the one-minute "watch on
eporner.com" clip away from home: checked from foreign networks
(check-host.net nodes in BR/DE/HU), a freshly signed link answers 302 ->
static.eporner.com/na.mp4, while the IP that resolved it gets the file. A
redirect therefore only works for a player behind the server's public IP.

/proxy/eporner/{alnum_id}/{height}.mp4 now streams the mp4: resolves the
link (cached 90 min, re-resolved when the CDN answers with a redirect),
forwards Range, and returns a sized body so 206 responses keep
Content-Length. It is no longer a redirect proxy, so app clients keep the
URLs in formats. Verified locally: HEAD, 0-1, mid-file and open-ended ranges
return 206 with Content-Length/Content-Range and valid MP4 data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-22 20:46:14 +00:00
Simon
6ba65f1457 eporner: play through a redirect proxy; the signed CDN links expire after 2h
Expired eporner mp4 links are answered with a 302 to static.eporner.com/
na.mp4, a one-minute "watch it on eporner.com" clip. The links expire
exactly two hours after the /xhr/video call, so anything played from a
favourite, history entry or a list left open past that hit the decoy.

New /proxy/eporner/{alnum_id}/{height} re-resolves the hash/xhr chain at
play time and 302s to the matching (else best) quality. The eporner
provider and melonstube's eporner destinations now emit these proxy URLs;
verified app clients get the best quality as video.url via the existing
redirect-proxy rule. Verified live: proxy -> fresh CDN link -> 206 video/mp4
for app and non-app clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-22 18:09:23 +00:00
Simon
b131910ea2 rule34video: make queries robust (single-page results, safe search paths, 429-proof enrichment)
- Parser no longer requires a pagination block: single-page results
  (small tags, short searches) used to parse to zero items.
- Search path is slugged like the site's own form: "2b/9s" 404'd, "%20"
  is refused with 403 and "&" truncated the query.
- Probed archives need >= 4 videos to beat keyword search ("elf girl" was a
  one-video tag); conclusive query -> target resolutions are remembered for 6h
  so later pages and the prefetch skip the probes.
- Enriched details are cached per video URL for 2h (tokens last ~12h);
  detail fetches go through get_probe so a lasting 429 stops the batch instead
  of escalating each page to Jina/FlareSolverr. Archive failures serve stale
  cache.
- requester: get_probe waits before retrying 429/5xx and compares redirect
  paths percent-decoded (non-ASCII archives like /tags/pokémon-species/ were
  treated as redirected away); get_with_headers backs off 1/2/4s on 429.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-21 20:17:58 +00:00
Simon
921ad93149 requester: treat DDoS-Guard interstitials as challenges (FlareSolverr fallback); mock-origin test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-19 20:38:17 +00:00
Simon
ad2abe0057 rule34video: artist, category and tag archives; resolve bare queries by probing; credit artists as uploader
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-19 17:06:00 +00:00
Simon
0aa8929f29 Docs: the redirect-proxy rule applies to verified Hot Tub app clients only
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-19 07:53:08 +00:00
Simon
6c69b46395 Move redirecting-proxy formats to video.url for verified app clients; document the rule
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-19 07:48:34 +00:00
Simon
f900987434 archivebate: verified app clients get the proxy URL as item url and no formats
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-19 07:39:48 +00:00
Simon
30271b9b7c archivebate: app clients get resolved media URLs with headers, resolved concurrently
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-18 19:30:55 +00:00
Simon
a4364e5be3 archivebate: keep the proxied format when enrichment times out; skip dead search candidates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-17 19:54:49 +00:00
Simon
edf6732f8e Query resolution todo, slug helper tests, fix txxx decoder test fixture
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-17 06:19:18 +00:00
Simon
697836b914 coomer: creator feeds are a bare array; single-word queries that are creator ids go to the creator 2026-10-17 06:16:24 +00:00
Simon
5598216381 sextb: probe actress/studio for names outside the loaded catalogs 2026-10-17 06:07:34 +00:00
Simon
4c29d27b3e redgifs: single-word queries that are creator names go to the creator 2026-10-17 06:05:54 +00:00
Simon
a486bc5502 erome: single-word queries that are uploader names go to the uploader 2026-10-17 06:03:58 +00:00
Simon
270b327f73 fikfap: single-word queries that are profile names go to the creator 2026-10-17 06:01:34 +00:00
Simon
813e2ec2ca clapdat: resolve tag/user names without prefix 2026-10-17 05:58:53 +00:00
Simon
26ef830f6e pornhub: probe pornstar/model/channel for unknown free-form queries 2026-10-17 05:56:31 +00:00
Simon
e34801f6f8 thepornbunny: resolve pornstar/studio/tag names; get_probe treats redirect-away as a miss 2026-10-17 05:53:43 +00:00
Simon
2646ca5659 xgroovy: resolve pornstar/channel names without prefix 2026-10-17 05:51:40 +00:00
Simon
981ec29ad8 xhamster: resolve pornstar/channel names without prefix 2026-10-17 05:50:41 +00:00
Simon
76b9d4e058 xvideos: resolve channel/pornstar names without prefix; channel archives via the JSON video list 2026-10-17 05:48:58 +00:00
Simon
59ea0495a6 porn4fans: resolve model/tag names without prefix 2026-10-17 05:40:28 +00:00
Simon
1f95549167 youporn: resolve pornstar/channel/amateur names without prefix 2026-10-17 05:38:47 +00:00
Simon
1e56fe2da5 tube8: resolve pornstar/channel names without prefix 2026-10-17 05:37:07 +00:00
Simon
bdde02fa8c Add get_probe/probe_first_archive; fullporner and fullpornerorg resolve pornstar names without prefix 2026-10-17 05:34:15 +00:00
Simon
665fb2a0ce hooporn: probe queries as pornstar/studio archives before keyword search
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-17 05:25:30 +00:00
Simon
99bc80adac Add hooporn provider
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-17 05:11:17 +00:00
Simon
d72dda5203 pornhub: proxy Referer-gated pix-cdn77 thumbnails
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 21:08:07 +00:00
Simon
6d7b16842d Add txxx provider; cumlouder: route keywords to pornstar /girl/ pages before tags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 15:47:05 +00:00
Simon
af6674b4ed Route Cloudflare challenges to FlareSolverr on cumlouder and hypnotube raw paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 11:59:42 +00:00
Simon
2addcc2e7c Deliver ytdlpCommand for an allowlist of verified channels
decorate_channel ignored every provider-declared ytdlpCommand except
pimpbunny's. Send the declared command for freepornvideosxxx and missav,
where plain yt-dlp is verified to fail; other channels stay unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 10:28:57 +00:00
Simon
40eab41b2b Fix hanime-cdn relative path prefixing and stale vidara test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 09:36:13 +00:00
Simon
5a0fb649ee todo: aps and porndish are Cloudflare/FlareSolverr bound 2026-10-10 09:34:05 +00:00
Simon
2d35f29f44 Fix xxxtik empty results; replay solved identity on raw requests
Raw requester calls did not adopt the remembered FlareSolverr user agent,
so a RedGifs token minted under it was rejected on every gif lookup.
Tag/creator pages that mix non-RedGifs sources are refilled from later
cursor batches instead of coming back empty.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 09:33:36 +00:00
Simon
5176178607 vjav: fall back to keyword search when an implicit uploader/tag match is empty
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 09:24:56 +00:00
Simon
0824941c23 Fix chaturbate search URL (swapped format args) and stale cache branch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 09:21:02 +00:00
Simon
2fccea0fc2 Fix noodlemagazine thumb proxy; check.py verifies proxied urls
The thumb proxy dropped the preview query string, rejected extensionless
getVideoPreview paths and warmed up through the slow fallback chain on
every image. check.py now verifies that a proxied video.url resolves
instead of failing it outright.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 09:17:30 +00:00
Simon
e4c5cb2f0a todo: mark xxdbx and cumlouder fixed 2026-10-10 09:10:05 +00:00
Simon
ae516aba91 Fix xxdbx site-relative thumbs; cumlouder answers 404 immediately
xxdbx assets are now /id.jpg, not //host/..; prefixing https: produced
https:/id.jpg. cumlouder's unknown-tag 404 went through the requester's
mirror/FlareSolverr fallbacks (146s); read the raw response and return
an empty list at once while still retrying 5xx.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 09:09:58 +00:00
Simon
95700c67a5 check.py: apply the channel's status ytdlpCommand to yt-dlp probes; log open decision
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 08:36:25 +00:00
Simon
765fcb5579 Fix fullpornerorg: drop deleted hoster files, keep myvidplay host
Deleted lulustream files stayed listed and 502'd. myvidplay embeds lost
their host and were resolved against lulustream; route them through the
doodstream proxy (now allowing myvidplay/playmogo) with the host kept.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 08:33:18 +00:00
Simon
a1e6021766 Fix hypnotube search sessions; check.py confirms HEAD failures with GET
The site serves one results page per searchgate POST, so later searches
and page 2 returned nothing on the shared cookie session. Use an isolated
session per (query, page). check.py now retries a refused HEAD (403/404/
405/501) with a ranged GET before reporting a URL as unreachable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 08:24:15 +00:00
Simon
78acac2150 Fix tube8 ids and Referer-gated Aylo thumbnails
Sponsored channel cards carry a stale data-video-id that 404s; use the
card link target. The signed pix-cdn77 thumbnail hosts 403 without a
Referer, so route them (tube8, ph-shorties) through a new host-restricted
aylo-thumb proxy. Also correct the pornhub-thumb route that sat under a
doubled /proxy prefix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 08:16:03 +00:00
Simon
a20a4ab642 Fix coomer: offset pagination, per-query cache key, video-only posts
The API ignores page= (every page 2+ repeated page 1), searches shared a
single cache entry, image-only posts were listed as unplayable videos, and
gzip/br bodies were not decoded because wreq had no decompression features.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 08:04:00 +00:00
Simon
5098d142f9 Fix pornmz: match contentUrl meta case-insensitively, bound enrichment
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 07:43:48 +00:00
Simon
aff5df31d6 Fix thaiporntv: decode renamed data-video-sources player attribute
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 07:33:51 +00:00
Simon
9bc8cf9b6a todo: record camsoda/hentaimama/hotbunny as environment-blocked 2026-10-10 07:30:33 +00:00
Simon
39314c9a53 Fix sextb: fetch HTML directly instead of the dead Jina markdown mirror
r.jina.ai now answers 403/429, so every request returned nothing. The
site serves plain HTML to the emulating client; parse cards, detail rows
and option lists from HTML, and return empty for no-hit searches (the
site renders a recommendation tray under the notice).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 07:21:50 +00:00
Simon
5b62023d99 Fix javtiful: parse redesigned card markup, drop per-item detail fetches
The site moved to article.video-card; the old front-video-card parser found
nothing and the unbounded detail fan-out timed out. The page URL now
resolves natively in yt-dlp, so it is returned directly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-10 07:10:33 +00:00
Simon
cf9701611a Fix noodlemagazine empty titles
The title div gained attributes (data-video-title-text); match the class
and fall back to the thumbnail alt text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-09 22:27:29 +00:00
Simon
316c317c81 Fix rule34video: thumbs, views and playback
Thumbs moved to data-rd-src, views to title="Views: ..", and yt-dlp no
longer resolves the page URL, so formats and tags come from the detail
page flashvars.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-09 22:27:29 +00:00
Simon
7fbf6cfd15 Add cumlouder provider
Listing scraper with category/tag/series routing; /search/ is behind a
Cloudflare challenge so keywords map to archives.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-09 22:27:28 +00:00
57 changed files with 5603 additions and 1143 deletions

View File

@@ -20,7 +20,7 @@ ntex-files = "3"
serde = "1.0.228"
serde_json = "1.0.145"
tokio = { version = "1.49", features = ["full"] }
wreq = { version = "6.0.0-rc.31", features = ["cookies", "multipart", "json"] }
wreq = { version = "6.0.0-rc.31", features = ["cookies", "multipart", "json", "gzip", "brotli", "deflate", "zstd"] }
wreq-util = "3.0.0-rc.14"
percent-encoding = "2.3.2"
capitalize = "0.3.4"

View File

@@ -356,6 +356,21 @@ const PROVIDERS: &[ProviderDef] = &[
module: "jable",
ty: "JableProvider",
},
ProviderDef {
id: "cumlouder",
module: "cumlouder",
ty: "CumlouderProvider",
},
ProviderDef {
id: "txxx",
module: "txxx",
ty: "TxxxProvider",
},
ProviderDef {
id: "hooporn",
module: "hooporn",
ty: "HoopornProvider",
},
ProviderDef {
id: "fullporner",
module: "fullporner",

View File

@@ -18,6 +18,8 @@ Checks per channel:
- yt-dlp -J on each format.url that is not a direct media file
"""
import os
import shlex
import sys
import json
import subprocess
@@ -149,13 +151,15 @@ def _is_cf_protected(url: str) -> bool:
def http_ok(url: str, headers: dict | None = None) -> tuple[bool, int]:
"""Return (ok, http_status). Tries HEAD then ranged GET on 405."""
"""Return (ok, http_status). Tries HEAD, then a ranged GET when HEAD is refused."""
h = {"User-Agent": _BROWSER_UA, **(headers or {})}
try:
r = requests.head(url, headers=h, timeout=HTTP_TIMEOUT, allow_redirects=True)
if r.status_code in (200, 206):
return True, r.status_code
if r.status_code == 405:
# Some servers mishandle HEAD (405/501, or even 404/403 while GET is 200);
# confirm with a ranged GET before reporting the URL as broken.
if r.status_code in (403, 404, 405, 501):
r2 = requests.get(
url,
headers={**h, "Range": "bytes=0-1023"},
@@ -176,6 +180,29 @@ def http_ok(url: str, headers: dict | None = None) -> tuple[bool, int]:
return False, -3
# Per-channel yt-dlp options taken from the channel's declared `ytdlpCommand`
# (e.g. "yt-dlp --impersonate chrome-120"), which real clients apply. Probing
# without them reports failures (TLS-fingerprint 403/502) no client sees.
_CHANNEL_YTDLP_ARGS: dict[str, list[str]] = {}
def register_ytdlp_command(channel_id: str, command: Optional[str]) -> None:
if not command:
return
try:
parts = shlex.split(command)
except ValueError:
return
if parts and os.path.basename(parts[0]) in ("yt-dlp", "yt_dlp"):
parts = parts[1:]
if parts:
_CHANNEL_YTDLP_ARGS[channel_id] = parts
def ytdlp_args_for(channel_id: str) -> list[str]:
return list(_CHANNEL_YTDLP_ARGS.get(channel_id, []))
def ytdlp_extract(url: str, extra_args: list[str] | None = None) -> tuple[Optional[dict], str]:
"""Run yt-dlp -j and return (parsed_info_or_None, stderr_text).
@@ -327,7 +354,29 @@ def check_video(video: dict, channel_id: str, results: Results, run_ytdlp: bool)
if not vurl:
results.err(channel_id, f"{label}: missing url")
elif "127.0.0.1" in vurl or "localhost" in vurl:
results.err(channel_id, f"{label}: url points to hottub server: {vurl}")
# A proxied video.url is legitimate when the page itself cannot be
# played directly (see the hottub skill: "if a video URL requires a
# server-side proxy, set it as the video URL"). Verify that it
# actually resolves instead of rejecting it outright.
resolved_vurl = follow_proxy_redirect(vurl)
if resolved_vurl and resolved_vurl != vurl:
ok, code = http_ok(resolved_vurl)
target = resolved_vurl
else:
ok, code = http_ok(vurl)
target = vurl
if ok:
results.info(channel_id, f"{label}: proxied url OK (HTTP {code})")
elif _is_cf_protected(target):
results.warn(
channel_id,
f"{label}: proxied url target unreachable HTTP={code} (CF-protected host, expected): {vurl}",
)
else:
results.err(
channel_id,
f"{label}: proxied url does not resolve (HTTP={code}, target {target[:120]}): {vurl}",
)
else:
ok, code = http_ok(vurl)
if not ok:
@@ -387,7 +436,7 @@ def check_video(video: dict, channel_id: str, results: Results, run_ytdlp: bool)
ytdlp_url = vurl if vurl and "127.0.0.1" not in vurl and "localhost" not in vurl else ""
if not formats and ytdlp_url and is_page_url(ytdlp_url):
results.info(channel_id, f"{label}: yt-dlp extract {ytdlp_url}")
yt, stderr = ytdlp_extract(ytdlp_url)
yt, stderr = ytdlp_extract(ytdlp_url, extra_args=ytdlp_args_for(channel_id))
if yt is None:
if _is_cf_protected(ytdlp_url):
results.warn(
@@ -455,7 +504,7 @@ def check_video(video: dict, channel_id: str, results: Results, run_ytdlp: bool)
for hk, hv in fheaders.items():
extra_args += ["--add-header", f"{hk}:{hv}"]
results.info(channel_id, f"{label} format[{j}]: yt-dlp extract {furl}")
yt, stderr = ytdlp_extract(furl, extra_args=extra_args)
yt, stderr = ytdlp_extract(furl, extra_args=ytdlp_args_for(channel_id) + extra_args)
if yt is None:
resolved = follow_proxy_redirect(furl)
if _is_cf_protected(furl) or _is_cf_protected(resolved):
@@ -579,6 +628,9 @@ def main():
print("ERROR: no channels in /api/status response")
sys.exit(1)
for ch in channels:
register_ytdlp_command(ch["id"], ch.get("ytdlpCommand"))
print(f"status OK — {len(channels)} channels")
# --- per-channel checks ---

View File

@@ -59,7 +59,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `redtube` | `mainstream-tube` | no | no | Mainstream archive. |
| `ripnsfw` | `onlyfans` | no | yes | Leaked-creator aggregator for ripnsfw.com — the entire catalogue (~2.5k rows) is a single client-side-rendered page whose data source is a published Google Sheet exported as CSV (`GET /data.csv`, no auth/CF gate); the provider fetches and parses that CSV directly instead of scraping any HTML, replicating the site's own JS `parseCSV`/`csvSplit` column layout (`THUMB,NEW,NOME,DOODSTREAM,LULUSTREAM,<link3>,DOOD,<guests>,SITE(bunkr links),TITLE,ID,#(tags),POST,,DATE,SIZE`). Feed/search/pagination/sort are all done in-memory over the fetched rows (no native site pagination exists — `perPage`-sized pages are sliced locally), with a 180s in-process cache to avoid re-fetching the CSV on every request. `tag:`/`category:`/`cat:` and `model:`/`uploader:` query prefixes filter by the parsed tag list / model name; bare keywords substring-match model name, guests, and tags. `video.url` is the site's client-rendered `https://ripnsfw.com/model/{slug}/{id}` route — the host has no server-side SPA fallback, so this 404s to any plain HTTP client (its custom 404 page stashes the path and JS-redirects to `/`, which restores the route — works in a real browser/webview only); `check.py`'s `_CF_PROTECTED_HOSTS` allowlist includes `ripnsfw.com` for this reason. Media is never hosted on ripnsfw.com itself: each row carries a Doodstream and/or Lulustream embed link, resolved into `formats[]` (Lulustream first, matching the site's own default-player order) via the **existing, previously-unused** `src/proxies/doodstream.rs` / `src/proxies/lulustream.rs` redirect proxies (`build_proxy_url(&options, "doodstream"/"lulustream", &strip_url_scheme(link))`) — no new proxy code was written. Both proxies resolve correctly to real signed CDN URLs (verified against live data), but neither CDN's final edge is reachable from this repo's dev sandbox: Lulustream's `*.tnmr.org` edge 403s datacenter/proxy IPs regardless of UA or TLS impersonation, and Doodstream's final `*.dood.video` edge resolves to a loopback/refused address from non-residential egress — both are IP-reputation-style CDN blocks, not code defects (same class of sandbox limitation already documented for `camsoda`/`hentaihaven`/`animeidhentai`), so `check.py` allowlists both by suffix (`_CF_PROTECTED_SUFFIXES`). No thumbnail proxy needed (`cdn.ripnsfw.com/thumbs/...` is directly hotlinkable, no CF/referer gate). No `/api/uploaders` (model pages exist but have no dedicated profile/stats schema confirmed). |
| `rule34gen` | `ai` | no | no | AI group example. |
| `rule34video` | `hentai-animation` | no | no | Hentai group example. |
| `rule34video` | `hentai-animation` | no | no | Hentai group example. **Archives:** artists are `/models/{slug}/`, franchises/genres `/categories/{slug}/`, characters and everything else `/tags/{slug}/`; each is paged and sorted through `?mode=async&function=get_block&block_id=custom_list_videos_common_videos&sort_by={sort}&from={page}` (unknown slugs 404). Prefix queries `artist:`/`model:`/`creator:`, `category:`/`franchise:`/`series:`, `tag:`/`character:`; a bare query is probed as artist, then category, then tag, then resolved through `/search_ajax.php?tag=` (characters are tagged `name (franchise)`; an unambiguous exact base-name match wins), else keyword search. Cards are enriched from the detail page, which also yields the credited artist as `uploader` (`rule34video:{slug}`) and artist names as tags. |
| `sextb` | `jav` | no | no | JAV family provider. sextb.net is Cloudflare-gated, so listing/pagination markdown is fetched through a hand-built Jina mirror URL (`mirror_url()`); the target is already an absolute `https://sextb.net/...` URL, so the scheme must be stripped before prepending the `https://r.jina.ai/http://` prefix — concatenating them directly (the pre-fix bug) produces a malformed `.../http://https://...` URL that Jina rejects outright. |
| `shooshtime` | `onlyfans` | no | yes | Redirect proxy plus dedicated media route. Listing cards are `div.card` (card→`a.title` for the link/title, `a.image img` for thumb/preview, `div.duration`, `div.list div.box` for views+date, `div.name a.text` for uploader) — the site redesigned from an older `div.item.thumb`/`span.description` layout, so re-verify these selectors against `{BASE_URL}/videos/` (the real default-sort URL, not the bare homepage) if the feed ever goes empty again. |
| `spankbang` | `mainstream-tube` | no | yes | Best template for redirect proxy plus anti-bot fetches. |
@@ -81,8 +81,9 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `jable` | `jav` | no | yes | HTML JAV archive scraper; extracts `var hlsUrl` from detail pages; m3u8 format requires Referer + browser User-Agent; proxy route handles HEAD (200 OK) and GET (redirect to watch page) since yt-dlp blocks jable.tv; tag/category/model shortcut queries. |
| `kwiky` | `tiktok` | no | no | Short-form cam clip provider for kwiky.com (ICF/StreamMate platform). Two-step fetch: ID list then metadata batch. Auth: XSRF token extracted from `meta.content = '<TOKEN>'` injected by `/api/state/v1/preload`, cached in an `Arc<RwLock<Option<String>>>` with double-checked locking (same pattern as `xxxtik` bearer token); all API calls also require `x-gateway: 76d59e04-97fa-4ced-aa99-b86ffaf756a2` (platform-identifying fixed UUID observed in browser JS, not stored in cookies or localStorage). Default feed: `GET /api/v1/gateway/v1/quickies/recommended?source=<base64-hotlist>` (cursor-paged, walks up to 5 cursors for higher page numbers). All tag/keyword queries go through `keywordSearch?keyword=...` (offset-paged: `pageToken=(page-1)*perPage`) — the recommended endpoint's `&tag=` param does not reliably filter, so even curated-tag requests use keywordSearch. Media: `media.url` = direct `media.icfcdn.com/*.mp4` (publicly accessible, no Referer/auth), `media.thumbUrl` thumbnail (same CDN, direct), `media.previewUrl` preview clip; both kwiky page URLs (`kwiky.com/quickies/{id}`) and the media CDN URLs pass direct `curl -I` health checks but the page URL is NOT yt-dlp-resolvable (React SPA, generic extractor fails), so `formats` are populated with the direct MP4 URL and `video.url` is the page. Aspect ratio set from `media.width`/`media.height`; `uploadedAt` from RFC3339 `created` field; uploader name/URL/ID from `creator.name` and `creator.id`. 48 curated tags from `/api/v1/gateway/v1/keywords/populartags?gender=f` exposed via `categories` filter option. No proxy needed. |
| `fullporner` | `mainstream-tube` | no | no | HTML scraper for fullporner.com; thumbnail IDs derived from `/thumb/{id}.jpg` URLs and used to build direct `xiaoshenke.net/vid/{id}/720` media redirect URLs (Referer + User-Agent headers required); supports cat:/category:/pornstar:/star: shortcut queries; no proxy needed. |
| `cumlouder` | `mainstream-tube` | no | no | HTML scraper for cumlouder.com (Spanish/amateur tube, same platform family as superporn). Cards are `a.muestra-escena[href^="/porn-video/"]` with lazy thumb in `img[data-src]` (protocol-relative `//th0cdn.cumlouder.com/...`, direct, no Referer/proxy), `h2` title, `.vistas` views, `.minutos` duration (`06:14 m`); relative date only ("2 years ago"), so `uploadedAt` is not set. Default feed `/porn/` (page N≥2 is the bare `/N/` path, not `/porn/N/`); category `/porn-videos/{slug}/{N}/`; tag `/tag/{slug}/{N}/`; series `/series/{slug}/{N}/`. **The site's own `/search/` (POST `form[search]` or `?q=`) is behind a Cloudflare managed challenge — 403 even in a real browser — so keyword queries are routed instead: a query matching a hardcoded category (72, scraped from `/categories/`) goes to the category archive, anything else to `/tag/{slug}/` (unknown tags 404 → empty list).** Pornstar pages live at `/girl/{slug}/{N}/` (same cards/pagination; unknown slug = fast 404). A free-form keyword that is not a category is tried as a pornstar page first, then as a tag archive; `girl:`/`pornstar:`/`star:`/`model:` force the pornstar page. Supports `cat:`/`category:`, `tag:`, `series:` prefixes and the `categories` option. A Cloudflare challenge on the raw listing fetch is handed to the requester's FlareSolverr chain. `video.url` is the detail page, which yt-dlp resolves natively via the generic html5 extractor (the page embeds a signed `m5cdnst.cumlouder.com/*.mp4?secure=…` `<source>`, no Referer needed), so no `formats`/enrichment. The origin intermittently answers a first, uncached request with a Cloudflare 522 (a retry succeeds) — the listing fetch retries up to 3×, and `check.py`/yt-dlp may show a one-off `HTTP=-1`/522 on a cold detail page that is not a provider fault. No proxy needed. |
| `thepornbunny` | `mainstream-tube` | no | yes | KVS-style HTML scraper for thepornbunny.com; 24 items per site page; thumbnails at `https://www.thepornbunny.com/images/thumb/{id}.webp` from `data-original` attribute (no proxy needed); studio exposed as uploader; pornstar names in tags; `/proxy/thepornbunny/{slug}` fetches the video page, extracts `generate_mp4(enc_data, key, rnd, video_id)` args, decrypts `enc_data` via PBKDF2-HMAC-SHA512+AES-256-CBC to get an OK.ru session key, calls `api.ok.ru/fb.do?method=video.get&session_key=KEY&vids=RND` to get signed CDN URLs, and returns 302 to the best-quality okcdn.ru/vkuser.net MP4 URL (no special client headers needed); supports sort: new/popular/rated, 20 hardcoded categories via `categories` option, and tag:/category:/studio:/pornstar: query shortcuts. |
| `eporner` | `mainstream-tube` | no | no | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); supports sort: new/popular/rated/best; 65 hardcoded categories, plus `cat:`/`category:`, `tag:`, `pornstar:`/`star:`, `uploader:`/`profile:` query-prefix routing to the matching archive, a bare-keyword category-name match, and a background-loaded pornstar name→URL map from `/pornstar-list/` (falls back to `/tag/{slug}` keyword search otherwise). `video.url` is the detail page — **not** yt-dlp-resolvable and not directly playable on its own: opening it bare serves a small restricted/preview clip. Real playback needs per-card enrichment (bounded `buffer_unordered(6)`): fetch the detail page for its 32-hex `hash`, derive the site's own hashed token (`calc_hash` — split into four 8-hex chunks, each parsed as u32 and base36-encoded, concatenated; reverse-engineered from the player JS), then call the site's own `GET /xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false` JSON API for direct per-resolution mp4 URLs into `formats[]`. Separately from the hash dance, the resolved CDN mp4 URLs enforce their own Referer check: a request with no Referer or one pointing at eporner.com gets the real file, but any other non-empty Referer gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the exact "this video is only available at eporner.com"-style short clip a client sees if its default (or app-injected) Referer doesn't match, so every format explicitly declares `http_headers: {Referer: "https://www.eporner.com/"}` rather than relying on the client's default. No proxy needed (formats are direct CDN URLs, just header-gated). **2026-09-21:** the `calc_hash`/hash-regex/`alnum_id_from_href`/`resolve_formats` logic moved to `src/util/eporner_resolve.rs` so `melonstube` (a meta-search aggregator that redirects out to eporner.com for ~34% of its own cards) can reuse the exact same resolution without a cross-provider `use`, which would break either provider's `HOT_TUB_PROVIDER=<id>` single-provider build (only the one selected provider module gets compiled; `src/util/*` always does). `eporner.rs` itself now just calls `crate::util::eporner_resolve::resolve_formats(...)`. |
| `eporner` | `mainstream-tube` | no | yes | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); supports sort: new/popular/rated/best; 65 hardcoded categories, plus `cat:`/`category:`, `tag:`, `pornstar:`/`star:`, `uploader:`/`profile:` query-prefix routing to the matching archive, a bare-keyword category-name match, and a background-loaded pornstar name→URL map from `/pornstar-list/` (falls back to `/tag/{slug}` keyword search otherwise). `video.url` is the detail page — **not** yt-dlp-resolvable and not directly playable on its own: opening it bare serves a small restricted/preview clip. Real playback needs per-card enrichment (bounded `buffer_unordered(6)`): fetch the detail page for its 32-hex `hash`, derive the site's own hashed token (`calc_hash` — split into four 8-hex chunks, each parsed as u32 and base36-encoded, concatenated; reverse-engineered from the player JS), then call the site's own `GET /xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false` JSON API for direct per-resolution mp4 URLs into `formats[]`. Separately from the hash dance, the resolved CDN mp4 URLs enforce their own Referer check: a request with no Referer or one pointing at eporner.com gets the real file, but any other non-empty Referer gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the exact "this video is only available at eporner.com"-style short clip a client sees if its default (or app-injected) Referer doesn't match, so every format explicitly declares `http_headers: {Referer: "https://www.eporner.com/"}` rather than relying on the client's default. No proxy needed (formats are direct CDN URLs, just header-gated). **2026-09-21:** the `calc_hash`/hash-regex/`alnum_id_from_href`/`resolve_formats` logic moved to `src/util/eporner_resolve.rs` so `melonstube` (a meta-search aggregator that redirects out to eporner.com for ~34% of its own cards) can reuse the exact same resolution without a cross-provider `use`, which would break either provider's `HOT_TUB_PROVIDER=<id>` single-provider build (only the one selected provider module gets compiled; `src/util/*` always does). `eporner.rs` itself now just calls `crate::util::eporner_resolve::resolve_formats(...)`. **2026-10-22:** the signed CDN URLs expire exactly two hours after the `/xhr/video` call (the leading number in `/{expiry}_{ip}_{n}/`) and an expired link gets the same `na.mp4` decoy, so favourites, history and long-open lists played the one-minute clip. The links are also **IP-locked**: checked from foreign networks (check-host.net nodes) a fresh link answers 302 → `na.mp4`, while the resolving IP gets the file, so a redirect only works for a player behind the server's own public IP (a Jina fetch had misleadingly suggested otherwise). Formats therefore point at the streaming proxy `/proxy/eporner/{alnum_id}/{height}.mp4`: it resolves (cached 90 min per id/height, re-resolved when the CDN answers with a redirect), forwards `Range`, and returns a sized body so `206` responses keep `Content-Length` for AVPlayer. Not a redirect proxy, so app clients keep these in `formats`. All eporner playback bandwidth now flows through the server. melonstube's eporner destinations use the same `resolve_proxied_formats`. |
| `xnxx` | `mainstream-tube` | no | no | HTML scraper for xnxx.com (10M+ videos); unified card parser handles two formats: `div.thumb-block[data-eid]` (search) and `div.thumb-block.video[data-video='{"id":...}']` (hits); eid extracted from `/video-{eid}/{slug}` URL path; thumbnails at `thumb-cdn77.xnxx-cdn.com` and `thumbs-gcore.xnxx-cdn.com` (no proxy, no Referer needed); 0-indexed pagination (page 1 = `/hits`, page N = `/hits/{N-1}`); default feed is `/hits` (most-viewed — xnxx has no chronological listing); search via `/search/{slug}` (works for keywords and tags); supports `tag:`, `cat:`, `category:` query shortcuts; yt-dlp resolves `video.url` natively (XNXX extractor, returns 4-7 HLS formats); no proxy needed. |
| `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. |
@@ -91,6 +92,8 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `wowxxx` | `studio-network` | no | no | HTML scraper for the wowxxx.to premium aggregator (wow.xxx 301-redirects here now; `BASE_URL` points at the canonical domain directly). The site is Cloudflare-protected — `fetch_html` must NOT pin `Version::HTTP_11` (removed; now `None`), since forcing h1 while the TLS handshake still advertises the browser emulation's h2 ALPN is exactly the mismatch documented for jable.tv's CF gate. Default feed `/latest-updates/`, page 2 `/{N}/` suffix (for example `/latest-updates/2/`), search `/search/{query}/relevance/` with the same page suffix; supports `site:`/`studio:`/`network:`/`model:`/`pornstar:`/`tag:`/`cat:` query shortcuts to direct archive routes; list cards expose preview clips (`cast.wowxxx.to/preview/*.mp4`), thumbnails (`img.wowxxx.to/.../medium@2x/1.jpg`), duration, rating, views, site (as uploader), and model tags; `video.url` is the detail page URL and yt-dlp resolves HTML5 MP4 formats dynamically; no proxy needed. `www.wowxxx.to`/`img.wowxxx.to` are in `check.py`'s CF-protected allowlist since a plain probe still 403s even though the app's own client succeeds. |
| `xlecx` | `hentai-animation` | no | no | DLE (DataLife Engine)-style HTML scraper for xlecx.one, following the `thaiporntv` template. Latest feed `/f/cat={id}/sort={mode}/order=desc/` (page N≥2 appends `page/{N}/`); `sort` options new/popular/commented/rated map to DLE's `date`/`news_read`/`comm_num`/`rating` sort params; `category` option is the 2 hardcoded top-level archives (`videos`, `hentai-video`). Search via `/index.php?do=search&subaction=search&story={query}` (page N adds `search_start={N}`). Tag shortcut (`#slug` query) routes to `/tags/{slug}/` (paginated via `/tags/{slug}/page/{N}/`); the ~1,044-entry tag catalog is background-loaded once at startup from `/tags/` (`.tags-list1 a[href*='/tags/']`, absolute-URL anchors) into an `Arc<RwLock<Vec<FilterOption>>>` and exposed as the `filter`/"Tags" `ChannelOption` — note this option (and any `categories`) is deliberately stripped from the live `/api/status` response by `should_hide_status_option`/`decorate_channel` in `providers/mod.rs` (any `id=="filter"` option titled "Tags" is hidden site-wide), so its absence there is expected, not a bug; it is still honored by `/api/videos`. Cards parsed from `h3.thumb__title` + `img` inside standard DLE thumb blocks; id extracted from the `/{id}-{slug}.html` URL suffix. Thumbnails are direct `xlecx.one/uploads/posts/...` webp (no proxy/Referer needed). `video.url` is the detail page URL, which yt-dlp resolves natively via the generic HTML5 embed extractor; no `formats`/proxy needed. No `/api/uploaders` (no stable uploader identity on the site). |
| `xxxtik` | `tiktok` | yes | no | JSON-API short-form aggregator for xxxtik.com — every post is a moderated repost of a RedGifs clip, so the real media backend is the public **RedGifs v2 API**, not xxxtik itself (new pattern; no other provider currently resolves through a third-party media API). Listing API (`xxxtik-api-iw98m.ondigitalocean.app`, found by grepping the Angular `main-es2015.js` bundle): `GET /post/new`, `/post/top/{week,month,year,all}`, `/post/tag/{name}`, `/post/creator/{username}` — all **cursor**-paginated (`?cursor={lastItemId}&limit=N`; the `cursor` is the numeric `id` of the last item from the previous batch, not an offset/count — reaching page N walks N sequential requests, mirroring `fikfap`'s `fetch_cursor_page`). `GET /search?query=Q` returns tag/profile autocomplete suggestions only (no posts), so free-text search is routed through it to resolve a `Tag`/`Creator` target before a second listing call; `tag:`/`category:`/`cat:` and `user:`/`uploader:`/`creator:` query prefixes skip that lookup. Each post's `source` field is a `redgifs.com/watch/{id}` URL; the provider fetches a short-lived anonymous bearer token from `POST api.redgifs.com/v2/auth/temporary` (cached, refreshed once on a 401) and resolves `GET api.redgifs.com/v2/gifs/{id}` (bounded to 8-way concurrency via `buffer_unordered`) for the real `media.redgifs.com/*.mp4` + poster, both fetchable with zero auth/Referer. `video.url` is the xxxtik page (`https://xxxtik.com/feed/{uuid}`, not yt-dlp-resolvable — Angular SPA, generic extractor fails), with `formats` populated from the resolved redgifs mp4; tags merge xxxtik's own tags with RedGifs' tags. 20 curated tags exposed via `categories` (xxxtik has ~62k tags total, too many to background-load). `/api/uploaders` works via `GET /user/by-username/{name}` + `/post/creator/{name}`, but xxxtik's "creator" accounts are inconsistent: some (e.g. `dlhoodninja`, `besttits`) return real posts matching their profile `_count.posts`; others with a nonzero `_count.posts` (e.g. `bigboobsgw`, count 472) return an empty `/post/creator/` list — likely synthetic curation accounts (`name@default` emails) rather than real uploaders. The provider degrades gracefully (returns the profile with `videos: []`, no error) rather than guessing which accounts are "real". No proxy needed — all media/thumb URLs are publicly fetchable with no Referer or auth. |
| `txxx` | `mainstream-tube` | no | no | JSON-API scraper for txxx.me (mirror of txxx.com; Vue SPA, same KVS-based API family as the hclips/upornia/hdzog network). Listing: `GET /api/json/videos2/86400/str/{sort}/{count}/{section}.{object}.{page}.all...jsond` with sort `latest-updates`/`most-popular`/`top-rated`/`longest`; **the `.json` variant ignores the page number, `jsond` does not**. Category archive = section `categories`, object = category dir slug (numeric ids return `category_not_found`); ~80 top categories hardcoded as the `categories` option (225 exist at `/api/json/categories/86400/str.all.en.jsond`). Search: `GET /api/videos2.php?params=86400/str/relevance/{count}/search..{page}.all..&s={query}` (relevance only; sort is ignored; page works although `params.page` echoes 1). `count` follows `perPage` (max 60). The page URL `/videos/{id}/{dir}/` is **not** yt-dlp-resolvable, so every card is enriched (bounded `buffered(8)`) via `GET /api/videofile.php?video_id={id}&lifetime=8640000`, whose `video_url` is base64 of `/get_file/...` with Cyrillic look-alike letters (А Е М С …) substituted and `~` for `=`; the decoded `https://txxx.me/get_file/...` 302s to a per-client signed `*.ahcdn.com` URL (IP-pinned, so it must NOT be resolved server-side; no Referer needed). Items without formats are dropped. Heights come from the listing `file_formats` (`||_hq.mp4|1278x720|…||_tr.mp4|…`; `_tr.mp4` is the trailer and becomes `preview` via `pv`). Rating 0..5 → ×20; `likes`/`dislikes` ignored; category titles become tags; uploader = content source else member `display_name` (`txxx:{user_id}`; no profile endpoint). Thumbs `tn.txxx.tube` direct. Queries matching a category name route to its archive; `cat:`/`category:` prefix. No models/channels archives (sections `models`/`channels` are rejected by the API). No proxy needed. |
| `hooporn` | `studio-network` | no | no | HTML scraper for hooporn.com (studio/pornstar tube; Cloudflare-fronted but serves plain 200 to the shared requester). Cards `div.video-card` with lazy thumb in `img[data-src]` (`//imgs.hooporn.com/thumb/...jpg?v2`, direct, no Referer), title in `div.video-title a`, `.time` duration, `span.create` unix timestamp → `uploadedAt`; no views/rating. Feed `/home/{N}`, category `/category/{slug}/{N}` (63 hardcoded from `/categories`), pornstar `/pornstar/{slug}/{N}`, studio `/studio/{slug}/{N}` (studio slugs are concatenated, e.g. `familystrokes`), search `/search?q=…&p={N}` (24 per page; `?search=`/`/search/q/N` do not work). Prefix queries `cat:`/`category:`, `pornstar:`/`star:`/`model:`, `studio:`/`site:`; a bare keyword matching a category name routes to the archive; any other query is first probed on the site (`/pornstar/{slug}/1`, then `/studio/{slug-without-dashes}/1`; unknown archives are an instant 404 read through the raw response, Cloudflare challenges are handed to the requester/FlareSolverr chain) and used as that archive if it exists, otherwise it is a keyword search. The probe runs on every page, so page N of a pornstar/studio query stays in that archive. The watch page `/watch/{24hex}` is **not** yt-dlp-resolvable (player is an iframe), and the mp4 is signed (`videos.trendyporn.com/...mp4?md5=…&expires=…`, ~1 day, 403 without the signature, no Referer/IP binding), so every card is enriched (`buffered(8)`): watch page → `iframe[src*="/video/"]` (`//vid.hooporn.com/video/{n}`) → `var video = "…"` into one `formats[]` entry (quality unknown, labelled `auto`); the watch page also yields category tags, pornstar names (as tags) and the studio (uploader `hooporn:{studio-slug}`). Items whose enrichment fails are dropped. No proxy needed. |
## Proxy Routes
@@ -123,6 +126,7 @@ These resolve a provider-specific input into a `302 Location`.
These return binary media or images, sometimes rewriting manifests or forwarding cookies/referers.
- `/proxy/shooshtime-media/{endpoint}*`
- `/proxy/eporner/{alnum_id}/{height}.mp4` (streams the IP-locked signed mp4 with Range passthrough; link cached 90 min)
- `/proxy/noodlemagazine/{endpoint}*`
- `/proxy/noodlemagazine-thumb/{endpoint}*`
- `/proxy/hanime-cdn/{endpoint}*`

93
docs/provider-fix-todo.md Normal file
View File

@@ -0,0 +1,93 @@
# Provider fix todo
Source: `check.py` + search probe audit, 2026-10-10. Serial re-run of every flagged channel,
so timeouts below are not load artifacts. Update the status column as each item is fixed and
verified (`HOT_TUB_PROVIDER=<id>` build, `/api/videos` feed + search + page 2, `yt-dlp` on
`video.url` or `formats[0].url`, `python3 check.py <id>`).
Status: `[ ]` open, `[~]` in progress, `[x]` fixed and verified, `[-]` not reproducible / not a provider bug (reason noted).
## 1. Channels returning no items
| | Channel | Symptom | Notes |
|---|---|---|---|
| [x] | `javtiful` | feed times out (150s) then 0 items; search 0 | site redesign (`article.video-card`); rewrote parser, page URL as `video.url` (yt-dlp-native), no per-item detail fetches. 23 items, p2/search OK |
| [x] | `sextb` | 0 items on feed, search and page 2 | Jina mirror now 403/429; site is plain HTML reachable with the emulating client. Rewrote listing/detail/option parsers for HTML, removed mirror. Remaining check.py errors are tooling items (proxy URL policy; imggle.net thumbs are TLS-gated for `requests`) |
| [-] | `camsoda` | 0 items on every request | not a provider bug here: `www.camsoda.com` is DNS-sinkholed by this host's resolver (0.0.0.0; public resolvers return 74.122.168.1), local FlareSolverr gets ERR_CONNECTION_REFUSED, Jina is 403/429, and Cloudflare serves a managed challenge to non-browser clients. Needs the production network to verify. |
| [-] | `hentaimama` | 0 items or 85-150s timeouts | not fixable in code from this host: site now serves a Cloudflare managed challenge (`cf-mitigated: challenge`) on every path; browser-TLS impersonation gets 403 and the local FlareSolverr (:8191, process outside this sandbox) hangs even on a non-CF page (javtiful, 60s). Latency is the requester waiting on FlareSolverr. Needs a healthy FlareSolverr to verify. |
| [-] | `hotbunny` | 0 items; first request 78s | same cause: `hotbunny.ai` (including `/api/post/*`) now answers a Cloudflare managed challenge; previously the JSON API was reachable directly. Needs a healthy FlareSolverr. |
## 2. Playback: page URL not resolvable by yt-dlp / formats not playable
| | Channel | Symptom | Notes |
|---|---|---|---|
| [x] | `thaiporntv` | page URL "Unsupported URL", no `formats` | player attribute renamed `data-enc` -> `data-video-sources` (reversed base64 JSON `{quality,url}`); decoder handles both. 3 formats/item, yt-dlp OK, check.py passes |
| [x] | `pornmz` | page URL "Unsupported URL", no `formats` | site changed `itemprop="contentURL"` -> `contentUrl`; selector now case-insensitive. Enrichment concurrency 10 + 25s per-item timeout. Origin is slow (15s+/detail) so first feed 25-50s. check.py passes |
| [x] | `coomer` | page URL "Unsupported URL"; media host CF-gated here | image-only posts were listed as videos with no playable URL: now dropped, and a video in `attachments` is picked up. Also fixed: `page=` is ignored by the API (every page 2+ was a copy of page 1; now `o=` offset), search results shared one cache key, and compressed API bodies were not decoded (enabled wreq gzip/br/deflate/zstd). n*.coomer.st CDN connect-times-out from this host, and the API 429s this IP after bursts, so playback/"girl" search could not be verified end to end |
| [x] | `tube8` | page URL 404; `/proxy/tube8/{id}` 502 | sponsored `channel` cards carry a stale `data-video-id` (404) while the card link is the live page; id/URL now come from the link. Signed `pix-cdn77` thumbs 403 without a Referer: new host-restricted `/proxy/aylo-thumb` (also fixes ph-shorties). check.py passes |
| [x] | `hypnotube` | page URLs 404 on all sampled items | the 404 was a check.py artifact (server answers HEAD 404, GET 200); check.py now confirms with a ranged GET. Real bug found on the way: the site returns ONE results page per searchgate POST, so any 2nd search on the shared session (and every page 2) came back empty. Each (query, page) now uses its own isolated session. 3 consecutive searches + page 2 verified |
| [x] | `fullpornerorg` | `lulustream` proxy 403 / 502 | three separate causes: (1) hoster files deleted ("File is no longer available") but still listed -> now probed and dropped; (2) `myvidplay.com` embeds lost their host (bare ID sent to lulustream, wrong namespace) -> routed to the doodstream proxy with host kept, which now allows myvidplay/playmogo; (3) tnmr.org m3u8 is TLS-fingerprint gated (works with chrome TLS = the channel's `--impersonate chrome-120`; plain yt-dlp 403s are the checker's expected CF warnings). myvidplay playback could not be verified: the embed now returns a Turnstile captcha shell to this client |
| [-] | `ripnsfw` | `doodstream` format 502 | lulustream format on the same items resolves (302). The doodstream format cannot be verified here: `*.dood.video` resolves to 127.0.0.1 on this host's resolver, so the CDN is unreachable. Needs the production network |
| [-] | `freepornvideosxxx` | yt-dlp 502 on page URL | listing/search/p2 fine (24 items). Page URL is yt-dlp-native (html5 `<source>` 480/720/2160). The 502 is the site's edge answering plain yt-dlp's TLS fingerprint (3 of 4 items 502; 4/4 fine with `--impersonate chrome-120`; curl always 200). The channel declares that command but see "Open decision" below: status never delivers it |
| [-] | `hdporn92` | m3u8 formats time out (curl + yt-dlp) | provider works (embed deobfuscated, signed m3u8 returned, yt-dlp resolves the first item). Every morencius.com request (embed page AND playlist) carries a constant ~20.1s server-side delay from this egress (detail page on hdporn92.com is 0.25s), which exceeds check.py's 15s HEAD / 20s yt-dlp socket timeouts. Raising enrichment concurrency 4 -> 10 made it worse (108s, then timeouts), so it was reverted. Likely a host tarpit for this network; verify from the production IP |
## 3. Thumbnails
| | Channel | Symptom | Notes |
|---|---|---|---|
| [x] | `xxdbx` | malformed thumbs `https:/7004….jpg` | assets are now site-relative (`/id.jpg`) rather than `//host/..`; new `absolute_url` resolves all three forms. 36 items, p2/search OK, check.py passes |
| [x] | `ph-shorties` | thumbs return 403 | same Referer-gated `pix-cdn77.phncdn.com` thumbs; routed through `aylo-thumb` proxy. check.py passes |
| [x] | `noodlemagazine` | thumbs unreachable (-1 / 400) in check.py | thumb proxy dropped the preview's query string (`?id&tkn..`), rejected extensionless `getVideoPreview` paths, and warmed up via noodlemagazine.com through the fallback chain on every image (10-28s). All 24 thumbs now 200 in ~0.08s. Titles fix from earlier commit stands |
## 4. Search
| | Channel | Symptom | Notes |
|---|---|---|---|
| [-] | `fapello` | "girl" and "milf" both 0 | by design, documented in the provider: the site's search returns photo models, not videos; no keyword video search exists, so queries are literal-filtered over the feed |
| [x] | `chaturbate` | "girl" and "milf" both 0 | format arguments swapped in the search URL (`girl/api/...?keywords=https://chaturbate.com`, a relative-URL builder error); fixed + unit test. Also stale-cache branch never refreshed. 90 results per query, yt-dlp resolves the live stream |
| [x] | `cumlouder` | unknown tag takes 146s before returning 0 | a real 404 went through the requester's Jina/FlareSolverr fallback chain; now the raw response is read, 404 returns empty immediately (0s), 5xx/429 still retried. check.py passes |
| [x] | `vjav` | "girl" 0, "milf" 10 | not term-specific: "girl" exactly matches a member username with no uploads, so the implicit uploader match hijacked the keyword search. Empty uploader/tag matches now fall back to keyword search (site API returns 34k results for "girl"). check.py passes |
| [x] | `xxxtik` | "girl" 0 (not re-run serially) | three things. (1) shared requester: raw requests did not replay the remembered FlareSolverr identity, so a RedGifs token minted under the solved UA was rejected (401) on every gif lookup after one transient connect error (empty feed, 71s); `get_raw_with_headers_timeout` now adopts it. (2) tag/creator pages mixing non-RedGifs posts (erome/onlyfans links) emptied whole pages; now pulls up to 4 raw batches per page. (3) "girl" itself has no RedGifs posts in its first 600 entries, so empty is correct there; "milf" returns 10. Feed/p2 10 items, check.py passes. First request can still take ~50s when api.redgifs.com has a connect error (FlareSolverr detour) |
| [x] | `hypnotube` | "milf" 0, "girl" 20 | same root cause as above (second search on a session returned nothing), not term-specific |
## 5. Slow channels
| | Channel | Symptom | Notes |
|---|---|---|---|
| [-] | `aps` | 30-98s per request; check.py 30s timeout | works (57 items, p2/search OK). allpornstream.com answers every client, including browser-TLS impersonation, with a Cloudflare interactive challenge ("Just a moment..."), so each uncached request is a FlareSolverr solve; the local FlareSolverr is slow/wedged. Provider code is not the bottleneck |
| [-] | `hdporn92` | feed about 66s per request | consequence of the 20s per-embed host delay above (10 embeds per page); not fixable in the provider without moving to play-time resolution, which would put the 20s on every play and every HLS playlist request |
| [-] | `porndish` | 14-23s feed/search | same class: Cloudflare challenge on porndish.com (plain/yt-dlp 403s), resolved through FlareSolverr per uncached request; also its myvidplay playback goes through the `curl_cffi` python subprocess, which is not installed in this sandbox |
## 6. Tooling
| | Item | Notes |
|---|---|---|
| [x] | `check.py` "url points to hottub server" | contradicted the hottub skill | proxied `video.url` is now verified to resolve (redirect target reachable, CF hosts -> warning) instead of hard-failing. Affected: spankbang, hqporner, pornhd3x, pimpbunny, sxyprn, noodlemagazine |
## Decision: `ytdlpCommand` delivery (agreed: targeted rollout)
Providers declare `Channel.ytdlpCommand` (~90 channels), but `/api/status` used to send it only for `pimpbunny`.
Delivering all of them at once risks breaking working channels if the app's yt-dlp lacks impersonation support, so
`decorate_channel` now delivers a provider's declared command **only for channels in `YTDLP_COMMAND_CHANNELS`**
(`src/providers/mod.rs`): `freepornvideosxxx`, `missav`. Add a channel there once plain yt-dlp is verified to fail for it.
Still to do with the app side: confirm the app applies `ytdlpCommand` for non-pimpbunny channels and that its yt-dlp has an
impersonation target; if a minimum client version is needed, gate on `ClientVersion` (not done, the minimum is unknown).
`check.py` applies whatever `/api/status` exposes, so it verifies each addition.
## Found while fixing (pre-existing, unrelated)
- `src/proxy.rs`: the `pornhub-thumb` resource was registered as `/proxy/pornhub-thumb` inside the `/proxy` scope (unreachable); fixed alongside `aylo-thumb`. No provider uses it today.
- Failing unit tests, untouched files: `proxies::hanimecdn::tests::prefixes_relative_paths_with_hanime_cdn_host`, `proxies::vidara::tests::normalizes_detail_request_with_path_only`.
## Environmental / needs the production IP (not worked here)
`porndish`, `supjav`, `jable`, `fapello`, `missav`, `wowxxx`, `hentaihaven` return Cloudflare 403 to
yt-dlp/curl from this machine while the server itself returns items. `hsex` returned 429 (rate limit).
## Cloudflare challenge rule
Any request path that bypasses the requester's fallback chain must still hand a Cloudflare challenge (403/503 or
"Just a moment...") to FlareSolverr. `cumlouder` (raw 404-aware listing fetch) now falls back to
`get_with_headers`; `hypnotube` (isolated search session) solves via the shared requester and replays the solved
cookies + user agent in a fresh isolated session.

View File

@@ -26,6 +26,7 @@ These rules cut across every step below. They are intentionally grouped at the t
### Output Shape
- **Verified Hot Tub app clients cannot follow redirects in `formats[].url`.** Redirect proxies (`REDIRECT_PROXIES` in `src/proxy.rs`) are for `video.url` in the app response; `api.rs` moves such formats to `url` for verified app clients as a safety net. Other clients keep the redirect-proxy URLs in `formats`.
- Use `formats` for real media URLs (HLS, multi-quality, signed token), and keep `video.url` on a stable page URL. Never set `video.url` to a tokenized stream that needs a `Referer` to play — the `url` field has no header contract.
- If thumbnails need a `Referer` and the player has no per-thumb header mechanism, proxy them via `/proxy/<id>-thumb/...`. The same applies to manifests with relative URIs.
- Keep filter `id` values stable and machine-targeted. Display `title`s change; ids route.

View File

@@ -0,0 +1,42 @@
# Seamless query resolution todo
Goal: a free-form query ("gal ritchie", "family strokes") should reach the matching
pornstar / studio / channel / tag archive **without** a `pornstar:` / `studio:` / `tag:` prefix,
by asking the site (probe) and falling back to keyword search. Prefixes stay as explicit overrides.
Pattern (see `hooporn`, `cumlouder`): after known-category matching, probe page 1 of each entity
archive for the slug with `Requester::get_probe` (404 = instant `None`, Cloudflare challenge goes to
FlareSolverr, 5xx/429 retried once), use the first archive that yields cards (page N stays in that
archive), else keyword search. A probe is only valid after checking what the site answers for an
unknown slug (404 vs soft-404 that lists unrelated videos).
Status: `[ ]` open, `[x]` done and verified live (feed of the archive + unknown name falls back),
`[-]` not applicable (reason).
| | Provider | Prefix kinds today | Plan / evidence |
|---|---|---|---|
| [x] | `hooporn` | cat, pornstar, studio | reference implementation |
| [x] | `cumlouder` | cat, tag, series, girl | reference implementation (girl page, then tag) |
| [x] | `fullporner` | cat, pornstar | probes `/pornstar/{slug}` (unknown = 404 with a suggestion grid, so status decides); `pornstar:` slug now dashed |
| [x] | `fullpornerorg` | pornstar | probes `/videos/pornstar/{slug}/` (404 for unknown) |
| [x] | `tube8` | tag, cat, channel, pornstar | probes pornstar then channel |
| [x] | `youporn` | tag, channel, pornstar, amateur | probes pornstar, channel, amateur (plus existing background shortcut map) |
| [x] | `porn4fans` | tag, cat, model | probes `/models/{slug}/` then `/tags/{slug}/` (both 404 when unknown) |
| [x] | `xvideos` | tag, cat, uploader/channel/profile | probes `/channels/{slug}/videos/new/0` (JSON; pornstar profiles redirect there). **Also fixed**: the old `channel:`/`uploader:` prefix returned nothing (landing page cards are `/prof-video-click/` links); channels are now read from the JSON video list |
| [x] | `xhamster` | cat, channel | added `Pornstar` target; probes pornstar then channel |
| [x] | `xgroovy` | cat, pornstar, channel | probes pornstar then channel |
| [x] | `thepornbunny` | tag, cat, studio, pornstar | probes pornstar, studio, tag (unknown slugs redirect home: `get_probe` now treats a redirect to another path as a miss) |
| [x] | `pornhub` | channel, pornstar, model, user | background catalogs already matched listed names; unknown names are probed (`pornstar`, `model`, `channels`) and cached in `tag_map` (misses remembered) |
| [x] | `clapdat` | tag, user | probes tag then user |
| [x] | `fikfap` | tag, user | single-word query that is an existing profile (profile endpoint) goes to its posts, empty profile falls back to search |
| [x] | `erome` | uploader, user | single-word query that is an existing `/{name}` with albums goes to the uploader |
| [x] | `redgifs` | tag, user | single-word query that is an existing creator (users endpoint) goes to the creator, empty falls back |
| [x] | `sextb` | (catalog matched) | catalog (actress/studio/genre) match first, then probes `/actress/{slug}` and `/studio/{slug}` for names outside the catalog |
| [x] | `coomer` | u:, @service:user | single-word query that is an existing creator id (`/api/v1/{service}/user/{id}/profile`) goes to the creator. **Also fixed**: creator feeds are a bare JSON array, which the parser rejected (every creator feed was empty) |
| [-] | `ripnsfw`, `hsex`, `omgxxx`, `eporner`, `borntobefuck`, `archivebate` | various | already seamless: bare names match background-loaded creator / studio / tag catalogs (or the whole catalog is filtered client-side) |
| [-] | `perfectgirls`, `okxxx`, `pornhat`, `perverzija`, `porntrex` | sites/models/stars | bare names already resolve through the background-loaded `tag_map`; these clones build their own `wreq` client with no `ServerOptions`, so a live probe needs a separate refactor onto the shared requester |
| [-] | `homoxxx` | models, pornstars, channels, categories, tags | legacy provider (own client, unpaginated archives, no `/models/` index on the site); not worth a probe until it is rewritten |
| [-] | `kwiky`, `animeidhentai`, `hentaitv`, `xnxx`, `blowjobspro`, `txxx` | tag/cat | search already is the entity lookup, or the site has no pornstar/studio archives to probe (txxx: the API rejects `models`/`channels` sections) |
| [-] | `fyptt` | cat | by design: bare category words are common search terms and must not be hijacked |
| [-] | `notfans` | tag, category | tag URLs are hashed ids, so an unknown name cannot be probed; search covers it |
| [-] | `allpornstream`, `jable`, `wowxxx`, `hornyleak`, `hotbunny`, `hentaimama`, `tikporn`, `camsoda` | actor/producer, model, studio, ... | Cloudflare-gated or down from this host (403/522, FlareSolverr wedged), so a probe cannot be verified here; each would add one or two FlareSolverr solves per search. Candidates for the same pattern once checked from the production network |

View File

@@ -714,6 +714,15 @@ async fn videos_post(
.collect();
}
// The Hot Tub app cannot follow a redirect in `formats[].url`: for verified
// app clients, formats that point at a redirecting `/proxy/...` become the
// item's `url` instead.
if clientversion.is_verified_hottub() {
for video in video_items.iter_mut() {
move_redirect_formats_to_url(video);
}
}
if let Some(literal_query) = literal_query.as_deref() {
#[cfg(feature = "debug")]
let before = video_items.len();
@@ -794,6 +803,30 @@ async fn videos_post(
Ok(web::HttpResponse::Ok().json(&videos))
}
/// Moves formats that point at a redirecting proxy to `video.url`.
///
/// If direct (non-redirecting) formats remain they are kept and `url` is left
/// alone; otherwise `url` becomes the first (best-listed) redirecting proxy URL
/// and `formats` is dropped entirely.
pub(crate) fn move_redirect_formats_to_url(video: &mut VideoItem) {
let Some(formats) = video.formats.take() else {
return;
};
let (redirecting, direct): (Vec<_>, Vec<_>) = formats
.into_iter()
.partition(|format| crate::proxy::is_redirect_proxy_url(&format.url));
if redirecting.is_empty() {
video.formats = Some(direct);
return;
}
if direct.is_empty() {
video.url = redirecting[0].url.clone();
video.formats = None;
} else {
video.formats = Some(direct);
}
}
async fn uploaders_post(
uploader_request: web::types::Json<UploadersRequest>,
cache: web::types::State<VideoCache>,
@@ -1082,4 +1115,53 @@ mod tests {
assert_eq!(item.uploader.as_deref(), Some("FreeusePorn"));
assert_eq!(item.formats.as_ref().map(|formats| formats.len()), Some(2));
}
fn item_with_formats(urls: &[&str]) -> VideoItem {
let mut item = VideoItem::new(
"id".to_string(),
"t".to_string(),
"https://site.example/watch/1".to_string(),
"c".to_string(),
"https://thumb".to_string(),
10,
);
item.formats = Some(
urls.iter()
.map(|u| crate::videos::VideoFormat::new(u.to_string(), "q".to_string(), "mp4".to_string()))
.collect(),
);
item
}
#[test]
fn redirect_only_formats_move_to_url() {
let mut item = item_with_formats(&[
"http://h/proxy/hqporner/1080",
"http://h/proxy/hqporner/720",
]);
move_redirect_formats_to_url(&mut item);
assert_eq!(item.url, "http://h/proxy/hqporner/1080");
assert!(item.formats.is_none());
}
#[test]
fn direct_formats_are_kept_and_redirect_ones_dropped() {
let mut item = item_with_formats(&[
"http://h/proxy/hqporner/1080",
"https://cdn.example/v.mp4",
]);
move_redirect_formats_to_url(&mut item);
assert_eq!(item.url, "https://site.example/watch/1");
let formats = item.formats.expect("direct format kept");
assert_eq!(formats.len(), 1);
assert_eq!(formats[0].url, "https://cdn.example/v.mp4");
}
#[test]
fn media_proxy_formats_are_untouched() {
let mut item = item_with_formats(&["http://h/proxy/noodlemagazine/x.mp4"]);
move_redirect_formats_to_url(&mut item);
assert_eq!(item.url, "https://site.example/watch/1");
assert_eq!(item.formats.map(|f| f.len()), Some(1));
}
}

View File

@@ -799,6 +799,39 @@ impl ArchivebateProvider {
.map(|value| value.to_string())
}
/// The proxied format needs no network, so an item always plays through
/// it even when the best-effort header enrichment below times out.
fn with_default_format(item: VideoItem, options: &ServerOptions) -> VideoItem {
let format_url = Self::proxied_video(options, &item.url);
if format_url.is_empty() || item.formats.is_some() {
return item;
}
let mut item = item;
item.formats = Some(vec![VideoFormat::new(
format_url,
"source".to_string(),
"mp4".to_string(),
)]);
item
}
/// For verified Hot Tub app clients: no `formats` at all. The item's `url`
/// is the lazy `/proxy/archivebate` redirect, which the app plays directly
/// (it resolves the media at playback time).
fn proxy_as_item_url(videos: Vec<VideoItem>, options: &ServerOptions) -> Vec<VideoItem> {
videos
.into_iter()
.map(|mut video| {
let proxy_url = Self::proxied_video(options, &video.url);
if !proxy_url.is_empty() {
video.url = proxy_url;
}
video.formats = None;
video
})
.collect()
}
async fn enrich_video(&self, item: VideoItem, options: &ServerOptions) -> VideoItem {
let page_url = item.url.clone();
let format_url = Self::proxied_video(options, &page_url);
@@ -1148,11 +1181,15 @@ impl ArchivebateProvider {
platform: candidate.platform.clone(),
gender: candidate.gender.clone(),
};
let items = self
// A candidate whose profile page is gone (the search index lists
// deleted profiles) must not abort the whole search: try the next.
match self
.fetch_items_for_target(cache.clone(), &target, 1, per_page, &options)
.await?;
if !items.is_empty() {
return Ok(items);
.await
{
Ok(items) if !items.is_empty() => return Ok(items),
Ok(_) => {}
Err(_) => {}
}
}
@@ -1188,6 +1225,14 @@ impl Provider for ArchivebateProvider {
if videos.is_empty() {
return videos;
}
let is_app_client = options
.client_version
.as_ref()
.map(ClientVersion::is_verified_hottub)
.unwrap_or(false);
if is_app_client {
return Self::proxy_as_item_url(videos, &options);
}
stream::iter(videos.into_iter().map(|video| {
let provider = self.clone();
let options = options.clone();
@@ -1199,11 +1244,11 @@ impl Provider for ArchivebateProvider {
.await;
match timeout_result {
Ok(enriched) => enriched,
Err(_) => video,
Err(_) => Self::with_default_format(video, &options),
}
}
}))
.buffer_unordered(4)
.buffered(4)
.collect::<Vec<_>>()
.await
}
@@ -1218,3 +1263,52 @@ impl Provider for ArchivebateProvider {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options() -> ServerOptions {
ServerOptions {
client_version: None,
featured: None,
category: None,
sites: None,
filter: None,
language: None,
public_url_base: Some("http://hottub.example".to_string()),
requester: None,
network: None,
stars: None,
categories: None,
duration: None,
sort: None,
sexuality: None,
}
}
#[test]
fn app_clients_get_the_proxy_url_and_no_formats() {
let mut item = VideoItem::new(
"archivebate:1".to_string(),
"t".to_string(),
"https://archivebate.com/watch/1".to_string(),
CHANNEL_ID.to_string(),
"https://cdn.example/t.jpg".to_string(),
10,
);
item.formats = Some(vec![VideoFormat::new(
"x".to_string(),
"source".to_string(),
"mp4".to_string(),
)]);
let out = ArchivebateProvider::proxy_as_item_url(vec![item], &options());
assert_eq!(out.len(), 1);
assert_eq!(
out[0].url,
"http://hottub.example/proxy/archivebate/archivebate.com/watch/1"
);
assert!(out[0].formats.is_none());
}
}

View File

@@ -138,6 +138,34 @@ impl ChaturbateProvider {
Ok(video_items)
}
/// `keywords=` search on the room list. (The base URL and query used to be
/// passed in swapped order, producing `milf/api/...?keywords=https://...`.)
fn build_search_url(base: &str, query: &str, page: u8) -> String {
let keywords = query
.trim()
.chars()
.map(|c| match c {
' ' => "+".to_string(),
c if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') => {
c.to_string()
}
c => {
let mut buf = [0u8; 4];
c.encode_utf8(&mut buf)
.bytes()
.map(|b| format!("%{b:02X}"))
.collect::<String>()
}
})
.collect::<String>();
format!(
"{}/api/ts/roomlist/room-list/?keywords={}&limit=90&offset={}",
base,
keywords,
90 * (page.max(1) as u32 - 1)
)
}
async fn query(
&self,
cache: VideoCache,
@@ -145,22 +173,15 @@ impl ChaturbateProvider {
query: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let mut video_url = format!(
"{}/api/ts/roomlist/room-list/?keywords={}&limit=90&offset={}",
query,
self.url,
90 * (page - 1)
);
video_url = video_url.replace(" ", "+");
let video_url = Self::build_search_url(&self.url, query, page);
// Check our Video Cache. If the result is younger than 1 hour, we return it.
let old_items = match cache.get(&video_url) {
Some((time, items)) => {
if time.elapsed().unwrap_or_default().as_secs() < 60 * 1 {
return Ok(items.clone());
} else {
let _ = cache.check().await;
return Ok(items.clone());
}
// Expired: refetch, keeping the stale list as the fallback.
items.clone()
}
None => {
vec![]
@@ -347,3 +368,18 @@ impl Provider for ChaturbateProvider {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::ChaturbateProvider;
#[test]
fn builds_search_url_with_base_first() {
assert_eq!(
ChaturbateProvider::build_search_url("https://chaturbate.com", "big tits", 2),
"https://chaturbate.com/api/ts/roomlist/room-list/?keywords=big+tits&limit=90&offset=90"
);
assert!(ChaturbateProvider::build_search_url("https://chaturbate.com", "a&b", 1)
.contains("keywords=a%26b&"));
}
}

View File

@@ -1,7 +1,8 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme,
Provider, build_proxy_url, probe_first_archive, report_provider_error, requester_or_default,
slug_dashed, strip_url_scheme,
};
use crate::status::*;
use crate::util::time::parse_time_to_seconds;
@@ -149,6 +150,29 @@ impl ClapdatProvider {
}
}
/// A free-form query that names a tag or a user goes to that archive
/// (probed on the site; unknown names 404), else keyword search.
async fn resolve_keyword(&self, query: &str, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let candidates = [
Target::Tag { slug: slug.clone() },
Target::User { username: slug },
];
let urls: Vec<String> = candidates
.iter()
.filter_map(|t| self.listing_url(t, 1))
.collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
let (index, _) = probe_first_archive(&mut requester, &urls, &format!("{}/", self.url), |body| {
body.contains("/video/")
})
.await?;
candidates.into_iter().nth(index)
}
fn listing_url(&self, target: &Target, page: u16) -> Option<String> {
let page = page.max(1);
match target {
@@ -491,7 +515,12 @@ impl Provider for ClapdatProvider {
sort
};
let query_value = query.unwrap_or_default();
let target = self.resolve_target(&query_value, &sort_value);
let mut target = self.resolve_target(&query_value, &sort_value);
if let Target::Search { query } = &target {
if let Some(found) = self.resolve_keyword(query, &options).await {
target = found;
}
}
let stubs = match &target {
Target::Search { query } => match self

View File

@@ -8,7 +8,7 @@ use wreq::Version;
use crate::{
DbPool,
api::ClientVersion,
providers::{Provider, requester_or_default},
providers::{Provider, probe_first_archive_with, requester_or_default},
status::{Channel, ChannelOption, FilterOption},
util::cache::VideoCache,
videos::{ServerOptions, VideoFormat, VideoItem},
@@ -37,6 +37,8 @@ const REFERER: &str = "https://coomer.st/";
const JSON_ACCEPT: &str = "text/css,*/*;q=0.1";
const CACHE_TTL_SECS: u64 = 60 * 15;
const MAX_PAGE: u16 = 50;
/// The API pages by offset (`o`), in blocks of this many posts. `page=` is ignored.
const API_PAGE_SIZE: u16 = 50;
const DEFAULT_PER_PAGE: usize = 30;
const USER_POSTS_FETCH_CONCURRENCY: usize = 4;
@@ -145,6 +147,43 @@ impl CoomerProvider {
}
}
/// A single-word query that is an existing creator id (the profile
/// endpoint answers 404 otherwise) goes to that creator's posts, trying the
/// filtered service first, else keyword search.
async fn resolve_keyword(
query: &str,
service_filter: Option<&str>,
options: &ServerOptions,
) -> Option<Target> {
let name = query.trim().trim_start_matches('@');
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
return None;
}
let mut services = vec![Service::Onlyfans, Service::Fansly, Service::Candfans];
if let Some(preferred) = Self::resolve_service(service_filter) {
services.retain(|s| s.as_str() != preferred.as_str());
services.insert(0, preferred);
}
let urls: Vec<String> = services
.iter()
.map(|s| format!("{BASE_URL}/api/v1/{}/user/{name}/profile", s.as_str()))
.collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
let (index, _) =
probe_first_archive_with(&mut requester, &urls, Self::json_headers(), |body| {
body.contains("\"id\"")
})
.await?;
Some(Target::Uploader {
service: services[index].clone(),
username: name.to_string(),
})
}
fn json_headers() -> Vec<(String, String)> {
vec![
("Referer".to_string(), REFERER.to_string()),
@@ -239,19 +278,18 @@ impl CoomerProvider {
}
}
fn offset_for(page: u16) -> u32 {
(page.max(1).min(MAX_PAGE) as u32 - 1) * API_PAGE_SIZE as u32
}
fn build_main_url(target: &Target, page: u16) -> String {
let page = page.max(1).min(MAX_PAGE);
let offset = Self::offset_for(page);
match target {
Target::Latest => format!("{}/api/v1/posts?limit=50&page={page}", BASE_URL),
Target::Popular => format!("{}/api/v1/posts/popular?limit=50&page={page}", BASE_URL),
// Search is non-paginated: ?page=N is ignored by the server for
// q=... requests, but we still emit a `page=1` for consistency.
Target::Latest => format!("{}/api/v1/posts?o={offset}", BASE_URL),
Target::Popular => format!("{}/api/v1/posts/popular?o={offset}", BASE_URL),
Target::Search { query } => {
let encoded = percent_encode(query);
format!(
"{}/api/v1/posts?q={encoded}&limit=50&page=1",
BASE_URL
)
format!("{}/api/v1/posts?q={encoded}&o={offset}", BASE_URL)
}
// Uploader URL is built separately.
Target::Uploader { .. } => String::new(),
@@ -259,14 +297,12 @@ impl CoomerProvider {
}
fn build_uploader_url(service: &Service, username: &str, page: u16) -> String {
let page = page.max(1).min(MAX_PAGE);
// The user-posts endpoint is also non-paginated, but we send page=1 for
// clarity. Service comes from the enum so the path is stable.
format!(
"{}/api/v1/{}/user/{}/posts?limit=50&page={page}",
"{}/api/v1/{}/user/{}/posts?o={}",
BASE_URL,
service.as_str(),
percent_encode(username),
Self::offset_for(page),
)
}
@@ -291,13 +327,16 @@ impl CoomerProvider {
)));
}
}
let posts: ApiPostsResponse = serde_json::from_str(&body).map_err(|err| {
let posts: ApiPostsPayload = serde_json::from_str(&body).map_err(|err| {
Error::from(format!(
"json parse failed for {url}: {err}; body[:200]={}",
&body.chars().take(200).collect::<String>()
))
})?;
Ok(posts.posts)
Ok(match posts {
ApiPostsPayload::List(posts) => posts,
ApiPostsPayload::Wrapped(wrapped) => wrapped.posts,
})
}
async fn fetch_items(
@@ -308,25 +347,21 @@ impl CoomerProvider {
per_page_limit: usize,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
let cache_key = match &target {
Target::Uploader { service, username } => {
format!("uploader::{}::{}::{}", service.as_str(), username, page)
}
_ => format!("main::{}::{}", target_label(&target), page),
};
if let Some((time, items)) = cache.get(&cache_key) {
if time.elapsed().unwrap_or_default().as_secs() < CACHE_TTL_SECS {
return Ok(items.clone());
}
}
let url = match &target {
Target::Uploader { service, username } => {
Self::build_uploader_url(service, username, page)
}
_ => Self::build_main_url(&target, page),
};
// Key on the full request URL: the search query must be part of it,
// otherwise every search shares one cached page.
let cache_key = format!("coomer::{url}");
if let Some((time, items)) = cache.get(&cache_key) {
if time.elapsed().unwrap_or_default().as_secs() < CACHE_TTL_SECS {
return Ok(items.clone());
}
}
let posts = self.fetch_json(options, "coomer.fetch_items", &url).await?;
eprintln!("[coomer] fetched {} posts from {}", posts.len(), url);
@@ -389,47 +424,46 @@ impl CoomerProvider {
});
let page_url = format!("{}/{}/user/{}/post/{}", BASE_URL, service, user, id);
// Coomer returns the file list in `attachments`; the main image/video
// lives in `file` for the first attachment. The `file.path` is the
// data-path (without the /data/ prefix); we have to prepend it.
let preview_file = post.file.as_ref();
let preview_url: Option<String> = preview_file
// Coomer returns the main file in `file` and the rest in `attachments`.
// Pick the first *video* across both: a video can sit in an attachment
// while `file` is a cover image. Image-only posts have nothing
// playable (and yt-dlp can't resolve the post page), so they are
// dropped from this video channel.
let is_video_ext =
|ext: &str| matches!(ext.to_ascii_lowercase().as_str(), "mp4" | "webm" | "mov" | "mkv" | "m4v");
let mut candidates: Vec<&ApiFile> = Vec::new();
if let Some(file) = post.file.as_ref() {
candidates.push(file);
}
if let Some(attachments) = post.attachments.as_ref() {
candidates.extend(attachments.iter());
}
let video_file = candidates.into_iter().find(|file| {
file.path.as_deref().is_some_and(|path| !path.is_empty())
&& file
.name
.as_deref()
.and_then(extension_from_name)
.or_else(|| file.path.as_deref().and_then(extension_from_name))
.is_some_and(|ext| is_video_ext(ext))
})?;
let preview_url: Option<String> = post
.file
.as_ref()
.and_then(|file| file.path.as_deref())
.filter(|path| !path.is_empty())
.map(|path| format!("{}/data/{}", BASE_URL, path.trim_start_matches('/')));
let mut format_url: Option<String> = None;
let mut ext_hint: Option<String> = None;
if let Some(file) = preview_file {
if let Some(name) = file.name.as_deref() {
if let Some(ext) = extension_from_name(name) {
ext_hint = Some(ext.to_string());
}
}
// The main file is the playable media. For images it's still
// served as the media URL; for videos it's the actual .mp4.
if let Some(path) = file.path.as_deref() {
format_url = Some(format!("{}/data/{}", BASE_URL, path.trim_start_matches('/')));
}
}
if format_url.is_none() {
if let Some(attach) = post.attachments.as_ref().and_then(|a| a.first()) {
if let Some(path) = attach.path.as_deref() {
format_url = Some(format!(
"{}/data/{}",
BASE_URL,
path.trim_start_matches('/')
));
}
if ext_hint.is_none() {
if let Some(name) = attach.name.as_deref() {
if let Some(ext) = extension_from_name(name) {
ext_hint = Some(ext.to_string());
}
}
}
}
}
let format_url: Option<String> = video_file
.path
.as_deref()
.map(|path| format!("{}/data/{}", BASE_URL, path.trim_start_matches('/')));
let ext_hint: Option<String> = video_file
.name
.as_deref()
.and_then(extension_from_name)
.or_else(|| video_file.path.as_deref().and_then(extension_from_name))
.map(|ext| ext.to_ascii_lowercase());
// Final fallback for the thumbnail: the preview file's data URL, or
// the format URL if the file itself is the only image.
@@ -461,7 +495,7 @@ impl CoomerProvider {
if let Some(ref url) = format_url {
let ext = ext_hint.unwrap_or_else(|| "mp4".to_string());
let is_video = matches!(ext.as_str(), "mp4" | "webm" | "mov" | "mkv");
let is_video = is_video_ext(&ext);
// An image-only post (very common - many creators post photos,
// not just videos) has nothing playable; emitting a "format"
// pointing at a static jpg/png just gives players a broken
@@ -495,15 +529,6 @@ impl CoomerProvider {
}
}
fn target_label(target: &Target) -> &'static str {
match target {
Target::Latest => "latest",
Target::Popular => "popular",
Target::Search { .. } => "search",
Target::Uploader { .. } => "uploader",
}
}
fn percent_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
@@ -558,6 +583,14 @@ struct ApiError {
error: String,
}
/// The global feed wraps posts in `{posts: [...]}`; a creator's feed is a bare array.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ApiPostsPayload {
List(Vec<ApiPost>),
Wrapped(ApiPostsResponse),
}
#[derive(Debug, Deserialize)]
struct ApiPostsResponse {
#[serde(default)]
@@ -616,8 +649,13 @@ impl Provider for CoomerProvider {
.clamp(1, 64);
let service_filter = options.categories.as_deref().or(options.sites.as_deref());
let target =
let mut target =
Self::resolve_target(&sort_value, query.as_deref(), service_filter);
if let Target::Search { query } = &target {
if let Some(found) = Self::resolve_keyword(query, service_filter, &options).await {
target = found;
}
}
// Bump the ignored `Sort` variant warning off by referencing it.
let _ = Sort::New;
@@ -660,11 +698,11 @@ mod tests {
fn builds_latest_url() {
assert_eq!(
CoomerProvider::build_main_url(&Target::Latest, 1),
"https://coomer.st/api/v1/posts?limit=50&page=1"
"https://coomer.st/api/v1/posts?o=0"
);
assert_eq!(
CoomerProvider::build_main_url(&Target::Latest, 3),
"https://coomer.st/api/v1/posts?limit=50&page=3"
"https://coomer.st/api/v1/posts?o=100"
);
}
@@ -672,7 +710,7 @@ mod tests {
fn builds_popular_url() {
assert_eq!(
CoomerProvider::build_main_url(&Target::Popular, 1),
"https://coomer.st/api/v1/posts/popular?limit=50&page=1"
"https://coomer.st/api/v1/posts/popular?o=0"
);
}
@@ -683,22 +721,24 @@ mod tests {
};
let url = CoomerProvider::build_main_url(&target, 1);
assert!(url.contains("q=milf%20%2B%20solo"));
assert!(url.contains("limit=50"));
assert!(url.ends_with("&o=0"));
let page_three = CoomerProvider::build_main_url(&target, 3);
assert!(page_three.ends_with("&o=100"));
}
#[test]
fn builds_uploader_url_per_service() {
assert_eq!(
CoomerProvider::build_uploader_url(&Service::Onlyfans, "tabycatxoxo", 1),
"https://coomer.st/api/v1/onlyfans/user/tabycatxoxo/posts?limit=50&page=1"
"https://coomer.st/api/v1/onlyfans/user/tabycatxoxo/posts?o=0"
);
assert_eq!(
CoomerProvider::build_uploader_url(&Service::Fansly, "ruby", 2),
"https://coomer.st/api/v1/fansly/user/ruby/posts?limit=50&page=2"
"https://coomer.st/api/v1/fansly/user/ruby/posts?o=50"
);
assert_eq!(
CoomerProvider::build_uploader_url(&Service::Candfans, "model", 1),
"https://coomer.st/api/v1/candfans/user/model/posts?limit=50&page=1"
"https://coomer.st/api/v1/candfans/user/model/posts?o=0"
);
}
@@ -835,11 +875,25 @@ mod tests {
"file": {"name": "p.jpg", "path": "/x/p.jpg"}
}"#;
let post: ApiPost = serde_json::from_str(json).expect("parse");
// Image-only posts have nothing playable and are dropped.
assert!(CoomerProvider::build_video_item(post).is_none());
}
#[test]
fn build_video_item_uses_video_attachment_when_file_is_image() {
let json = r#"{
"id": "43",
"user": "ruby",
"service": "fansly",
"title": "clip",
"file": {"name": "cover.jpg", "path": "/c/cover.jpg"},
"attachments": [{"name": "a.jpg", "path": "/a/a.jpg"}, {"name": "v.MP4", "path": "/v/v.mp4"}]
}"#;
let post: ApiPost = serde_json::from_str(json).expect("parse");
let item = CoomerProvider::build_video_item(post).expect("item");
assert_eq!(item.title, "Hello there friends");
assert_eq!(item.thumb, "https://coomer.st/data/x/p.jpg");
// For an image attachment, no formats are emitted (preview is enough).
assert!(item.formats.is_none());
let formats = item.formats.expect("video attachment must yield a format");
assert_eq!(formats[0].url, "https://coomer.st/data/v/v.mp4");
assert_eq!(item.thumb, "https://coomer.st/data/c/cover.jpg");
}
#[test]
@@ -848,4 +902,18 @@ mod tests {
let post: ApiPost = serde_json::from_str(json).expect("parse");
assert!(CoomerProvider::build_video_item(post).is_none());
}
#[test]
fn parses_bare_array_and_wrapped_post_payloads() {
let bare = r#"[{"id":"1","user":"u","service":"onlyfans","title":"t","file":{},"attachments":[]}]"#;
match serde_json::from_str::<ApiPostsPayload>(bare).expect("bare array") {
ApiPostsPayload::List(posts) => assert_eq!(posts.len(), 1),
other => panic!("expected list, got {other:?}"),
}
let wrapped = r#"{"posts":[{"id":"1"}],"count":1}"#;
match serde_json::from_str::<ApiPostsPayload>(wrapped).expect("wrapped") {
ApiPostsPayload::Wrapped(w) => assert_eq!(w.posts.len(), 1),
other => panic!("expected wrapped, got {other:?}"),
}
}
}

575
src/providers/cumlouder.rs Normal file
View File

@@ -0,0 +1,575 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::requester::Requester;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use scraper::{Html, Selector};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["amateur", "spanish", "hd", "free"],
};
const BASE_URL: &str = "https://www.cumlouder.com";
const CHANNEL_ID: &str = "cumlouder";
// Scraped from /categories/ (`a.muestra-categoria[tag-url]`). The site's own
// /search/ endpoint sits behind a Cloudflare managed challenge, so keyword
// queries are routed to these archives or to `/tag/{slug}/` instead.
const CATEGORIES: &[(&str, &str)] = &[
("anal", "Anal"),
("big-ass", "Big ass"),
("spain", "Spain"),
("latina", "Latina"),
("mature", "Mature"),
("teen", "Teen"),
("black", "Black"),
("squirting", "Squirting"),
("hardcore", "Hardcore"),
("big-dick", "Big dick"),
("natural-tits", "Natural tits"),
("cowgirl", "Cowgirl"),
("busty", "Busty"),
("ass", "Ass"),
("facial", "Facial"),
("reality", "Reality"),
("orgasm", "Orgasm"),
("casting", "Casting"),
("milf", "Milf"),
("tattoo", "Tattoo"),
("huge-boobs", "Huge boobs"),
("fake-tits", "Fake tits"),
("brunette", "Brunette"),
("public", "Public"),
("cumshot", "Cumshot"),
("deep-throat", "Deep throat"),
("big-tits", "Big tits"),
("party", "Party"),
("interracial", "Interracial"),
("parody", "Parody"),
("amateur", "Amateur"),
("pornstar", "Pornstar"),
("petite", "Petite"),
("rough-sex", "Rough sex"),
("blonde", "Blonde"),
("doggystyle", "Doggystyle"),
("schoolgirl", "Schoolgirl"),
("double-penetration", "Double penetration"),
("van", "Van"),
("creampie", "Creampie"),
("barcelona", "Barcelona"),
("exhibitionist", "Exhibitionist"),
("threesome", "Threesome"),
("blowjob", "Blowjob"),
("mulatta", "Mulatta"),
("money", "Money"),
("masturbation", "Masturbation"),
("tits", "Tits"),
("handjob", "Handjob"),
("comedy", "Comedy"),
("european", "European"),
("beach", "Beach"),
("redhead", "Redhead"),
("scandal", "Scandal"),
("orgy", "Orgy"),
("freak", "Freak"),
("pov", "POV"),
("lesbian", "Lesbian"),
("russian", "Russian"),
("ass-to-mouth", "Ass to mouth"),
("los-angeles", "Los Angeles"),
("fuck", "Fuck"),
("slapping", "Slapping"),
("nice-eyes", "Nice eyes"),
("asian", "Asian"),
("asslicking", "Asslicking"),
("mixer", "Mixer"),
("sport", "Sport"),
("busted", "Busted"),
("piercing", "Piercing"),
("gape", "Gape"),
("first-time", "First time"),
];
error_chain! {
foreign_links {
Io(std::io::Error);
Url(url::ParseError);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct CumlouderProvider;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Latest,
Category(String),
Tag(String),
Series(String),
/// Pornstar page `/girl/{slug}/`.
Girl(String),
/// Free-form keyword: a pornstar page if one exists, else a tag archive.
Keyword(String),
}
impl CumlouderProvider {
pub fn new() -> Self {
Self
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let mut category_options = vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}];
category_options.extend(CATEGORIES.iter().map(|(slug, title)| FilterOption {
id: (*slug).to_string(),
title: (*title).to_string(),
}));
Channel {
id: CHANNEL_ID.to_string(),
name: "Cumlouder".to_string(),
description: "Free amateur and Spanish-scene porn videos from Cumlouder, with category and tag archives.".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=cumlouder.com".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Browse Cumlouder by newest videos.".to_string(),
systemImage: "arrow.up.arrow.down".to_string(),
colorName: "blue".to_string(),
options: vec![FilterOption {
id: "new".to_string(),
title: "Latest".to_string(),
}],
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Browse a Cumlouder category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: category_options,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: None,
}
}
fn slugify(value: &str) -> String {
let mut out = String::new();
let mut last_dash = true;
for c in value.trim().chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn category_slug(value: &str) -> Option<&'static str> {
let slug = Self::slugify(value);
CATEGORIES.iter().find_map(|(s, title)| {
(*s == slug || Self::slugify(title) == slug).then_some(*s)
})
}
fn pick_target(query: Option<&str>, options: &ServerOptions) -> Target {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
let lower = q.to_ascii_lowercase();
for prefix in ["cat:", "category:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
let slug = Self::category_slug(rest).map(str::to_string);
return Target::Category(slug.unwrap_or_else(|| Self::slugify(rest)));
}
}
if let Some(rest) = lower.strip_prefix("tag:") {
return Target::Tag(Self::slugify(rest));
}
if let Some(rest) = lower.strip_prefix("series:") {
return Target::Series(Self::slugify(rest));
}
for prefix in ["girl:", "pornstar:", "star:", "model:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
return Target::Girl(Self::slugify(rest));
}
}
// The site search is behind a Cloudflare challenge: fall back to a
// category archive, then to a pornstar page, then to a tag archive.
if let Some(slug) = Self::category_slug(q) {
return Target::Category(slug.to_string());
}
return Target::Keyword(Self::slugify(q));
}
if let Some(cat) = options.categories.as_deref() {
if cat != "all" && !cat.is_empty() {
return Target::Category(Self::slugify(cat));
}
}
Target::Latest
}
fn build_listing_url(target: &Target, page: u32) -> String {
let suffix = if page > 1 {
format!("{page}/")
} else {
String::new()
};
match target {
// Page 1 is /porn/, page N is the bare /N/ path.
Target::Latest => {
if page > 1 {
format!("{BASE_URL}/{page}/")
} else {
format!("{BASE_URL}/porn/")
}
}
Target::Category(slug) => format!("{BASE_URL}/porn-videos/{slug}/{suffix}"),
Target::Tag(slug) => format!("{BASE_URL}/tag/{slug}/{suffix}"),
Target::Series(slug) => format!("{BASE_URL}/series/{slug}/{suffix}"),
Target::Girl(slug) | Target::Keyword(slug) => {
format!("{BASE_URL}/girl/{slug}/{suffix}")
}
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value).map_err(|e| Error::from(format!("selector `{value}` failed: {e}")))
}
fn absolute(url: &str) -> String {
if let Some(rest) = url.strip_prefix("//") {
format!("https://{rest}")
} else if url.starts_with('/') {
format!("{BASE_URL}{url}")
} else {
url.to_string()
}
}
fn parse_views(text: &str) -> Option<u32> {
let token = text.split_whitespace().next()?.replace(',', "");
let (num, mult) = match token.chars().last()? {
'K' | 'k' => (&token[..token.len() - 1], 1_000.0),
'M' | 'm' => (&token[..token.len() - 1], 1_000_000.0),
_ => (token.as_str(), 1.0),
};
num.parse::<f64>().ok().map(|n| (n * mult) as u32)
}
fn parse_listing_page(html: &str) -> Result<Vec<VideoItem>> {
let document = Html::parse_document(html);
let card_sel = Self::selector("a.muestra-escena[href]")?;
let img_sel = Self::selector("img")?;
let title_sel = Self::selector("h2")?;
let views_sel = Self::selector(".vistas")?;
let minutes_sel = Self::selector(".minutos")?;
let mut items = Vec::new();
let mut seen = std::collections::HashSet::new();
for card in document.select(&card_sel) {
let href = card.value().attr("href").unwrap_or_default();
let Some(slug) = href
.strip_prefix("/porn-video/")
.map(|s| s.trim_matches('/').to_string())
.filter(|s| !s.is_empty())
else {
continue;
};
if !seen.insert(slug.clone()) {
continue;
}
let img = card.select(&img_sel).next();
let thumb = img
.and_then(|i| i.value().attr("data-src"))
.map(Self::absolute)
.unwrap_or_default();
let title = card
.select(&title_sel)
.next()
.map(|h| h.text().collect::<String>().trim().to_string())
.filter(|t| !t.is_empty())
.or_else(|| {
img.and_then(|i| i.value().attr("alt"))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
})
.unwrap_or_else(|| slug.replace('-', " "));
let duration = card
.select(&minutes_sel)
.next()
.map(|m| m.text().collect::<String>())
.and_then(|t| {
let t = t.trim().trim_end_matches('m').trim().to_string();
parse_time_to_seconds(&t)
})
.and_then(|s| u32::try_from(s).ok())
.unwrap_or(0);
let views = card
.select(&views_sel)
.next()
.map(|v| v.text().collect::<String>())
.and_then(|t| Self::parse_views(t.trim()));
let url = format!("{BASE_URL}/porn-video/{slug}/");
let mut item = VideoItem::new(
slug,
title,
url,
CHANNEL_ID.to_string(),
thumb,
duration,
);
item.views = views;
item.aspectRatio = Some(16.0 / 9.0);
items.push(item);
}
Ok(items)
}
async fn fetch_listing(
target: &Target,
page: u32,
per_page: usize,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
let url = Self::build_listing_url(target, page);
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_listing");
let headers = vec![("Referer".to_string(), format!("{BASE_URL}/"))];
// The origin sporadically answers its first uncached request with a
// Cloudflare 522; a retry succeeds. Use the raw response so a real
// 404 (unknown tag/category) answers immediately instead of
// escalating through the requester's mirror/FlareSolverr fallbacks,
// which cost minutes.
let mut last_err = String::new();
let mut html = None;
for _ in 0..3 {
match requester
.get_raw_with_headers(&url, headers.clone())
.await
{
Ok(response) => {
let status = response.status().as_u16();
let mut body = response.text().await.unwrap_or_default();
if status == 404 {
return Ok(vec![]);
}
// Cloudflare challenge: hand over to the requester's full
// chain (FlareSolverr solve + identity replay).
if status == 403
|| status == 503
|| Requester::looks_like_cloudflare_challenge(&body)
{
match requester.get_with_headers(&url, headers.clone(), None).await {
Ok(solved) => {
body = solved;
if body.contains("muestra-escena") {
html = Some(body);
break;
}
}
Err(e) => {
last_err = e.to_string();
continue;
}
}
}
if body.contains("muestra-escena") {
html = Some(body);
break;
}
if status >= 500 || status == 429 {
last_err = format!("HTTP {status}");
continue;
}
// A page with no cards is a real empty archive.
last_err = format!("no cards in {} bytes", body.len());
html = Some(body);
break;
}
Err(e) => last_err = e.to_string(),
}
}
let html = html.ok_or_else(|| Error::from(format!("fetch failed for {url}: {last_err}")))?;
let mut items = Self::parse_listing_page(&html)?;
items.truncate(per_page.max(1));
Ok(items)
}
}
#[async_trait]
impl Provider for CumlouderProvider {
async fn get_videos(
&self,
_cache: VideoCache,
_pool: DbPool,
_sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u32>().unwrap_or(1).max(1);
let per_page = per_page.parse::<usize>().unwrap_or(30).clamp(1, 60);
let target = Self::pick_target(query.as_deref(), &options);
let result = match &target {
Target::Keyword(slug) => {
// A name matches a pornstar page (`/girl/{slug}/`); anything
// else is a tag. An unknown girl is a fast 404 -> empty.
match Self::fetch_listing(&target, page, per_page, &options).await {
Ok(items) if !items.is_empty() => Ok(items),
Ok(_) if page > 1 => Ok(vec![]),
Ok(_) | Err(_) => {
let tag = Target::Tag(slug.clone());
Self::fetch_listing(&tag, page, per_page, &options).await
}
}
}
_ => Self::fetch_listing(&target, page, per_page, &options).await,
};
match result {
Ok(items) => items,
Err(e) => {
report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> ServerOptions {
ServerOptions {
featured: None,
category: None,
sites: None,
filter: None,
language: None,
public_url_base: None,
requester: None,
network: None,
stars: None,
categories: None,
duration: None,
sort: None,
sexuality: None,
client_version: None,
}
}
#[test]
fn builds_urls() {
assert_eq!(
CumlouderProvider::build_listing_url(&Target::Latest, 1),
"https://www.cumlouder.com/porn/"
);
assert_eq!(
CumlouderProvider::build_listing_url(&Target::Latest, 3),
"https://www.cumlouder.com/3/"
);
assert_eq!(
CumlouderProvider::build_listing_url(&Target::Tag("deepthroat".into()), 2),
"https://www.cumlouder.com/tag/deepthroat/2/"
);
assert_eq!(
CumlouderProvider::build_listing_url(&Target::Girl("karyn".into()), 2),
"https://www.cumlouder.com/girl/karyn/2/"
);
assert_eq!(
CumlouderProvider::build_listing_url(&Target::Category("anal".into()), 1),
"https://www.cumlouder.com/porn-videos/anal/"
);
}
#[test]
fn routes_queries() {
let o = opts();
assert_eq!(
CumlouderProvider::pick_target(Some("Big Tits"), &o),
Target::Category("big-tits".into())
);
assert_eq!(
CumlouderProvider::pick_target(Some("Karyn"), &o),
Target::Keyword("karyn".into())
);
assert_eq!(
CumlouderProvider::pick_target(Some("girl:Taylor Sands"), &o),
Target::Girl("taylor-sands".into())
);
assert_eq!(
CumlouderProvider::pick_target(Some("series:Nacho Vidal"), &o),
Target::Series("nacho-vidal".into())
);
assert_eq!(CumlouderProvider::pick_target(None, &o), Target::Latest);
}
#[test]
fn parses_cards() {
let html = r#"<a class="muestra-escena" href="/porn-video/some-scene/">
<img class="thumb" src="data:image/gif;base64,AA" data-src="//th0cdn.cumlouder.com/abc/thumbnail_001.jpg" alt="Some scene">
<h2> <span class="ico-h2 sprite"></span> Some scene</h2>
<span class="box-fecha-mins">
<span class="vistas"> <span class="ico-vistas sprite"></span> 1,154 views</span>
<span class="fecha"> <span class="ico-fecha sprite"></span> 2 years ago</span>
</span>
<span class="minutos"> <span class="ico-minutos sprite"></span> 06:14 m</span>
</a>
<a class="muestra-escena" href="/porn-videos/anal/"><h2>Category</h2></a>"#;
let items = CumlouderProvider::parse_listing_page(html).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "some-scene");
assert_eq!(items[0].title, "Some scene");
assert_eq!(items[0].duration, 374);
assert_eq!(items[0].views, Some(1154));
assert_eq!(
items[0].thumb,
"https://th0cdn.cumlouder.com/abc/thumbnail_001.jpg"
);
assert_eq!(
items[0].url,
"https://www.cumlouder.com/porn-video/some-scene/"
);
}
}

View File

@@ -440,15 +440,26 @@ impl EpornerProvider {
Ok(cards)
}
async fn build_item(requester: &mut Requester, card: EpornerCard) -> VideoItem {
async fn build_item(
requester: &mut Requester,
options: &ServerOptions,
card: EpornerCard,
) -> VideoItem {
// The hash/xhr resolution + Referer handling live in
// `crate::util::eporner_resolve` (shared with other providers that
// redirect out to eporner.com, e.g. `melonstube`) rather than here,
// since single-provider builds can't depend on another provider's
// module — see that module's doc comment.
let formats =
crate::util::eporner_resolve::resolve_formats(requester, &card.alnum_id, &card.page_url)
.await;
// Formats point at the eporner streaming proxy: the CDN links expire
// after two hours and only serve the server's IP; anything else plays
// a one-minute "watch on eporner.com" clip.
let formats = crate::util::eporner_resolve::resolve_proxied_formats(
requester,
options,
&card.alnum_id,
&card.page_url,
)
.await;
let mut item = VideoItem::new(
card.id,
@@ -648,7 +659,8 @@ impl EpornerProvider {
let cards = Self::parse_list_cards(&html)?;
let items = stream::iter(cards.into_iter().take(per_page.max(1)).map(|card| {
let mut req = requester.clone();
async move { Self::build_item(&mut req, card).await }
let options = &options;
async move { Self::build_item(&mut req, options, card).await }
}))
.buffer_unordered(ENRICH_CONCURRENCY)
.collect::<Vec<_>>()

View File

@@ -1,6 +1,6 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{Provider, probe_first_archive, report_provider_error, requester_or_default};
use crate::status::*;
use crate::util::parse_abbreviated_number;
use crate::videos::{ServerOptions, VideoItem};
@@ -164,6 +164,26 @@ impl EromeProvider {
(!slug.is_empty()).then_some(slug)
}
/// A single-word query that is an existing uploader name (`/{name}` answers
/// 404 otherwise) goes to that uploader's albums, else keyword search.
async fn resolve_keyword(&self, query: &str, options: &ServerOptions) -> Option<Target> {
let slug = query.trim().trim_start_matches('@');
if slug.is_empty()
|| !slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return None;
}
let url = format!("{}/{slug}", self.url);
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
probe_first_archive(&mut requester, &[url], &format!("{}/", self.url), |body| {
body.contains("album-link")
})
.await
.map(|_| Target::UploaderPosts { slug: slug.to_string() })
}
fn resolve_target(&self, query: &str, options: &ServerOptions, sort: &str) -> Target {
if let Some(site) = options.sites.as_deref() {
let normalized = site.trim();
@@ -367,7 +387,12 @@ impl Provider for EromeProvider {
};
let query_value = query.unwrap_or_default();
let page_value = page.parse::<u16>().unwrap_or(1);
let target = self.resolve_target(&query_value, &options, &sort_value);
let mut target = self.resolve_target(&query_value, &options, &sort_value);
if let Target::Search { query, .. } = &target {
if let Some(found) = self.resolve_keyword(query, &options).await {
target = found;
}
}
let url = self.build_url_for_target(&target, page_value);
match self.fetch_page(&options, &url).await {

View File

@@ -639,6 +639,24 @@ impl Provider for FikfapProvider {
.map(str::trim)
.filter(|value| !value.is_empty());
let target = Self::pick_target(normalized_query, &sort, &options);
// A single-word query that is an existing profile name goes to that
// creator's posts (checked against the profile endpoint); a profile
// without posts falls back to the keyword search.
if let Target::Search(query) = &target {
if !query.contains(char::is_whitespace) {
if let Ok(Some(profile)) = self.fetch_user_profile(query, &options).await {
let user = Target::User(profile.username);
if let Ok(items) = self
.fetch_target_items(user, page, per_page, &options)
.await
{
if !items.is_empty() {
return items;
}
}
}
}
}
match self.fetch_target_items(target, page, per_page, &options).await {
Ok(items) => items,

View File

@@ -1,6 +1,8 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{
Provider, probe_first_archive, report_provider_error, requester_or_default, slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::time::parse_time_to_seconds;
@@ -134,13 +136,13 @@ impl FullpornerProvider {
}
if let Some(slug) = q.strip_prefix("pornstar:") {
return Target::Pornstar {
slug: slug.trim().to_string(),
slug: slug_dashed(slug),
page,
};
}
if let Some(slug) = q.strip_prefix("star:") {
return Target::Pornstar {
slug: slug.trim().to_string(),
slug: slug_dashed(slug),
page,
};
}
@@ -153,6 +155,25 @@ impl FullpornerProvider {
Target::Latest { page }
}
/// A free-form query that names a pornstar goes to her archive (probed on
/// the site; unknown names 404), otherwise it stays a keyword search.
async fn resolve_keyword(query: &str, page: u32, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let probe = Target::Pornstar { slug: slug.clone(), page: 1 };
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
probe_first_archive(
&mut requester,
&[Self::build_listing_url(&probe)],
&format!("{BASE_URL}/"),
|body| body.contains("class=\"video-card\""),
)
.await
.map(|_| Target::Pornstar { slug, page })
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value)
.map_err(|e| Error::from(format!("selector `{value}` failed: {e}")))
@@ -394,7 +415,10 @@ impl Provider for FullpornerProvider {
.filter(|q| !q.is_empty())
.map(ToOwned::to_owned);
let target = Self::pick_target(normalized_query.as_deref(), page);
let mut target = Self::pick_target(normalized_query.as_deref(), page);
if let Target::Search { query, .. } = &target {
target = Self::resolve_keyword(query, page, &options).await.unwrap_or(target);
}
match Self::fetch_page(&target, per_page, &options).await {
Ok(items) => items,

View File

@@ -1,6 +1,6 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
use crate::providers::{probe_first_archive, slug_dashed,
Provider, report_provider_error, report_provider_error_background, requester_or_default,
};
use crate::status::*;
@@ -11,6 +11,8 @@ use futures::stream::{self, StreamExt};
use regex::Regex;
use scraper::{Html, Selector};
const DELETED_EMBED: &str = "hoster file deleted";
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
@@ -107,12 +109,12 @@ impl FullpornerorgProvider {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
if let Some(slug) = q.strip_prefix("pornstar:") {
return Target::Pornstar {
slug: slug.trim().to_string(),
slug: slug_dashed(slug),
};
}
if let Some(slug) = q.strip_prefix("star:") {
return Target::Pornstar {
slug: slug.trim().to_string(),
slug: slug_dashed(slug),
};
}
return Target::Search {
@@ -151,6 +153,25 @@ impl FullpornerorgProvider {
}
}
/// A free-form query that names a pornstar goes to her archive (probed on
/// the site; unknown names 404), otherwise it stays a keyword search.
async fn resolve_keyword(query: &str, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let target = Target::Pornstar { slug };
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
probe_first_archive(
&mut requester,
&[Self::build_listing_url(&target, 1)],
&format!("{BASE_URL}/"),
|body| body.contains("loop-video"),
)
.await
.map(|_| target)
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![
("User-Agent".to_string(), BROWSER_UA.to_string()),
@@ -311,8 +332,28 @@ impl FullpornerorgProvider {
None
}
/// Builds the lulustream proxy URL for a given embed ID.
fn build_proxy_url(embed_id: &str, options: &ServerOptions) -> String {
/// The hoster serves a tiny stub page for removed files. Listings on
/// fullporner.org keep pointing at them, so detect it and skip the item.
fn embed_is_deleted(html: &str) -> bool {
let lower = html.to_ascii_lowercase();
lower.contains("no longer available")
|| lower.contains("has been deleted")
// DoodStream-protocol hosts (myvidplay.com) title the stub this way.
|| lower.contains("<title>video not found")
}
/// Builds the proxy URL for an embed. The ID alone is only unique per
/// hoster: `myvidplay.com` is a DoodStream-protocol host, so those embeds
/// must keep their host and go through the doodstream proxy; resolving the
/// same ID on lulustream finds nothing (or a different, deleted file).
fn build_proxy_url(embed_url: &str, embed_id: &str, options: &ServerOptions) -> String {
if embed_url.contains("myvidplay.com/") {
return crate::providers::build_proxy_url(
options,
"doodstream",
&format!("myvidplay.com/e/{embed_id}"),
);
}
crate::providers::build_proxy_url(options, "lulustream", embed_id)
}
@@ -324,9 +365,17 @@ impl FullpornerorgProvider {
let embed_id = Self::extract_embed_id(&embed_url)
.ok_or_else(|| Error::from(format!("could not parse embed id from {embed_url}")))?;
// Skip files the hoster has already removed (fail open if the probe
// itself is blocked or times out).
if let Ok(embed_html) = Self::fetch_html(options, &embed_url, BASE_URL).await {
if Self::embed_is_deleted(&embed_html) {
return Err(Error::from(DELETED_EMBED));
}
}
// Step 2: build the proxy URL (will hit /proxy/lulustream/{embed_id} which
// resolves the actual HLS URL via the lulustream proxy).
let proxy_url = Self::build_proxy_url(&embed_id, options);
let proxy_url = Self::build_proxy_url(&embed_url, &embed_id, options);
// Build the format. The proxy will return a redirect to the actual HLS URL.
let mut format = VideoFormat::m3u8(proxy_url, "auto".to_string(), "m3u8".to_string());
@@ -371,11 +420,13 @@ impl FullpornerorgProvider {
match Self::enrich_card(stub, &options).await {
Ok(item) => Some(item),
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"fetch_page.enrich_card",
&error.to_string(),
);
if error.to_string() != DELETED_EMBED {
report_provider_error_background(
CHANNEL_ID,
"fetch_page.enrich_card",
&error.to_string(),
);
}
None
}
}
@@ -414,7 +465,10 @@ impl Provider for FullpornerorgProvider {
.filter(|q| !q.is_empty())
.map(ToOwned::to_owned);
let target = Self::pick_target(normalized_query.as_deref());
let mut target = Self::pick_target(normalized_query.as_deref());
if let Target::Search { query } = &target {
target = Self::resolve_keyword(query, &options).await.unwrap_or(target);
}
match Self::fetch_page(target, page, per_page, &options).await {
Ok(items) => items,
@@ -482,6 +536,36 @@ mod tests {
);
}
#[test]
fn myvidplay_embeds_route_to_doodstream_with_host() {
let options = ServerOptions {
client_version: None, featured: None, category: None, sites: None, filter: None,
language: None, public_url_base: None, requester: None, network: None, stars: None,
categories: None, duration: None, sort: None, sexuality: None,
};
assert_eq!(
FullpornerorgProvider::build_proxy_url("https://myvidplay.com/e/abc123", "abc123", &options),
"/proxy/doodstream/myvidplay.com/e/abc123"
);
assert_eq!(
FullpornerorgProvider::build_proxy_url("https://luluvdo.com/e/abc123", "abc123", &options),
"/proxy/lulustream/abc123"
);
}
#[test]
fn detects_deleted_embed_stub() {
assert!(FullpornerorgProvider::embed_is_deleted(
"<body>File is no longer available as it expired or has been deleted.</body>"
));
assert!(FullpornerorgProvider::embed_is_deleted(
"<html><head><title>Video not found | DoodStream</title></head></html>"
));
assert!(!FullpornerorgProvider::embed_is_deleted(
"<html><title>Payton Preslee - Lulustream.mp4</title><script>eval(function(p,a,c,k,e,d){}</script></html>"
));
}
#[test]
fn picks_search_for_query() {
match FullpornerorgProvider::pick_target(Some("test")) {

View File

@@ -1,13 +1,8 @@
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use async_trait::async_trait;
use base64::Engine;
use error_chain::error_chain;
use futures::future::join_all;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::SystemTime;
use std::vec;
use crate::DbPool;
@@ -16,6 +11,7 @@ use crate::db;
use crate::providers::{Provider, report_provider_error, report_provider_error_background};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::hanime_resolve::{self as resolve, HanimeSource, SITE_ORIGIN};
use crate::videos::{self, ServerOptions, VideoItem};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
@@ -34,14 +30,6 @@ error_chain! {
/// Full catalogue dump. Ignores every query parameter, so filtering, sorting and
/// pagination all happen client side. ~860 KB compressed, CDN cached for an hour.
const INDEX_URL: &str = "https://guest.freeanimehentai.net/api/v11/search_hvs";
const HANDSHAKE_URL: &str = "https://auth.hanime.tv/api/v11/handshake";
const SITE_ORIGIN: &str = "https://hanime.tv";
/// Envelope encryption used for the handshake request/response bodies.
const HANDSHAKE_KEY_SEED: &[u8] = b"htv-insecure-handshake-v1";
const HANDSHAKE_AAD: &[u8] = b"htv-insecure-v1";
/// Pieces of the `x-signature` pre-image, as baked into the site's wasm module.
const SIGNATURE_SECRET: &str = "Xkdi29";
const SIGNATURE_SALT: &str = "mn2";
const INDEX_TTL_SECS: u64 = 3600;
type IndexCache = OnceLock<Mutex<Option<(SystemTime, Arc<Vec<HanimeSearchResult>>)>>>;
@@ -76,99 +64,6 @@ struct HanimeSearchResult {
released_at_unix: u64,
}
#[derive(serde::Deserialize, Debug)]
struct HanimeHandshakePayload {
#[serde(default)]
sources: Vec<HanimeSource>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct HanimeSource {
#[serde(default)]
src: String,
#[serde(default)]
height: u32,
#[serde(default)]
label: String,
/// `"normal"` for the free streams, `"promotion"` for the premium-only teaser
/// entries which always carry an empty `src`.
#[serde(default)]
kind: String,
}
fn b64() -> base64::engine::general_purpose::GeneralPurpose {
base64::engine::general_purpose::URL_SAFE_NO_PAD
}
fn b64_decode(value: &str) -> Result<Vec<u8>> {
b64()
.decode(value.trim_end_matches('='))
.map_err(|e| Error::from(format!("base64url decode failed: {e}")))
}
fn handshake_cipher() -> Aes256Gcm {
let digest = Sha256::digest(HANDSHAKE_KEY_SEED);
let key = Key::<Aes256Gcm>::from_slice(digest.as_slice());
Aes256Gcm::new(key)
}
/// `{"v":1,"alg":"AES-256-GCM","iv":..,"tag":..,"data":..}`, base64url encoded.
fn seal_envelope(plaintext: &[u8]) -> Result<String> {
let mut iv = [0u8; 12];
rand::fill(&mut iv);
let sealed = handshake_cipher()
.encrypt(
Nonce::from_slice(&iv),
Payload {
msg: plaintext,
aad: HANDSHAKE_AAD,
},
)
.map_err(|e| Error::from(format!("handshake encrypt failed: {e}")))?;
if sealed.len() < 16 {
return Err(Error::from("handshake ciphertext too short"));
}
let (data, tag) = sealed.split_at(sealed.len() - 16);
let envelope = json!({
"v": 1,
"alg": "AES-256-GCM",
"iv": b64().encode(iv),
"tag": b64().encode(tag),
"data": b64().encode(data),
});
Ok(b64().encode(envelope.to_string().as_bytes()))
}
fn open_envelope(token: &str) -> Result<Vec<u8>> {
let envelope: serde_json::Value = serde_json::from_slice(&b64_decode(token)?)
.map_err(|e| Error::from(format!("handshake envelope is not JSON: {e}")))?;
let field = |name: &str| -> Result<Vec<u8>> {
let raw = envelope
.get(name)
.and_then(|v| v.as_str())
.ok_or_else(|| Error::from(format!("handshake envelope missing {name}")))?;
b64_decode(raw)
};
let iv = field("iv")?;
let mut ciphertext = field("data")?;
ciphertext.extend_from_slice(&field("tag")?);
handshake_cipher()
.decrypt(
Nonce::from_slice(&iv),
Payload {
msg: &ciphertext,
aad: HANDSHAKE_AAD,
},
)
.map_err(|e| Error::from(format!("handshake decrypt failed: {e}")))
}
fn signature_for(timestamp: u64) -> String {
let pre_image =
format!("{timestamp},{SIGNATURE_SECRET},{SITE_ORIGIN},{SIGNATURE_SALT},{timestamp}");
hex::encode(Sha256::digest(pre_image.as_bytes()))
}
#[derive(Debug, Clone)]
pub struct HanimeProvider;
@@ -242,6 +137,25 @@ impl HanimeProvider {
}
}
/// Fresh free sources for a slug, best quality first.
async fn fetch_sources(&self, slug: &str, options: &ServerOptions) -> Result<Vec<HanimeSource>> {
let mut requester =
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
resolve::fetch_sources(&mut requester, slug)
.await
.map_err(Error::from)
}
/// The stream URLs expire, so players get a stable redirect that does a fresh
/// handshake on every play instead of the signed link itself.
fn proxied_url(options: &ServerOptions, slug: &str, source: &HanimeSource) -> String {
crate::providers::build_proxy_url(
options,
"hanime",
&format!("{slug}/{}.m3u8", source.height),
)
}
fn db_key(slug: &str) -> String {
format!("https://hanime.tv/videos/hentai/{slug}")
}
@@ -319,86 +233,6 @@ impl HanimeProvider {
Ok(items)
}
/// Returns the free `sources` for a slug, best quality first. The auth host
/// occasionally refuses one of a burst of parallel connections, so retry once.
async fn fetch_sources(&self, slug: &str, options: &ServerOptions) -> Result<Vec<HanimeSource>> {
match self.handshake(slug, options).await {
Ok(sources) => Ok(sources),
Err(_) => {
ntex::time::sleep(ntex::time::Millis(500)).await;
self.handshake(slug, options).await
}
}
}
async fn handshake(&self, slug: &str, options: &ServerOptions) -> Result<Vec<HanimeSource>> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let token = seal_envelope(
json!({
"timestamp_unix": timestamp,
"directive": "htv_player_handshake",
"slug": slug,
})
.to_string()
.as_bytes(),
)?;
let mut requester =
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
let response = requester
.post_json(
HANDSHAKE_URL,
&json!({ "token": token }),
vec![
("Origin".to_string(), SITE_ORIGIN.to_string()),
("Referer".to_string(), format!("{SITE_ORIGIN}/")),
("Accept".to_string(), "application/json".to_string()),
("x-signature-version".to_string(), "web2".to_string()),
("x-signature".to_string(), signature_for(timestamp)),
("x-time".to_string(), timestamp.to_string()),
],
)
.await
.map_err(|e| Error::from(format!("handshake request failed: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(Error::from(format!("handshake returned HTTP {status}")));
}
// The interesting part of the answer travels in a response header, not the body.
let encrypted = response
.headers()
.get("x-token")
.and_then(|v| v.to_str().ok())
.map(|v| v.to_string())
.ok_or_else(|| Error::from("handshake response is missing x-token"))?;
let payload: HanimeHandshakePayload = serde_json::from_slice(&open_envelope(&encrypted)?)
.map_err(|e| Error::from(format!("Failed to parse handshake payload: {e}")))?;
let mut sources: Vec<HanimeSource> = payload
.sources
.into_iter()
.filter(|s| s.kind == "normal" && !s.src.is_empty())
.collect();
sources.sort_by(|a, b| b.height.cmp(&a.height));
match sources.is_empty() {
true => Err(Error::from("handshake returned no playable sources")),
false => Ok(sources),
}
}
fn source_url(source: &HanimeSource) -> String {
match source.src.starts_with("http") {
true => source.src.clone(),
false => format!("{SITE_ORIGIN}{}", source.src),
}
}
fn quality_label(source: &HanimeSource) -> String {
match source.label.trim_end_matches('p') {
"" => source.height.to_string(),
@@ -452,15 +286,15 @@ impl HanimeProvider {
.iter()
.map(|source| {
videos::VideoFormat::new(
Self::source_url(source),
Self::proxied_url(&options, &hit.slug, source),
Self::quality_label(source),
"m3u8".to_string(),
)
})
.collect();
let stream_url = Self::source_url(&sources[0]);
let stream_url = Self::proxied_url(&options, &hit.slug, &sources[0]);
let duration = self
.fetch_duration(&stream_url, &options)
.fetch_duration(&resolve::source_url(&sources[0]), &options)
.await
.unwrap_or_default();
if let Ok(mut conn) = pool.get() {
@@ -490,11 +324,8 @@ impl HanimeProvider {
.and_then(|mut conn| db::get_video(&mut conn, db_key.clone()).ok().flatten());
match db_result {
Some(video_url) if video_url.contains("m3u8") || video_url.contains("/hls/") => {
let duration = self
.fetch_duration(&video_url, &options)
.await
.unwrap_or_default();
Some(video_url) if video_url.contains("/proxy/hanime/") => {
let duration = 0;
let formats = vec![videos::VideoFormat::new(
video_url.clone(),
"720".to_string(),

606
src/providers/hooporn.rs Normal file
View File

@@ -0,0 +1,606 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::requester::Requester;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use scraper::{Html, Selector};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "studio-network",
tags: &["studio", "hd", "pornstars", "free"],
};
const BASE_URL: &str = "https://hooporn.com";
const CHANNEL_ID: &str = "hooporn";
const ENRICH_CONCURRENCY: usize = 8;
// Scraped from /categories (`div.channels-card a[href^="/category/"]`).
const CATEGORIES: &[(&str, &str)] = &[
("anal", "anal"),
("ass-licking", "ass licking"),
("atm", "ass to mouth"),
("babe", "babe"),
("bbw", "BBW"),
("big-ass", "big ass"),
("big-tits", "big tits"),
("black", "black"),
("blonde", "blonde"),
("blowjob", "blowjob"),
("bondage", "bondage"),
("brunette", "brunette"),
("busty", "busty"),
("casting", "casting"),
("college", "college"),
("compilation", "compilation"),
("creampie", "creampie"),
("cuckold", "cuckold"),
("czech", "czech"),
("deepthroat", "deepthroat"),
("doggystyle", "doggystyle"),
("dp", "double penetration"),
("ebony", "ebony"),
("fantasy", "fantasy"),
("fetish", "fetish"),
("fingering", "fingering"),
("footjob", "footjob"),
("foursome", "foursome"),
("gangbang", "gangbang"),
("gangbang-creampie", "gangbang creampie"),
("gaping", "gaping"),
("german", "german"),
("gloryhole", "gloryhole"),
("hairy", "hairy"),
("handjob", "handjob"),
("hardcore", "hardcore"),
("homemade", "homemade"),
("interracial", "interracial"),
("latina", "latina"),
("lesbian", "lesbian"),
("lingerie", "lingerie"),
("massage", "massage"),
("masturbation", "masturbation"),
("mature", "mature"),
("milf", "MILF"),
("orgy", "orgy"),
("outdoor", "outdoor"),
("petite", "petite"),
("pov", "POV"),
("public", "public"),
("pussy-licking", "pussy licking"),
("red-head", "red head"),
("riding", "riding"),
("russian", "russian"),
("skinny", "skinny"),
("small-tits", "small tits"),
("solo", "solo"),
("squirt", "squirt"),
("swallow", "swallow"),
("teen", "teen"),
("threesome", "threesome"),
("toys", "toys"),
("wife", "wife"),
];
error_chain! {
foreign_links {
Io(std::io::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct HoopornProvider;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Latest,
Category(String),
Pornstar(String),
Studio(String),
Search(String),
/// Free-form query: a pornstar page or a studio page if one exists by that
/// name (probed on the site), else a keyword search.
Keyword(String),
}
/// Detail-page data: the player iframe plus taxonomy links.
#[derive(Debug, Default, PartialEq, Eq)]
struct Detail {
iframe: Option<String>,
tags: Vec<String>,
studio: Option<(String, String)>,
}
impl HoopornProvider {
pub fn new() -> Self {
Self
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let mut category_options = vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}];
category_options.extend(CATEGORIES.iter().map(|(slug, title)| FilterOption {
id: (*slug).to_string(),
title: Self::title_case(title),
}));
Channel {
id: CHANNEL_ID.to_string(),
name: "HooPorn".to_string(),
description: "Free HD studio videos from HooPorn with category, pornstar and studio archives.".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hooporn.com".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Browse HooPorn by newest videos.".to_string(),
systemImage: "arrow.up.arrow.down".to_string(),
colorName: "blue".to_string(),
options: vec![FilterOption {
id: "new".to_string(),
title: "Latest".to_string(),
}],
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Browse a HooPorn category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: category_options,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: None,
}
}
fn title_case(value: &str) -> String {
if value.chars().any(|c| c.is_uppercase()) {
return value.to_string();
}
value
.split(' ')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn slugify(value: &str) -> String {
let mut out = String::new();
let mut last_dash = true;
for c in value.trim().chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn category_slug(value: &str) -> Option<&'static str> {
let slug = Self::slugify(value);
if slug.is_empty() {
return None;
}
CATEGORIES
.iter()
.find_map(|(s, title)| (*s == slug || Self::slugify(title) == slug).then_some(*s))
}
fn pick_target(query: Option<&str>, options: &ServerOptions) -> Target {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
let lower = q.to_ascii_lowercase();
for prefix in ["cat:", "category:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
let slug = Self::category_slug(rest)
.map(str::to_string)
.unwrap_or_else(|| Self::slugify(rest));
return Target::Category(slug);
}
}
for prefix in ["pornstar:", "star:", "model:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
return Target::Pornstar(Self::slugify(rest));
}
}
for prefix in ["studio:", "site:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
return Target::Studio(Self::slugify(rest).replace('-', ""));
}
}
if let Some(slug) = Self::category_slug(q) {
return Target::Category(slug.to_string());
}
return Target::Keyword(q.to_string());
}
if let Some(cat) = options.categories.as_deref() {
let cat = cat.trim();
if !cat.is_empty() && !cat.eq_ignore_ascii_case("all") {
let slug = Self::category_slug(cat)
.map(str::to_string)
.unwrap_or_else(|| Self::slugify(cat));
return Target::Category(slug);
}
}
Target::Latest
}
fn build_listing_url(target: &Target, page: u32) -> String {
match target {
Target::Latest => format!("{BASE_URL}/home/{page}"),
Target::Category(slug) => format!("{BASE_URL}/category/{slug}/{page}"),
Target::Pornstar(slug) => format!("{BASE_URL}/pornstar/{slug}/{page}"),
Target::Studio(slug) => format!("{BASE_URL}/studio/{slug}/{page}"),
Target::Search(query) | Target::Keyword(query) => format!(
"{BASE_URL}/search?q={}&p={page}",
utf8_percent_encode(query, NON_ALPHANUMERIC)
),
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value).map_err(|e| Error::from(format!("selector `{value}` failed: {e}")))
}
fn absolute(url: &str) -> String {
if let Some(rest) = url.strip_prefix("//") {
format!("https://{rest}")
} else if url.starts_with('/') {
format!("{BASE_URL}{url}")
} else {
url.to_string()
}
}
fn parse_listing(html: &str) -> Result<Vec<VideoItem>> {
let document = Html::parse_document(html);
let card_sel = Self::selector("div.video-card")?;
let link_sel = Self::selector("div.video-title a[href^=\"/watch/\"]")?;
let img_sel = Self::selector("img")?;
let time_sel = Self::selector(".time")?;
let created_sel = Self::selector("span.create")?;
let mut items = Vec::new();
let mut seen = std::collections::HashSet::new();
for card in document.select(&card_sel) {
let Some(link) = card.select(&link_sel).next() else {
continue;
};
let href = link.value().attr("href").unwrap_or_default();
let id = href.trim_start_matches("/watch/").trim_matches('/').to_string();
if id.is_empty() || !seen.insert(id.clone()) {
continue;
}
let title = link.text().collect::<String>().trim().to_string();
if title.is_empty() {
continue;
}
let thumb = card
.select(&img_sel)
.next()
.and_then(|i| i.value().attr("data-src"))
.map(Self::absolute)
.unwrap_or_default();
let duration = card
.select(&time_sel)
.next()
.map(|t| t.text().collect::<String>())
.and_then(|t| parse_time_to_seconds(t.trim()))
.and_then(|s| u32::try_from(s).ok())
.unwrap_or(0);
let mut item = VideoItem::new(
id.clone(),
title,
format!("{BASE_URL}/watch/{id}"),
CHANNEL_ID.to_string(),
thumb,
duration,
);
item.uploadedAt = card
.select(&created_sel)
.next()
.and_then(|c| c.text().collect::<String>().trim().parse::<u64>().ok());
item.aspectRatio = Some(16.0 / 9.0);
items.push(item);
}
Ok(items)
}
fn parse_detail(html: &str) -> Result<Detail> {
let document = Html::parse_document(html);
let iframe_sel = Self::selector("iframe[src*=\"/video/\"]")?;
let tag_sel = Self::selector("p.tag-link a[href^=\"/category/\"]")?;
let star_sel = Self::selector("a[href^=\"/pornstar/\"]")?;
let studio_sel = Self::selector("a[href^=\"/studio/\"]")?;
let mut detail = Detail::default();
detail.iframe = document
.select(&iframe_sel)
.next()
.and_then(|f| f.value().attr("src"))
.map(Self::absolute);
let mut push = |tag: String| {
let tag = tag.trim().trim_start_matches('#').trim().to_string();
if !tag.is_empty() && !detail.tags.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
detail.tags.push(tag);
}
};
for a in document.select(&tag_sel) {
push(a.text().collect::<String>());
}
for a in document.select(&star_sel) {
push(a.text().collect::<String>());
}
if let Some(a) = document.select(&studio_sel).next() {
let name = a.text().collect::<String>().trim().to_string();
let slug = a
.value()
.attr("href")
.unwrap_or_default()
.trim_start_matches("/studio/")
.trim_matches('/')
.to_string();
if !name.is_empty() && !slug.is_empty() {
detail.studio = Some((name, slug));
}
}
Ok(detail)
}
/// The player frame carries `var video = "https://videos…mp4?md5=…&expires=…"`.
fn parse_player_video(html: &str) -> Option<String> {
let marker = "var video = \"";
let start = html.find(marker)? + marker.len();
let end = html[start..].find('"')? + start;
let url = html[start..end].replace("&amp;", "&");
url.starts_with("http").then_some(url)
}
async fn enrich(items: Vec<VideoItem>, requester: Requester) -> Vec<VideoItem> {
let referer = format!("{BASE_URL}/");
stream::iter(items.into_iter().map(|mut item| {
let mut requester = requester.clone();
let referer = referer.clone();
async move {
let headers = vec![("Referer".to_string(), referer.clone())];
let page = requester
.get_with_headers(&item.url, headers.clone(), None)
.await
.ok()?;
let detail = Self::parse_detail(&page).ok()?;
let frame = detail.iframe?;
let player = requester
.get_with_headers(&frame, headers, None)
.await
.ok()?;
let video_url = Self::parse_player_video(&player)?;
item.formats = Some(vec![
VideoFormat::new(video_url, "auto".to_string(), "mp4".to_string())
.format_id("mp4".to_string()),
]);
if !detail.tags.is_empty() {
item.tags = Some(detail.tags);
}
if let Some((name, slug)) = detail.studio {
item.uploaderUrl = Some(format!("{BASE_URL}/studio/{slug}"));
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
item.uploader = Some(name);
}
Some(item)
}
}))
.buffered(ENRICH_CONCURRENCY)
.filter_map(|item| async move { item })
.collect::<Vec<_>>()
.await
}
/// Fetches a listing page; `None` for a real 404 (see `Requester::get_probe`).
async fn fetch_html(requester: &mut Requester, url: &str) -> Result<Option<String>> {
let headers = vec![("Referer".to_string(), format!("{BASE_URL}/"))];
requester
.get_probe(url, headers)
.await
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))
}
async fn fetch(target: &Target, page: u32, options: &ServerOptions) -> Result<Vec<VideoItem>> {
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch");
let html = match target {
Target::Keyword(query) => {
// Probe page 1 of the pornstar and studio archive for this
// name; the first that exists wins, otherwise keyword search.
let candidates = [
Target::Pornstar(Self::slugify(query)),
Target::Studio(Self::slugify(query).replace('-', "")),
];
let mut found = None;
for candidate in candidates {
let slug_ok = match &candidate {
Target::Pornstar(s) | Target::Studio(s) => !s.is_empty(),
_ => false,
};
if !slug_ok {
continue;
}
let first = Self::fetch_html(&mut requester, &Self::build_listing_url(&candidate, 1)).await?;
if let Some(body) = first.filter(|b| b.contains("class=\"video-card\"")) {
found = Some(if page <= 1 {
Some(body)
} else {
Self::fetch_html(&mut requester, &Self::build_listing_url(&candidate, page)).await?
});
break;
}
}
match found {
Some(html) => html,
None => Self::fetch_html(&mut requester, &Self::build_listing_url(target, page)).await?,
}
}
_ => Self::fetch_html(&mut requester, &Self::build_listing_url(target, page)).await?,
};
let Some(html) = html else {
return Ok(vec![]);
};
let items = Self::parse_listing(&html)?;
Ok(Self::enrich(items, requester).await)
}
}
#[async_trait]
impl Provider for HoopornProvider {
async fn get_videos(
&self,
_cache: VideoCache,
_pool: DbPool,
_sort: String,
query: Option<String>,
page: String,
_per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u32>().unwrap_or(1).max(1);
let target = Self::pick_target(query.as_deref(), &options);
match Self::fetch(&target, page, &options).await {
Ok(items) => items,
Err(e) => {
report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> ServerOptions {
ServerOptions {
featured: None,
category: None,
sites: None,
filter: None,
language: None,
public_url_base: None,
requester: None,
network: None,
stars: None,
categories: None,
duration: None,
sort: None,
sexuality: None,
client_version: None,
}
}
#[test]
fn builds_urls() {
assert_eq!(HoopornProvider::build_listing_url(&Target::Latest, 2), "https://hooporn.com/home/2");
assert_eq!(
HoopornProvider::build_listing_url(&Target::Category("milf".into()), 3),
"https://hooporn.com/category/milf/3"
);
assert_eq!(
HoopornProvider::build_listing_url(&Target::Search("big tits".into()), 2),
"https://hooporn.com/search?q=big%20tits&p=2"
);
assert_eq!(
HoopornProvider::build_listing_url(&Target::Studio("mylf".into()), 1),
"https://hooporn.com/studio/mylf/1"
);
}
#[test]
fn routes_queries() {
let o = opts();
assert_eq!(
HoopornProvider::pick_target(Some("Double Penetration"), &o),
Target::Category("dp".into())
);
assert_eq!(
HoopornProvider::pick_target(Some("pornstar:Gal Ritchie"), &o),
Target::Pornstar("gal-ritchie".into())
);
assert_eq!(
HoopornProvider::pick_target(Some("studio:Family Strokes"), &o),
Target::Studio("familystrokes".into())
);
assert_eq!(
HoopornProvider::pick_target(Some("golf"), &o),
Target::Keyword("golf".into())
);
assert_eq!(HoopornProvider::pick_target(None, &o), Target::Latest);
}
#[test]
fn parses_cards_detail_and_player() {
let list = r#"<div class="video-card"><div class="video-card-image">
<a class="popout" href="/watch/abc123"><img src="//cdn.hooporn.com/img/blank.gif" data-src="//imgs.hooporn.com/t.jpg?v2" alt="x"></a>
<div class="time">37:16</div></div><div class="video-card-body"><div class="video-title">
<a class="popout" href="/watch/abc123">A &amp; B - Title</a></div>
<div class="video-view"><span class="create">1792152610</span></div></div></div>"#;
let items = HoopornProvider::parse_listing(list).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "A & B - Title");
assert_eq!(items[0].thumb, "https://imgs.hooporn.com/t.jpg?v2");
assert_eq!(items[0].duration, 37 * 60 + 16);
assert_eq!(items[0].uploadedAt, Some(1792152610));
let detail = r##"<iframe src="//vid.hooporn.com/video/182550"></iframe>
<p class="tag-link"><span><a class="popout" href="/category/milf">#milf</a></span></p>
<a href="/pornstar/gal-ritchie">gal ritchie</a><a href="/studio/familystrokes">FAMILY STROKES</a>"##;
let d = HoopornProvider::parse_detail(detail).unwrap();
assert_eq!(d.iframe.as_deref(), Some("https://vid.hooporn.com/video/182550"));
assert_eq!(d.tags, vec!["milf".to_string(), "gal ritchie".to_string()]);
assert_eq!(d.studio, Some(("FAMILY STROKES".to_string(), "familystrokes".to_string())));
let player = r#"var video = "https://videos.trendyporn.com/v/a.mp4?md5=X&amp;expires=1";"#;
assert_eq!(
HoopornProvider::parse_player_video(player).as_deref(),
Some("https://videos.trendyporn.com/v/a.mp4?md5=X&expires=1")
);
}
}

View File

@@ -14,7 +14,11 @@ use htmlentity::entity::{ICodedDataTrait, decode};
use std::sync::{Arc, RwLock};
use std::{thread, vec};
use titlecase::Titlecase;
use wreq::Client;
use wreq::Version;
use wreq::cookie::Jar;
use wreq::redirect::Policy;
use wreq_util::Emulation;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
@@ -246,6 +250,84 @@ impl HypnotubeProvider {
video_items
}
/// Fetches one page of search results.
///
/// The site hands out exactly one results page per `searchgate.php` POST
/// (a second GET of the same search, or any later search on the same
/// session, comes back empty), so every (query, page) uses its own
/// isolated cookie session instead of the shared requester jar. Page 1 is
/// the redirect target of the POST; page N>1 is fetched directly.
async fn fetch_search_html(&self, query: &str, page: u8) -> Result<String> {
let (status, body) = self.search_once(query, page, None).await?;
if !(status == 403 || status == 503 || Requester::looks_like_cloudflare_challenge(&body)) {
return Ok(body);
}
// Cloudflare challenged the isolated session: solve it through the
// shared requester (FlareSolverr fallback), then replay the solved
// cookies and user agent in a fresh isolated session.
let mut requester = Requester::new();
let home = format!("{}/", self.url);
let _ = requester.get(&home, Some(Version::HTTP_11)).await;
let identity = (
requester.cookie_header_for_url(&home),
Requester::remembered_flaresolverr_user_agent(&home),
);
let (_, body) = self.search_once(query, page, Some(identity)).await?;
Ok(body)
}
async fn search_once(
&self,
query: &str,
page: u8,
identity: Option<(Option<String>, Option<String>)>,
) -> Result<(u16, String)> {
let jar = Arc::new(Jar::default());
let mut user_agent = None;
if let Some((cookies, ua)) = identity {
if let Some(cookies) = cookies {
for pair in cookies.split(';').map(str::trim).filter(|p| !p.is_empty()) {
jar.add(pair, self.url.as_str());
}
}
user_agent = ua;
}
let client = Client::builder()
.tls_cert_verification(false)
.emulation(Emulation::Firefox136)
.cookie_provider(jar)
.redirect(if page <= 1 { Policy::default() } else { Policy::none() })
.build()?;
let body = format!("q={}&type=videos", query.trim().replace(' ', "+"));
let mut gate = client
.post(format!("{}/searchgate.php", self.url))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Referer", format!("{}/", self.url))
.body(body);
if let Some(ua) = &user_agent {
gate = gate.header("User-Agent", ua.as_str());
}
let gate = gate.send().await?;
if page <= 1 {
let status = gate.status().as_u16();
return Ok((status, gate.text().await?));
}
let slug = query.trim().replace(' ', "%20");
let page_url = format!("{}/search/videos/{}/page{}.html", self.url, slug, page);
let mut request = client
.get(page_url)
.header("Referer", format!("{}/search/videos/{}/", self.url, slug));
if let Some(ua) = &user_agent {
request = request.header("User-Agent", ua.as_str());
}
let response = request.send().await?;
let status = response.status().as_u16();
Ok((status, response.text().await?))
}
async fn query(
&self,
cache: VideoCache,
@@ -281,41 +363,18 @@ impl HypnotubeProvider {
}
};
let mut requester =
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
let post_response = match requester
.post(
format!("{}/searchgate.php", self.url).as_str(),
format!("q={}&type=videos", query.replace(" ", "+")).as_str(),
vec![("Content-Type", "application/x-www-form-urlencoded")],
)
.await
{
Ok(response) => response,
let text = match self.fetch_search_html(query, page).await {
Ok(text) => text,
Err(e) => {
crate::providers::report_provider_error(
"hypnotube",
"query.search_post",
"query.search",
&format!("url={video_url}; error={e}"),
)
.await;
return old_items;
}
};
let text = match post_response.text().await {
Ok(t) => t,
Err(e) => {
eprint!("Hypnotube search POST request failed: {}", e);
crate::providers::report_provider_error_background(
"hypnotube",
"query.search_post.text",
&e.to_string(),
);
return vec![];
}
};
// println!("Hypnotube search POST response status: {}", p.text().await.unwrap_or_default());
// let text = requester.get(&video_url, Some(Version::HTTP_11)).await.unwrap();
if text.contains("Sorry, no results were found.") {
return vec![];
}

View File

@@ -1,20 +1,16 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, build_proxy_url, strip_url_scheme};
use crate::providers::Provider;
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::discord::{format_error_chain, send_discord_error_report};
use crate::util::requester::Requester;
use crate::util::parse_abbreviated_number;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::future::join_all;
use htmlentity::entity::{ICodedDataTrait, decode};
use std::sync::{Arc, RwLock};
use std::vec;
use titlecase::Titlecase;
use wreq::Version;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
@@ -40,16 +36,13 @@ error_chain! {
#[derive(Debug, Clone)]
pub struct JavtifulProvider {
url: String,
categories: Arc<RwLock<Vec<FilterOption>>>,
}
impl JavtifulProvider {
pub fn new() -> Self {
let provider = Self {
Self {
url: "https://javtiful.com".to_string(),
categories: Arc::new(RwLock::new(vec![])),
};
provider
}
}
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
@@ -61,18 +54,7 @@ impl JavtifulProvider {
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=javtiful.com".to_string(),
status: "active".to_string(),
categories: self
.categories
.read()
.map(|categories| categories.iter().map(|c| c.title.clone()).collect())
.unwrap_or_else(|e| {
crate::providers::report_provider_error_background(
"javtiful",
"build_channel.categories_read",
&e.to_string(),
);
vec![]
}),
categories: vec![],
options: vec![ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
@@ -80,35 +62,85 @@ impl JavtifulProvider {
systemImage: "list.number".to_string(),
colorName: "blue".to_string(),
options: vec![
FilterOption {
id: "relevance".into(),
title: "Relevance".into(),
},
FilterOption {
id: "latest".into(),
title: "Latest".into(),
},
FilterOption {
id: "popular".into(),
title: "Popular".into(),
title: "Popular This Week".into(),
},
FilterOption {
id: "most_viewed".into(),
title: "Most Viewed".into(),
},
FilterOption {
id: "most_liked".into(),
title: "Most Liked".into(),
},
],
multiSelect: false,
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
if let Ok(mut vec) = target.write() {
if !vec.iter().any(|x| x.id == item.id) {
vec.push(item);
}
/// Maps the app's sort ids to the site's `?sort=` values. The default
/// listing (no parameter) is newest first.
fn sort_param(sort: &str) -> &'static str {
match sort {
"popular" => "sort=popular_week&",
"most_viewed" => "sort=most_viewed&",
"most_liked" => "sort=most_liked&",
_ => "",
}
}
async fn fetch_listing(
&self,
cache: VideoCache,
video_url: String,
page: u8,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
let old_items = match cache.get(&video_url) {
Some((time, items)) if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 => {
return Ok(items.clone());
}
Some((_, items)) => items.clone(),
None => vec![],
};
let mut requester =
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
let text = match requester.get(&video_url, Some(Version::HTTP_2)).await {
Ok(text) => text,
Err(e) => {
crate::providers::report_provider_error(
"javtiful",
"fetch_listing.request",
&format!("url={video_url}; error={e}"),
)
.await;
return Ok(old_items);
}
};
// Out-of-range pages are served as page 1 again; only trust the grid
// when the pager marks the requested page as current.
if page > 1 && !text.contains(&format!("aria-current=\"page\">{}<", page)) {
return Ok(vec![]);
}
let video_items = self.get_video_items_from_html(&text);
if video_items.is_empty() {
return Ok(old_items);
}
cache.remove(&video_url);
cache.insert(video_url, video_items.clone());
Ok(video_items)
}
async fn get(
&self,
cache: VideoCache,
@@ -116,58 +148,13 @@ impl JavtifulProvider {
sort: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let sort_string = match sort {
"latest" => "sort=latest&",
"popular" => "sort=popular&",
_ => "",
};
let video_url = format!("{}/videos?{}page={}", self.url, sort_string, page);
println!("Fetching URL: {}", video_url);
let old_items = match cache.get(&video_url) {
Some((time, items)) => {
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
return Ok(items.clone());
} else {
items.clone()
}
}
None => {
vec![]
}
};
let mut requester =
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
let text = match requester.get(&video_url, Some(Version::HTTP_2)).await {
Ok(text) => text,
Err(e) => {
crate::providers::report_provider_error(
"javtiful",
"get.request",
&format!("url={video_url}; error={e}"),
)
.await;
return Ok(old_items);
}
};
if page > 1
&& !text.contains(&format!(
"aria-current=\"page\">{}</a>",
page
))
{
return Ok(vec![]);
}
let video_items: Vec<VideoItem> = self
.get_video_items_from_html(text.clone(), &mut requester, &options)
.await;
if !video_items.is_empty() {
cache.remove(&video_url);
cache.insert(video_url.clone(), video_items.clone());
} else {
return Ok(old_items);
}
Ok(video_items)
let video_url = format!(
"{}/videos?{}page={}",
self.url,
Self::sort_param(sort),
page
);
self.fetch_listing(cache, video_url, page, &options).await
}
async fn query(
@@ -177,271 +164,83 @@ impl JavtifulProvider {
query: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let sort_string = match options.sort.as_deref().unwrap_or("") {
"latest" => "sort=latest&",
"popular" => "sort=popular&",
_ => "",
};
let sort = options.sort.clone().unwrap_or_default();
let video_url = format!(
"{}/search?{}q={}&page={}",
self.url,
sort_string,
query.replace(" ", "+"),
Self::sort_param(&sort),
query.trim().replace(' ', "+"),
page
);
// Check our Video Cache. If the result is younger than 1 hour, we return it.
let old_items = match cache.get(&video_url) {
Some((time, items)) => {
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
return Ok(items.clone());
} else {
let _ = cache.check().await;
return Ok(items.clone());
}
}
None => {
vec![]
}
};
let mut requester =
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
let text = match requester.get(&video_url, Some(Version::HTTP_2)).await {
Ok(text) => text,
Err(e) => {
crate::providers::report_provider_error(
"javtiful",
"query.request",
&format!("url={video_url}; error={e}"),
)
.await;
return Ok(old_items);
}
};
if page > 1
&& !text.contains(&format!(
"aria-current=\"page\">{}</a>",
page
))
{
return Ok(vec![]);
}
let video_items: Vec<VideoItem> = self
.get_video_items_from_html(text.clone(), &mut requester, &options)
.await;
if !video_items.is_empty() {
cache.remove(&video_url);
cache.insert(video_url.clone(), video_items.clone());
} else {
return Ok(old_items);
}
Ok(video_items)
self.fetch_listing(cache, video_url, page, &options).await
}
async fn get_video_items_from_html(
&self,
html: String,
requester: &mut Requester,
options: &ServerOptions,
) -> Vec<VideoItem> {
if html.is_empty() || html.contains("404 Not Found") {
return vec![];
}
fn between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
let from = text.find(start)? + start.len();
let rest = &text[from..];
Some(&rest[..rest.find(end)?])
}
let block = match html
.split("front-pagination")
.next()
.and_then(|s| s.split("front-video-grid").nth(1))
{
Some(b) => b,
None => {
eprint!("Javtiful Provider: Failed to get block from html");
let e = Error::from(ErrorKind::Parse("html".into()));
send_discord_error_report(
e.to_string(),
Some(format_error_chain(&e)),
Some("Javtiful Provider"),
Some(&format!("Failed to get block from html:\n```{html}\n```")),
file!(),
line!(),
module_path!(),
)
.await;
return vec![];
}
};
let futures = block
.split("\"front-video-card\"")
fn get_video_items_from_html(&self, html: &str) -> Vec<VideoItem> {
// Cards are `<article class="video-card">`; sponsored slots add a
// `video-card--partner` modifier and link off-site, so skip them.
html.split("<article class=\"video-card")
.skip(1)
.filter(|seg| !seg.contains("front-ad-card"))
.map(|el| self.get_video_item(el.to_string(), requester.clone(), options));
join_all(futures)
.await
.into_iter()
.inspect(|r| {
if let Err(e) = r {
eprint!("Javtiful Provider: Failed to get video item:{}\n", e);
// Prepare data to move into the background task
let msg = e.to_string();
let chain = format_error_chain(&e);
// Spawn the report into the background - NO .await here
tokio::spawn(async move {
let _ = send_discord_error_report(
msg,
Some(chain),
Some("Javtiful Provider"),
Some("Failed to get video item"),
file!(), // Note: these might report the utility line
line!(), // better to hardcode or pass from outside
module_path!(),
)
.await;
});
}
})
.filter_map(Result::ok)
.filter(|seg| !seg.starts_with("video-card--partner") && !seg.starts_with(" video-card--partner"))
.filter_map(|seg| self.get_video_item(seg))
.collect()
}
async fn get_video_item(
&self,
seg: String,
mut requester: Requester,
options: &ServerOptions,
) -> Result<VideoItem> {
let video_url = format!(
"{}{}",
self.url,
seg.split(" href=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.ok_or_else(|| ErrorKind::Parse(format!("video url\n\n{seg}")))?
.to_string()
);
fn get_video_item(&self, seg: &str) -> Option<VideoItem> {
let href = Self::between(seg, "href=\"", "\"")?;
if !href.starts_with("/video/") {
return None;
}
let video_url = format!("{}{}", self.url, href);
let id = href.trim_start_matches("/video/").split('/').next()?.to_string();
if id.is_empty() {
return None;
}
let mut title = match seg.contains("front-video-title") {
true => seg
.split("front-video-title")
.nth(1)
.and_then(|s| s.split('>').nth(1))
.and_then(|s| s.split('<').next())
.ok_or_else(|| ErrorKind::Parse(format!("video title\n\n{seg}")))?
.trim()
.to_string(),
false => seg
.split("alt=\"")
.nth(1)
.and_then(|s| s.split('\"').next())
.ok_or_else(|| ErrorKind::Parse(format!("video title\n\n{seg}")))?
.trim()
.to_string(),
};
title = decode(title.as_bytes())
let title_raw = Self::between(seg, "class=\"video-card__title\" title=\"", "\"")
.or_else(|| Self::between(seg, "alt=\"Thumbnail for ", "\""))?
.trim();
let title = decode(title_raw.as_bytes())
.to_string()
.unwrap_or(title)
.titlecase();
let id = video_url
.split("/video/")
.filter(|s| !s.is_empty())
.nth(1)
.ok_or_else(|| ErrorKind::Parse(format!("video id\n\n{seg}")))?
.to_string();
let thumb_block = seg.split("<img ").nth(1);
.unwrap_or_else(|_| title_raw.to_string());
let thumb = match thumb_block {
Some(block) => format!("{}{}", self.url,block
.split("data-front-lazy-src=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string()),
None => "".to_string(),
};
let mut preview = seg
.split("data-front-video-preview-src=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let raw_duration = seg
.split("class=\"front-duration-tag\">")
.nth(1)
.and_then(|s| s.split('<').next())
.unwrap_or("")
.to_string();
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
let (tags, views) = self.extract_media(&video_url, &mut requester).await?;
if preview.len() == 0 {
preview = format!("https://trailers.jav.si/preview/{id}.mp4");
}
let proxy_url = build_proxy_url(
options,
"javtiful",
&strip_url_scheme(video_url.clone().as_str()),
);
let video_item = VideoItem::new(id, title, proxy_url, "javtiful".into(), thumb, duration)
.tags(tags)
.preview(preview)
.views(views);
Ok(video_item)
}
async fn extract_media(
&self,
url: &str,
requester: &mut Requester,
) -> Result<(Vec<String>, u32)> {
let text = requester
.get(url, Some(Version::HTTP_2))
.await
.map_err(|e| Error::from(format!("{}", e)))?;
let tags = text
.split("related-actress")
.next()
.and_then(|s| s.split("video-comments").next())
.and_then(|s| s.split(">Tags").nth(1))
.and_then(|s| s.split("</div>").next())
.map(|tag_block| {
tag_block
.split("<a ")
.skip(1)
.filter_map(|tag_el| {
tag_el
.split('>')
.nth(1)
.and_then(|s| s.split('<').next())
.map(|s| {
decode(s.as_bytes())
.to_string()
.unwrap_or(s.to_string())
.titlecase()
})
})
.collect()
let thumb = Self::between(seg, "data-front-lazy-src=\"", "\"")
.map(|t| {
if t.starts_with("http") {
t.to_string()
} else {
format!("{}{}", self.url, t)
}
})
.unwrap_or_else(|| vec![]);
for tag in &tags {
Self::push_unique(
&self.categories,
FilterOption {
id: tag.to_ascii_lowercase().replace(" ", "+"),
title: tag.to_string(),
},
);
}
let views = text
.split(" views</span>")
.next()
.and_then(|s| s.split('>').last())
.and_then(|s| s.trim().replace(".", "").replace(",", "").parse::<u32>().ok())
.unwrap_or_default();
let preview = Self::between(seg, "data-front-video-preview-src=\"", "\"")
.unwrap_or("")
.to_string();
// `<span class="video-card__details"><strong>FHD</strong>02:49:46</span>`
let duration = Self::between(seg, "class=\"video-card__details\">", "</span>")
.and_then(|d| d.rsplit("</strong>").next())
.and_then(|d| parse_time_to_seconds(d.trim()))
.unwrap_or(0) as u32;
// `<div class="video-card__meta">12.9K views <span ..>·</span> 6 hours ago</div>`
let views = Self::between(seg, "class=\"video-card__meta\">", "views")
.and_then(|v| parse_abbreviated_number(&v.trim().replace(',', "")))
.unwrap_or(0);
Ok((tags, views))
// The page URL resolves natively in yt-dlp (html5 `<source>`), so no
// proxy and no formats are needed.
let mut item = VideoItem::new(id, title, video_url, "javtiful".into(), thumb, duration)
.views(views);
if !preview.is_empty() {
item = item.preview(preview);
}
Some(item)
}
}
@@ -474,3 +273,32 @@ impl Provider for JavtifulProvider {
Some(self.build_channel(v))
}
}
#[cfg(test)]
mod tests {
use super::JavtifulProvider;
#[test]
fn parses_cards_and_skips_partner_slots() {
let html = r#"<div class="video-grid">
<article class="video-card"><a href="/video/114451/royd-358" class="video-card__thumbnail" data-front-video-preview-src="https://x.jav.si/p.mp4"><img src="/ph.svg" data-front-lazy-src="/uploads/t.jpg" alt="Thumbnail for ROYD-358 T"><span class="video-card__details"><strong>FHD</strong>02:49:46</span></a>
<div class="video-card__copy"><a href="/video/114451/royd-358" class="video-card__title" title="ROYD-358 I&#8217;ll &amp; You">ROYD-358</a><div class="video-card__meta">12.9K views <span aria-hidden="true">&middot;</span> 6 hours ago</div></div></article>
<article class="video-card video-card--partner"><a href="https://ads.example/c" class="video-card__thumbnail"><img data-front-lazy-src="/a.jpg" alt="Partner thumbnail for Ad"></a></article>
</div>"#;
let items = JavtifulProvider::new().get_video_items_from_html(html);
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "114451");
assert_eq!(items[0].url, "https://javtiful.com/video/114451/royd-358");
assert_eq!(items[0].thumb, "https://javtiful.com/uploads/t.jpg");
assert_eq!(items[0].duration, 2 * 3600 + 49 * 60 + 46);
assert_eq!(items[0].views, Some(12900));
assert!(items[0].formats.is_none());
}
#[test]
fn maps_sort_values() {
assert_eq!(JavtifulProvider::sort_param("latest"), "");
assert_eq!(JavtifulProvider::sort_param("popular"), "sort=popular_week&");
assert_eq!(JavtifulProvider::sort_param("most_viewed"), "sort=most_viewed&");
}
}

View File

@@ -494,11 +494,18 @@ impl MelonstubeProvider {
Self::parse_kvs_formats(&html, &origin)
}
/// Through the eporner streaming proxy: its CDN links expire after two
/// hours and only serve the server's IP (else a "watch on eporner.com" clip).
async fn resolve_eporner(
requester: &mut Requester,
options: &ServerOptions,
detail_url: &str,
) -> Option<Vec<VideoFormat>> {
crate::util::eporner_resolve::resolve_public_formats(requester, detail_url).await
let alnum_id = crate::util::eporner_resolve::alnum_id_from_href(detail_url)?;
crate::util::eporner_resolve::resolve_proxied_formats(
requester, options, &alnum_id, detail_url,
)
.await
}
fn xhamster_mirror_mp4_regex() -> &'static Regex {
@@ -632,10 +639,11 @@ impl MelonstubeProvider {
async fn resolve_source_formats(
kind: SourceKind,
requester: &mut Requester,
options: &ServerOptions,
detail_url: &str,
) -> Option<Vec<VideoFormat>> {
match kind {
SourceKind::Eporner => Self::resolve_eporner(requester, detail_url).await,
SourceKind::Eporner => Self::resolve_eporner(requester, options, detail_url).await,
SourceKind::Kvs => Self::resolve_kvs(requester, detail_url, false).await,
SourceKind::KvsCfGated => Self::resolve_kvs(requester, detail_url, true).await,
SourceKind::XhamsterMirror => Self::resolve_xhamster_mirror(requester, detail_url).await,
@@ -904,6 +912,7 @@ impl MelonstubeProvider {
if !pending.is_empty() {
let resolved = stream::iter(pending.into_iter().map(|(idx, kind, dest_url)| {
let mut req = requester.clone();
let options = &options;
async move {
// Bounded so one slow/hung third-party destination (e.g.
// an unreachable FlareSolverr fallback) can't stall the
@@ -911,7 +920,7 @@ impl MelonstubeProvider {
// its plain destination URL with no formats.
let formats = tokio::time::timeout(
std::time::Duration::from_secs(ENRICH_TIMEOUT_SECS),
Self::resolve_source_formats(kind, &mut req, &dest_url),
Self::resolve_source_formats(kind, &mut req, options, &dest_url),
)
.await
.ok()

View File

@@ -612,6 +612,66 @@ pub fn report_provider_error_background(provider_name: &str, context: &str, msg:
});
}
/// `Some(slug)` joined with `-` ("Gal Ritchie" -> "gal-ritchie") for archive URLs.
pub fn slug_dashed(value: &str) -> String {
let mut out = String::new();
let mut last_dash = true;
for c in value.trim().chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
out.trim_matches('-').to_string()
}
/// Same as [`slug_dashed`] without separators ("Family Strokes" -> "familystrokes").
pub fn slug_joined(value: &str) -> String {
slug_dashed(value).replace('-', "")
}
/// Probes candidate archive URLs (pornstar / studio / channel / tag pages) in
/// order and returns the index and body of the first that exists and
/// `has_items` accepts. A miss (404, empty page, transport error) moves on to
/// the next candidate; the caller falls back to keyword search on `None`.
/// Uses `Requester::get_probe`, so an unknown archive answers instantly and a
/// Cloudflare challenge is solved through FlareSolverr.
pub async fn probe_first_archive(
requester: &mut Requester,
urls: &[String],
referer: &str,
has_items: impl Fn(&str) -> bool,
) -> Option<(usize, String)> {
probe_first_archive_with(
requester,
urls,
vec![("Referer".to_string(), referer.to_string())],
has_items,
)
.await
}
/// [`probe_first_archive`] with caller-supplied request headers (JSON APIs
/// that demand a special `Accept`, extra tokens, ...).
pub async fn probe_first_archive_with(
requester: &mut Requester,
urls: &[String],
headers: Vec<(String, String)>,
has_items: impl Fn(&str) -> bool,
) -> Option<(usize, String)> {
for (index, url) in urls.iter().enumerate() {
if let Ok(Some(body)) = requester.get_probe(url, headers.clone()).await {
if has_items(&body) {
return Some((index, body));
}
}
}
None
}
pub fn requester_or_default(
options: &ServerOptions,
provider_name: &str,
@@ -644,6 +704,25 @@ pub fn requester_or_default(
}
#[allow(dead_code)]
/// Routes signed Aylo CDN thumbnails (`pix-cdn77.t8cdn.com`,
/// `pix-cdn77.phncdn.com`) through the local `aylo-thumb` proxy, because the
/// CDN answers 403 unless a matching Referer is sent. Other URLs are returned
/// unchanged.
pub fn proxy_aylo_thumb(options: &ServerOptions, thumb: &str) -> String {
let needs_proxy = thumb
.strip_prefix("https://")
.or_else(|| thumb.strip_prefix("http://"))
.and_then(|rest| rest.split('/').next())
.is_some_and(|host| {
host.starts_with("pix-cdn77.")
&& (host.ends_with(".t8cdn.com") || host.ends_with(".phncdn.com"))
});
if !needs_proxy {
return thumb.to_string();
}
build_proxy_url(options, "aylo-thumb", &strip_url_scheme(thumb))
}
pub fn strip_url_scheme(url: &str) -> String {
url.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
@@ -759,11 +838,20 @@ fn sanitize_status_options(options: Vec<ChannelOption>) -> Vec<ChannelOption> {
.collect()
}
/// Channels whose own declared `ytdlpCommand` is delivered in `/api/status`.
/// Verified: `freepornvideosxxx` (plain yt-dlp gets 502 on its pages, fine with
/// `--impersonate chrome-120`) and `missav` (Cloudflare 403 without it).
const YTDLP_COMMAND_CHANNELS: &[&str] = &["freepornvideosxxx", "missav"];
pub fn decorate_channel(channel: Channel) -> ChannelView {
let metadata = channel_metadata_for(&channel.id);
let runtime_status = current_provider_channel_status(&channel.id);
let ytdlp_command = match channel.id.as_str() {
"pimpbunny" => Some("yt-dlp --compat-options allow-unsafe-ext".to_string()),
// Providers declare a `ytdlpCommand`, but it is only sent to clients
// for channels where plain yt-dlp was verified to fail (TLS-fingerprint
// 403/502 that real clients hit). Widen this list one channel at a time.
id if YTDLP_COMMAND_CHANNELS.contains(&id) => channel.ytdlpCommand.clone(),
_ => None,
};
ChannelView {
@@ -1353,6 +1441,25 @@ mod tests {
assert_eq!(groups[2].id, "jav");
}
#[test]
fn delivers_declared_ytdlp_command_only_for_allowlisted_channels() {
for id in ["freepornvideosxxx", "missav"] {
PROVIDER_RUNTIME_STATUS.remove(id);
assert_eq!(
decorate_channel(base_channel(id)).ytdlpCommand.as_deref(),
Some("yt-dlp --impersonate chrome-120"),
"{id} should deliver its declared command"
);
}
// Declares the same command but is not allowlisted.
PROVIDER_RUNTIME_STATUS.remove("hsex");
assert_eq!(decorate_channel(base_channel("hsex")).ytdlpCommand, None);
// An allowlisted id that declares nothing stays None.
let mut undeclared = base_channel("missav");
undeclared.ytdlpCommand = None;
assert_eq!(decorate_channel(undeclared).ytdlpCommand, None);
}
#[test]
fn decorates_pimpbunny_with_ytdlp_command() {
PROVIDER_RUNTIME_STATUS.remove("pimpbunny");
@@ -1730,3 +1837,16 @@ mod tests {
);
}
}
#[cfg(test)]
mod slug_tests {
use super::{slug_dashed, slug_joined};
#[test]
fn slugs_names_for_archive_urls() {
assert_eq!(slug_dashed(" Gal Ritchie "), "gal-ritchie");
assert_eq!(slug_dashed("Mia Khalifa!!"), "mia-khalifa");
assert_eq!(slug_joined("Family Strokes"), "familystrokes");
assert_eq!(slug_dashed("???"), "");
}
}

View File

@@ -498,13 +498,26 @@ impl NoodlemagazineProvider {
let video_url = format!("{}{}", self.url, href);
// The title div now carries extra attributes
// (`<div class="title" data-video-title-text>`), so match the class
// and skip to the tag end; fall back to the thumbnail `alt`, which
// holds the same text.
let mut title = video_segment
.split("<div class=\"title\">")
.split("<div class=\"title\"")
.nth(1)
.and_then(|s| s.split('<').next())
.unwrap_or("")
.trim()
.to_string();
.and_then(|s| s.split_once('>'))
.and_then(|(_, rest)| rest.split('<').next())
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.or_else(|| {
video_segment
.split("alt=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
})
.unwrap_or_default();
title = decode(title.as_bytes())
.to_string()
@@ -671,6 +684,30 @@ mod tests {
assert_eq!(items[0].formats.as_ref().map(|f| f.len()), Some(1));
}
#[test]
fn parses_title_when_title_div_has_extra_attributes() {
let provider = NoodlemagazineProvider::new();
let options = options();
let html = r##"
<div class="list_videos" id="list_videos">
<div class="item" data-video-title-base="x">
<a href="/watch/-1_2" class="item_link">
<div class="i_img"><img src="data:image/gif;base64,R0lG" data-src="https://img.pvvstream.pro/preview/a/b.jpg" class="lazyload" alt="Alt text">
<div class="m_views"><svg class="icon icon-eye"><use xlink:href="#eye"></use></svg> 133</div>
<div class="m_time"><svg class="icon icon-clock-o"><use xlink:href="#clock-o"></use></svg> 04:55</div></div>
<div class="i_info"><div class="title" data-video-title-text>The girl &amp; her lover</div></div>
</a>
</div>
>Show more</div>
"##;
let items = provider.get_video_items_from_html(html.to_string(), &options);
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "The Girl & Her Lover");
assert_eq!(items[0].duration, 295);
}
#[test]
fn keeps_https_cdn_thumbs_but_drops_non_images() {
let provider = NoodlemagazineProvider::new();

View File

@@ -2,6 +2,7 @@ use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, report_provider_error, report_provider_error_background, requester_or_default,
probe_first_archive,
};
use crate::status::*;
use crate::util::cache::VideoCache;
@@ -260,6 +261,34 @@ impl Porn4fansProvider {
Target::Latest
}
/// A free-form query that names a model or a tag goes to that archive
/// (probed on the site; unknown names 404), else keyword search.
async fn resolve_keyword(&self, query: &str, options: &ServerOptions) -> Option<Target> {
let slug = Self::slugify(query);
if slug.is_empty() {
return None;
}
let candidates = [
Target::Model { slug: slug.clone() },
Target::Tag { slug },
];
let urls: Vec<String> = candidates
.iter()
.map(|t| match t {
Target::Model { slug } => format!("{}/models/{slug}/", self.url),
Target::Tag { slug } => format!("{}/tags/{slug}/", self.url),
_ => String::new(),
})
.collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
let (index, _) =
probe_first_archive(&mut requester, &urls, &format!("{}/", self.url), |body| {
body.contains("custom_list_videos")
})
.await?;
candidates.into_iter().nth(index)
}
/// Build the async `get_block` URL plus the Referer to send with it.
fn build_block_url(&self, target: &Target, sort: &str, page: u32) -> (String, String) {
let sort_by = Self::sort_by(sort);
@@ -788,7 +817,12 @@ impl Provider for Porn4fansProvider {
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u32>().unwrap_or(1).max(1);
let target = self.target_from_request(query.as_deref(), options.categories.as_deref());
let mut target = self.target_from_request(query.as_deref(), options.categories.as_deref());
if let Target::Search { query } = &target {
if let Some(found) = self.resolve_keyword(query, &options).await {
target = found;
}
}
match self
.get_videos_for_target(cache, &target, &sort, page, options)

View File

@@ -1,6 +1,9 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{
Provider, probe_first_archive, proxy_aylo_thumb, report_provider_error, requester_or_default,
slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::parse_abbreviated_number;
@@ -45,6 +48,8 @@ error_chain! {
pub struct PornhubProvider {
url: String,
tag_map: Arc<RwLock<HashMap<String, TagInfo>>>,
/// Queries already probed on the site that are neither a pornstar, model nor channel.
probe_misses: Arc<RwLock<std::collections::HashSet<String>>>,
}
#[derive(Debug, Clone, Copy)]
@@ -91,6 +96,7 @@ impl PornhubProvider {
let provider = Self {
url: BASE_URL.to_string(),
tag_map: Arc::new(RwLock::new(HashMap::new())),
probe_misses: Arc::new(RwLock::new(std::collections::HashSet::new())),
};
provider.spawn_initial_load();
provider
@@ -588,6 +594,43 @@ impl PornhubProvider {
values.push(normalized.to_string());
}
/// A free-form query that is not in the loaded catalogs may still name a
/// pornstar, model or channel. Probe the site (unknown names 404 or
/// redirect away) and remember the hit in `tag_map` so later pages and
/// queries resolve without another request.
async fn learn_query_target(&self, query: &str, options: &ServerOptions) {
let key = query.trim().to_ascii_lowercase();
let slug = slug_dashed(query);
if slug.is_empty() || self.probe_misses.read().unwrap().contains(&key) {
return;
}
let kinds = [
QueryTargetKind::Pornstar,
QueryTargetKind::Model,
QueryTargetKind::Channel,
];
let urls: Vec<String> = kinds
.iter()
.map(|kind| format!("{}/{}/{slug}/videos", self.url, kind.path_segment()))
.collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "learn_query_target");
let hit = probe_first_archive(&mut requester, &urls, &format!("{}/", self.url), |body| {
body.contains("pcVideoListItem")
})
.await;
match hit {
Some((index, _)) => {
self.tag_map.write().unwrap().insert(
key,
TagInfo { kind: kinds[index], slug, title: query.trim().to_string() },
);
}
None => {
self.probe_misses.write().unwrap().insert(key);
}
}
}
async fn fetch_listing(
&self,
cache: VideoCache,
@@ -596,6 +639,11 @@ impl PornhubProvider {
query: Option<&str>,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
if self.parse_query_target(q).is_none() {
self.learn_query_target(q, &options).await;
}
}
if query.is_some() && self.tag_map.read().unwrap().is_empty() {
let _ = Self::load_tags(&self.url, Arc::clone(&self.tag_map)).await;
}
@@ -624,7 +672,13 @@ impl PornhubProvider {
};
let items = match self.parse_listing_page(&text, scope) {
Ok(items) => items,
// `pix-cdn77.phncdn.com` thumbs answer 403 without a matching Referer.
Ok(mut items) => {
for item in items.iter_mut() {
item.thumb = proxy_aylo_thumb(&options, &item.thumb);
}
items
}
Err(error) => {
report_provider_error(
CHANNEL_ID,

View File

@@ -1,6 +1,6 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{Provider, proxy_aylo_thumb, report_provider_error, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoItem};
@@ -408,6 +408,11 @@ impl PornhubShortiesProvider {
let items: Vec<VideoItem> = shorties
.into_iter()
.filter_map(Self::item_to_video)
.map(|mut video| {
// The signed pix-cdn77 thumbnails 403 without a Referer.
video.thumb = proxy_aylo_thumb(&options, &video.thumb);
video
})
.collect();
if items.is_empty() {

View File

@@ -835,7 +835,8 @@ impl PornmzProvider {
}
fn meta_itemprop(document: &Html, itemprop: &str) -> Option<String> {
let selector = Self::selector(&format!("meta[itemprop=\"{itemprop}\"]")).ok()?;
// The site mixes `contentURL` / `contentUrl`; match the value case-insensitively.
let selector = Self::selector(&format!("meta[itemprop=\"{itemprop}\" i]")).ok()?;
document
.select(&selector)
.next()
@@ -1016,9 +1017,21 @@ impl PornmzProvider {
let enriched = stream::iter(items.into_iter().map(|item| {
let provider = self.clone();
let options = options.clone();
async move { provider.enrich_item(item, &options).await }
async move {
// Detail pages are slow (~15s); don't let one hung page stall the list.
let fallback = item.clone();
match tokio::time::timeout(
std::time::Duration::from_secs(25),
provider.enrich_item(item, &options),
)
.await
{
Ok(enriched) => enriched,
Err(_) => fallback,
}
}
}))
.buffer_unordered(6)
.buffer_unordered(10)
.collect::<Vec<_>>()
.await;
@@ -1239,6 +1252,23 @@ mod tests {
assert!(item.uploadedAt.is_some());
}
#[test]
fn reads_lowercase_content_url_meta() {
let provider = PornmzProvider::new();
let item = VideoItem::new(
"pm1".to_string(),
"T".to_string(),
"https://pornmz.com/video/id=pm1".to_string(),
CHANNEL_ID.to_string(),
String::new(),
0,
);
let html = r#"<div class="entry-content"><meta itemprop="contentUrl" content="https://video.twimg.com/x/pl/a.m3u8" /></div>"#;
let item = provider.apply_detail_video(item, html).expect("detail");
assert_eq!(item.url, "https://video.twimg.com/x/pl/a.m3u8");
assert_eq!(item.formats.as_ref().map(|f| f.len()), Some(1));
}
#[test]
fn derives_mp4_media_extension() {
assert_eq!(

View File

@@ -560,6 +560,24 @@ impl Provider for RedgifsProvider {
let sort = Sort::from_sort_id(&sort);
let target = Self::pick_target(normalized_query, sort, &options);
// A single-word query that is an existing creator name goes to that
// creator's gifs (checked against the users endpoint); a creator
// without gifs falls back to the keyword search.
if let Target::Query(text, _) = &target {
if !text.contains(char::is_whitespace) {
if let Ok(Some(profile)) = self.fetch_user_profile(text, &options).await {
let creator = Target::Creator(profile.username);
if let Ok(items) = self
.fetch_target_items(creator, page, per_page, &options)
.await
{
if !items.is_empty() {
return items;
}
}
}
}
}
match self.fetch_target_items(target, page, per_page, &options).await {
Ok(items) => items,

View File

@@ -1,18 +1,35 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::Provider;
use crate::providers::{Provider, requester_or_default, slug_dashed};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::discord::send_discord_error_report;
use crate::util::parse_abbreviated_number;
use crate::util::requester::Requester;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoItem};
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use htmlentity::entity::{ICodedDataTrait, decode};
use std::time::{SystemTime, UNIX_EPOCH};
use scraper::{Html, Selector};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use std::vec;
const ENRICH_CONCURRENCY: usize = 4;
/// The signed `v-acctoken` format URLs last ~12h; keep details well inside that.
const DETAIL_TTL: Duration = Duration::from_secs(2 * 60 * 60);
const DETAIL_CACHE_SIZE: usize = 5000;
const TARGET_TTL: Duration = Duration::from_secs(6 * 60 * 60);
const TARGET_CACHE_SIZE: usize = 2000;
/// A probed archive smaller than this loses to keyword search: "elf girl" is a
/// one-video tag, while the search finds hundreds.
const MIN_PROBED_ARCHIVE_ITEMS: usize = 4;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "hentai-animation",
@@ -32,15 +49,66 @@ error_chain! {
}
}
#[derive(Debug, Clone)]
/// What a request resolves to. Artists live under `/models/`, franchises and
/// genres under `/categories/`, characters and everything else under `/tags/`.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Latest,
Search(String),
Artist(String),
Category(String),
Tag(String),
}
impl Target {
/// Archive path (`models/{slug}`, ...) for the archive targets.
fn archive_path(&self) -> Option<String> {
match self {
Target::Artist(slug) => Some(format!("models/{slug}")),
Target::Category(slug) => Some(format!("categories/{slug}")),
Target::Tag(slug) => Some(format!("tags/{slug}")),
_ => None,
}
}
}
#[derive(Clone)]
pub struct Rule34videoProvider {
url: String,
/// Enriched items (formats, tags, artist) by video URL. Every listing costs
/// ~23 detail pages, again for the prefetched next page; refetching them
/// on each request is what runs into the site's 429 rate limit.
details: VideoCache,
/// Bare query -> resolved target, so later pages skip the archive probes.
targets: Arc<Mutex<HashMap<String, (Instant, Target)>>>,
}
impl Rule34videoProvider {
pub fn new() -> Self {
let mut details = VideoCache::new();
details.max_size(DETAIL_CACHE_SIZE);
Rule34videoProvider {
url: "https://rule34video.com".to_string(),
details,
targets: Arc::new(Mutex::new(HashMap::new())),
}
}
fn remembered_target(&self, query: &str) -> Option<Target> {
let targets = self.targets.lock().ok()?;
let (time, target) = targets.get(&slug_dashed(query))?;
(time.elapsed() < TARGET_TTL).then(|| target.clone())
}
fn remember_target(&self, query: &str, target: &Target) {
if let Ok(mut targets) = self.targets.lock() {
if targets.len() >= TARGET_CACHE_SIZE {
targets.retain(|_, (time, _)| time.elapsed() < TARGET_TTL);
if targets.len() >= TARGET_CACHE_SIZE {
targets.clear();
}
}
targets.insert(slug_dashed(query), (Instant::now(), target.clone()));
}
}
@@ -89,6 +157,192 @@ impl Rule34videoProvider {
}
}
/// Explicit `artist:` / `category:` / `tag:` style prefixes (the server has
/// already stripped a leading `#`).
fn parse_prefixed(query: &str) -> Option<Target> {
let (kind, value) = query.trim().split_once(':')?;
let slug = slug_dashed(value);
if slug.is_empty() {
return None;
}
match kind.trim().to_ascii_lowercase().as_str() {
"artist" | "artists" | "model" | "models" | "author" | "creator" | "uploader" => {
Some(Target::Artist(slug))
}
"cat" | "category" | "categories" | "franchise" | "series" | "game" => {
Some(Target::Category(slug))
}
"tag" | "tags" | "character" | "char" => Some(Target::Tag(slug)),
_ => None,
}
}
/// Titles of the tags offered by `/search_ajax.php?tag=`.
fn parse_tag_suggestions(html: &str) -> Vec<String> {
let document = Html::parse_fragment(html);
let Ok(selector) = Selector::parse("input[data-title]") else {
return vec![];
};
document
.select(&selector)
.filter_map(|input| input.value().attr("data-title"))
.map(|title| title.trim().to_string())
.filter(|title| !title.is_empty())
.collect()
}
/// The suggested tag whose name (ignoring a trailing "(franchise)") equals
/// the query, e.g. "samus aran" -> "samus aran (metroid)".
fn best_tag_title(titles: &[String], query: &str) -> Option<String> {
let wanted = slug_dashed(query);
if wanted.is_empty() {
return None;
}
let mut exact = titles.iter().filter(|title| {
let base = title.split('(').next().unwrap_or(title);
slug_dashed(base) == wanted || slug_dashed(title) == wanted
});
let first = exact.next()?;
// Several franchises share a character name: ambiguous, leave it to search.
exact.next().is_none().then(|| first.clone())
}
/// A free-form query that names an artist, category or tag goes to that
/// archive (probed on the site; unknown names 404), else keyword search.
/// The second value is false when a probe failed (rate limit, outage) and
/// the answer must not be remembered.
async fn resolve_keyword(&self, query: &str, options: &ServerOptions) -> (Target, bool) {
let slug = slug_dashed(query);
if slug.is_empty() {
return (Target::Search(query.trim().to_string()), true);
}
// Artist slugs are inconsistent on the site: "general-butch" but "biggreen".
let mut candidates = vec![Target::Artist(slug.clone())];
if slug.contains('-') {
candidates.push(Target::Artist(slug.replace('-', "")));
}
candidates.push(Target::Category(slug.clone()));
candidates.push(Target::Tag(slug));
let mut requester = requester_or_default(options, "rule34video", "resolve_keyword");
let referer = format!("{}/", self.url);
let mut conclusive = true;
for candidate in &candidates {
let Some(path) = candidate.archive_path() else {
continue;
};
let url = format!("{}/{path}/", self.url);
let headers = vec![("Referer".to_string(), referer.clone())];
match requester.get_probe(&url, headers).await {
Ok(Some(body)) => {
let items = self.get_video_items_from_html(body).len();
if items >= MIN_PROBED_ARCHIVE_ITEMS {
return (candidate.clone(), true);
}
}
Ok(None) => {}
Err(_) => conclusive = false,
}
}
// Characters are tagged "name (franchise)": ask the site's own tag search.
let suggest_url = format!(
"{}/search_ajax.php?tag={}",
self.url,
utf8_percent_encode(query.trim(), NON_ALPHANUMERIC)
);
let headers = vec![
("Referer".to_string(), referer),
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
];
match requester.get_probe(&suggest_url, headers).await {
Ok(Some(body)) => {
let titles = Self::parse_tag_suggestions(&body);
if let Some(title) = Self::best_tag_title(&titles, query) {
return (Target::Tag(slug_dashed(&title)), conclusive);
}
}
Ok(None) => {}
Err(_) => conclusive = false,
}
(Target::Search(query.trim().to_string()), conclusive)
}
async fn resolve_target(&self, query: Option<&str>, options: &ServerOptions) -> Target {
let Some(query) = query.map(str::trim).filter(|q| !q.is_empty()) else {
return Target::Latest;
};
if let Some(target) = Self::parse_prefixed(query) {
return target;
}
if let Some(target) = self.remembered_target(query) {
return target;
}
let (target, conclusive) = self.resolve_keyword(query, options).await;
if conclusive {
self.remember_target(query, &target);
}
target
}
async fn archive(
&self,
cache: VideoCache,
target: &Target,
page: u8,
sort: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let Some(path) = target.archive_path() else {
return Ok(vec![]);
};
let sort_val = match sort {
"post_date" | "video_viewed" | "rating" | "duration" | "pseudo_random" => sort,
_ => "post_date",
};
let index = format!("rule34video:{path}:{page}:{sort_val}");
if sort_val != "pseudo_random" {
if let Some((time, items)) = cache.get(&index) {
if time.elapsed().unwrap_or_default().as_secs() < 300 {
return Ok(items.clone());
}
}
}
let mut requester = options.requester.clone().ok_or("Requester missing")?;
let url = format!(
"{}/{path}/?mode=async&function=get_block&block_id=custom_list_videos_common_videos&sort_by={sort_val}&from={page}",
self.url
);
let headers = vec![("Referer".to_string(), format!("{}/{path}/", self.url))];
let stale = || cache.get(&index).map(|(_, items)| items).unwrap_or_default();
let text = match requester.get_probe(&url, headers).await {
Ok(Some(text)) => text,
// A 404 is an unknown archive (or a page past the end).
Ok(None) => return Ok(vec![]),
Err(e) => {
eprintln!("rule34video archive fetch failed for {url}: {e}");
return Ok(stale());
}
};
let video_items = self.get_video_items_from_html(text);
let video_items = self.enrich_items(video_items, requester).await;
if video_items.is_empty() {
return Ok(stale());
}
cache.insert(index, video_items.clone());
Ok(video_items)
}
/// `/search/{q}/` takes dashes for spaces. A raw `/` splits the path (404),
/// `%20` is refused (403) and `&` cuts the query short, so keep only words.
fn search_path_segment(query: &str) -> String {
slug_dashed(query)
.split('-')
.map(|word| utf8_percent_encode(word, NON_ALPHANUMERIC).to_string())
.collect::<Vec<_>>()
.join("-")
}
/// Helper to safely extract a string between two delimiters
fn extract_between<'a>(content: &'a str, start_pat: &str, end_pat: &str) -> Option<&'a str> {
let start_idx = content.find(start_pat)? + start_pat.len();
@@ -152,6 +406,7 @@ impl Rule34videoProvider {
"".to_string()
});
let video_items = self.get_video_items_from_html(text);
let video_items = self.enrich_items(video_items, requester).await;
if !video_items.is_empty() {
cache.insert(index, video_items.clone());
@@ -203,7 +458,7 @@ impl Rule34videoProvider {
let url = format!(
"{}/search/{}/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search&tag_ids=&sort_by={}&from_videos={}&from_albums={}&_={}",
self.url,
query.replace(" ", "-"),
Self::search_path_segment(query),
sort_val,
page,
page,
@@ -224,6 +479,7 @@ impl Rule34videoProvider {
"".to_string()
});
let video_items = self.get_video_items_from_html(text);
let video_items = self.enrich_items(video_items, requester).await;
if !video_items.is_empty() {
cache.insert(index, video_items.clone());
@@ -236,20 +492,193 @@ impl Rule34videoProvider {
}
}
/// Reads a single-quoted JS string value (`key: '...'`) from the
/// `flashvars` object, honouring `\'` escapes.
fn flashvar(block: &str, key: &str) -> Option<String> {
let marker = format!("{}: '", key);
let start = block.find(&marker)? + marker.len();
let mut out = String::new();
let mut chars = block[start..].chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.push(chars.next()?),
'\'' => return Some(out),
other => out.push(other),
}
}
None
}
/// The page URL is no longer resolvable by yt-dlp's Rule34Video
/// extractor ("No video formats found"), so pull the signed mp4 URLs
/// (360p/480p/720p, valid ~12h, no cookie/Referer needed) and the tags
/// straight from the detail page's `flashvars`.
fn parse_detail(html: &str) -> (Vec<VideoFormat>, Vec<String>) {
let Some(start) = html.find("var flashvars") else {
return (vec![], vec![]);
};
let block = &html[start..];
let block = &block[..block.len().min(6000)];
let mut formats: Vec<(u32, VideoFormat)> = Vec::new();
for (url_key, text_key) in [
("video_url", "video_url_text"),
("video_alt_url", "video_alt_url_text"),
("video_alt_url2", "video_alt_url2_text"),
("video_alt_url3", "video_alt_url3_text"),
] {
let Some(url) = Self::flashvar(block, url_key) else {
continue;
};
if !url.starts_with("http") {
continue;
}
let quality = Self::flashvar(block, text_key).unwrap_or_else(|| "auto".to_string());
let height = quality.trim_end_matches('p').parse::<u32>().unwrap_or(0);
let mut format = VideoFormat::new(url, quality.clone(), "mp4".to_string())
.format_id(format!("mp4-{}", quality));
if height > 0 {
format = format.height(height);
}
formats.push((height, format));
}
formats.sort_by(|a, b| b.0.cmp(&a.0));
let mut tags: Vec<String> = Vec::new();
for key in ["video_categories", "video_tags"] {
for tag in Self::flashvar(block, key).unwrap_or_default().split(',') {
let tag = tag.trim();
if !tag.is_empty() && !tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
tags.push(tag.to_string());
}
}
}
(formats.into_iter().map(|(_, f)| f).collect(), tags)
}
/// Artists credited on a video page: `(name, slug)` from the "Artist" group.
fn parse_artists(html: &str) -> Vec<(String, String)> {
let document = Html::parse_document(html);
let Ok(group_sel) = Selector::parse("[data-suggest-type=\"model\"] a[href*=\"/models/\"]")
else {
return vec![];
};
let name_sel = Selector::parse(".name").ok();
let mut artists: Vec<(String, String)> = Vec::new();
for link in document.select(&group_sel) {
let href = link.value().attr("href").unwrap_or_default();
let slug = href
.split("/models/")
.nth(1)
.unwrap_or_default()
.trim_matches('/')
.to_string();
let name = name_sel
.as_ref()
.and_then(|sel| link.select(sel).next())
.map(|n| n.text().collect::<String>())
.unwrap_or_else(|| link.text().collect::<String>());
let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
if slug.is_empty() || name.is_empty() || artists.iter().any(|(_, s)| *s == slug) {
continue;
}
artists.push((name, slug));
}
artists
}
fn cached_detail(&self, item: &mut VideoItem) -> bool {
let Some((time, cached)) = self.details.get(&item.url) else {
return false;
};
let Some(cached) = cached.into_iter().next() else {
return false;
};
if time.elapsed().unwrap_or(DETAIL_TTL) >= DETAIL_TTL {
return false;
}
item.formats = cached.formats;
item.tags = cached.tags;
item.uploader = cached.uploader;
item.uploaderUrl = cached.uploaderUrl;
item.uploaderId = cached.uploaderId;
true
}
async fn enrich_items(&self, items: Vec<VideoItem>, requester: Requester) -> Vec<VideoItem> {
let referer = format!("{}/", self.url);
let base = self.url.clone();
// Once the site rate-limits a detail page, the rest of the batch keeps
// the listing data instead of digging the hole deeper.
let throttled = Arc::new(AtomicBool::new(false));
stream::iter(items.into_iter().map(|mut item| {
let mut requester = requester.clone();
let referer = referer.clone();
let base = base.clone();
let throttled = throttled.clone();
async move {
if item.url.is_empty() || self.cached_detail(&mut item) {
return item;
}
if throttled.load(Ordering::Relaxed) {
return item;
}
// `get_probe` hands challenges to the full fallback chain but
// reports a lasting 429 as an error rather than escalating every
// detail page to FlareSolverr.
let headers = vec![("Referer".to_string(), referer)];
let html = match requester.get_probe(&item.url, headers).await {
Ok(Some(html)) => html,
Ok(None) => return item,
Err(_) => {
throttled.store(true, Ordering::Relaxed);
return item;
}
};
let (formats, mut tags) = Self::parse_detail(&html);
if !formats.is_empty() {
item.formats = Some(formats);
}
let artists = Self::parse_artists(&html);
if let Some((name, slug)) = artists.first() {
item.uploader = Some(name.clone());
item.uploaderUrl = Some(format!("{base}/models/{slug}/"));
item.uploaderId = Some(format!("rule34video:{slug}"));
}
for (name, _) in &artists {
if !tags.iter().any(|t| t.eq_ignore_ascii_case(name)) {
tags.push(name.clone());
}
}
if !tags.is_empty() {
item.tags = Some(tags);
}
if item.formats.is_some() {
self.details.insert(item.url.clone(), vec![item.clone()]);
}
item
}
}))
.buffered(ENRICH_CONCURRENCY)
.collect::<Vec<_>>()
.await
}
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
if html.is_empty() {
return vec![];
}
// Safely isolate the video listing section
let video_listing = match Self::extract_between(
&html,
"id=\"custom_list_videos",
"<div class=\"pagination\"",
) {
Some(content) => content,
None => return vec![],
// Isolate the video listing section. A result that fits on one page has
// no pagination block, so the listing then runs to the end.
let Some(start) = html.find("id=\"custom_list_videos") else {
return vec![];
};
let video_listing = &html[start..];
let video_listing = video_listing
.find("<div class=\"pagination\"")
.map_or(video_listing, |end| &video_listing[..end]);
let mut items = Vec::new();
// Skip the first split result as it's the preamble
@@ -280,15 +709,37 @@ impl Rule34videoProvider {
Self::extract_between(video_segment, "<div class=\"time\">", "<").unwrap_or("0:00");
let duration = parse_time_to_seconds(raw_duration).unwrap_or(0) as u32;
// Views extraction
let views_segment = Self::extract_between(video_segment, "<div class=\"views\">", "<");
let views_count_str = views_segment
.and_then(|s| s.split("</svg>").nth(1))
.unwrap_or("0");
let views = parse_abbreviated_number(views_count_str.trim()).unwrap_or(0);
// Views extraction: `<div class="views video-views-count ..."
// title="Views: 13K">`. The sibling `views` div is the comment
// count, so match the title rather than the bare class.
let views = Self::extract_between(video_segment, "title=\"Views: ", "\"")
.and_then(|v| parse_abbreviated_number(v.trim()))
.or_else(|| {
Self::extract_between(video_segment, "<div class=\"views\">", "</div>")
.map(|s| s.rsplit("</svg>").next().unwrap_or(s))
.and_then(|v| parse_abbreviated_number(v.trim()))
})
.unwrap_or(0);
// Thumbnail extraction
let thumb = Self::extract_between(video_segment, "data-original=\"", "\"")
// Rating extraction: `72% (47)` inside the rating div.
let rating = Self::extract_between(video_segment, "custom-thumbs-up", "</div>")
.and_then(|s| s.rsplit("</svg>").next())
.and_then(|s| s.trim().split('%').next())
.and_then(|s| s.trim().parse::<f32>().ok());
// Thumbnail extraction: the site dropped `data-original`. Lazy
// cards are `<img class="thumb rd-lz" src="data:image/gif..."
// data-rd-src="...">`; eager ones carry the real `src`.
let thumb = Self::extract_between(video_segment, "<img class=\"thumb", ">")
.and_then(|tag| {
Self::extract_between(tag, "data-rd-src=\"", "\"")
.or_else(|| {
Self::extract_between(tag, " src=\"", "\"")
.filter(|src| src.starts_with("http"))
})
.or_else(|| Self::extract_between(tag, "data-rd-jpg=\"", "\""))
})
.or_else(|| Self::extract_between(video_segment, "data-original=\"", "\""))
.unwrap_or("")
.to_string();
@@ -298,10 +749,13 @@ impl Rule34videoProvider {
.unwrap_or("")
.to_string();
items.push(
let mut item =
VideoItem::new(id, title, url, "Rule34video".to_string(), thumb, duration)
.views(views),
);
.views(views);
if let Some(rating) = rating {
item = item.rating(rating);
}
items.push(item);
}
items
}
@@ -321,9 +775,11 @@ impl Provider for Rule34videoProvider {
) -> Vec<VideoItem> {
let page_num = page.parse::<u8>().unwrap_or(1);
let result = match query {
Some(q) => self.query(cache, page_num, &q, &sort, options).await,
None => self.get(cache, page_num, &sort, options).await,
let target = self.resolve_target(query.as_deref(), &options).await;
let result = match &target {
Target::Latest => self.get(cache, page_num, &sort, options).await,
Target::Search(q) => self.query(cache, page_num, q, &sort, options).await,
archive => self.archive(cache, archive, page_num, &sort, options).await,
};
match result {
@@ -339,3 +795,131 @@ impl Provider for Rule34videoProvider {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_card_thumb_from_img_src_and_skips_ads() {
let html = r#"<div id="custom_list_videos_x"><div class="item thumb video_1 " data-video-card-id="42">
<a class="th js-open-popup" href="https://rule34video.com/video/42/some-title/" title="Some Title">
<div class="img wrap_image"><img class="thumb" src="https://rule34video.com/contents/videos_screenshots/0/42/336x189/3.jpg" data-rd-jpg="x" alt="a">
<div class="time">1:28</div></div><div class="thumb_title">Some Title</div>
<div class="rating"><svg class="custom-svg custom-thumbs-up"></svg> 72% (47) </div>
<div class="views video-comments-count" title="Comments: 0"><svg></svg> 0 </div>
<div class="views video-views-count" title="Views: 6.9K"><svg></svg> 6.9K </div></a></div>
<div class="item thumb video_3"><a class="th js-open-popup" href="https://rule34video.com/video/43/lazy/" title="Lazy">
<div class="img wrap_image"><img class="thumb rd-lz" src="data:image/gif;base64,R0lG" data-rd-src="https://rule34video.com/contents/videos_screenshots/0/43/336x189/3.jpg" alt="b">
<div class="time">0:30</div></div><div class="thumb_title">Lazy</div></a></div>
<div class="item thumb video_2"><a class="th" href="https://ads" title="Advertisement"></a></div>
<div class="pagination" id="p">"#;
let items = Rule34videoProvider::new().get_video_items_from_html(html.to_string());
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "42");
assert_eq!(items[0].views, Some(6900));
assert_eq!(items[0].rating, Some(72.0));
assert_eq!(
items[1].thumb,
"https://rule34video.com/contents/videos_screenshots/0/43/336x189/3.jpg"
);
assert_eq!(
items[0].thumb,
"https://rule34video.com/contents/videos_screenshots/0/42/336x189/3.jpg"
);
assert_eq!(items[0].duration, 88);
}
#[test]
fn parses_flashvars_formats_and_tags() {
let html = r#"var flashvars = { video_id: '1', video_categories: '3D, Cyberpunk 2077', video_tags: 'bondage, panam palmer (cyberpunk), 3d', video_url: 'https://rule34video.com/get_file/a_360.mp4/?v-acctoken=1', video_url_text: '360p', video_alt_url: 'https://rule34video.com/get_file/a_480p.mp4/?v-acctoken=2', video_alt_url_text: '480p', video_alt_url2: 'https://rule34video.com/get_file/a_720p.mp4/?v-acctoken=3', video_alt_url2_text: '720p', video_title: 'It\'s' };"#;
let (formats, tags) = Rule34videoProvider::parse_detail(html);
assert_eq!(formats.len(), 3);
assert!(formats[0].url.contains("a_720p"), "best quality first");
assert!(formats[2].url.contains("a_360"));
assert_eq!(tags, vec!["3D", "Cyberpunk 2077", "bondage", "panam palmer (cyberpunk)"]);
}
#[test]
fn detail_without_flashvars_yields_nothing() {
let (formats, tags) = Rule34videoProvider::parse_detail("<html></html>");
assert!(formats.is_empty() && tags.is_empty());
}
#[test]
fn parses_prefixed_queries() {
assert_eq!(
Rule34videoProvider::parse_prefixed("artist:General Butch"),
Some(Target::Artist("general-butch".into()))
);
assert_eq!(
Rule34videoProvider::parse_prefixed("category:Super Smash Bros"),
Some(Target::Category("super-smash-bros".into()))
);
assert_eq!(
Rule34videoProvider::parse_prefixed("tag:black widow (marvel)"),
Some(Target::Tag("black-widow-marvel".into()))
);
assert_eq!(Rule34videoProvider::parse_prefixed("samus aran"), None);
}
#[test]
fn picks_the_unambiguous_character_tag() {
let titles = vec![
"samus aran (metroid)".to_string(),
"samus aran's mother".to_string(),
];
assert_eq!(
Rule34videoProvider::best_tag_title(&titles, "samus aran").as_deref(),
Some("samus aran (metroid)")
);
let ambiguous = vec!["peach (mario)".to_string(), "peach (pokemon)".to_string()];
assert_eq!(Rule34videoProvider::best_tag_title(&ambiguous, "peach"), None);
}
#[test]
fn parses_tag_suggestions_and_artists() {
let suggest = r#"<div class="item checkbox-container"><input id="tag_143" type="checkbox" value="143" data-title="samus aran (metroid)"/></div>"#;
assert_eq!(
Rule34videoProvider::parse_tag_suggestions(suggest),
vec!["samus aran (metroid)".to_string()]
);
let page = r#"<div class="col vp-group" data-suggest-type="model"><div class="label">Artist</div>
<span class="chip"><a class="item btn_link" href="https://rule34video.com/models/joigang-va/">
<span class="name">JOIGang (VA)</span></a></span></div>"#;
assert_eq!(
Rule34videoProvider::parse_artists(page),
vec![("JOIGang (VA)".to_string(), "joigang-va".to_string())]
);
}
#[test]
fn parses_a_single_page_listing_without_pagination() {
let html = r#"<div id="custom_list_videos_common_videos_items">
<div class="item thumb video_7"><a class="th js-open-popup" href="https://rule34video.com/video/7/elf/">
<img class="thumb" src="https://rule34video.com/t/7.jpg"><div class="time">0:30</div>
<div class="thumb_title">Elf</div></a></div></div></div>"#;
let items = Rule34videoProvider::new().get_video_items_from_html(html.to_string());
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "7");
}
#[test]
fn search_segment_uses_dashes_and_drops_path_breaking_punctuation() {
assert_eq!(Rule34videoProvider::search_path_segment("elf girl"), "elf-girl");
assert_eq!(Rule34videoProvider::search_path_segment("2b/9s"), "2b-9s");
assert_eq!(Rule34videoProvider::search_path_segment("Tifa & Aerith"), "tifa-aerith");
assert_eq!(Rule34videoProvider::search_path_segment("Pokémon"), "pok%C3%A9mon");
}
#[test]
fn remembers_resolved_targets_by_slug() {
let provider = Rule34videoProvider::new();
provider.remember_target("Samus Aran", &Target::Tag("samus-aran-metroid".into()));
assert_eq!(
provider.remembered_target("samus aran"),
Some(Target::Tag("samus-aran-metroid".into()))
);
assert_eq!(provider.remembered_target("samus"), None);
}
}

View File

@@ -1,7 +1,8 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, report_provider_error, report_provider_error_background, requester_or_default,
Provider, probe_first_archive, report_provider_error, report_provider_error_background,
requester_or_default,
};
use crate::status::*;
use crate::util::cache::VideoCache;
@@ -13,6 +14,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, Utc};
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use htmlentity::entity::ICodedDataTrait;
use regex::Regex;
use std::sync::{Arc, RwLock};
use std::time::Duration as StdDuration;
@@ -40,7 +42,6 @@ error_chain! {
}
const BASE_URL: &str = "https://sextb.net";
const MIRROR_PREFIX: &str = "https://r.jina.ai/http://";
const CHANNEL_ID: &str = "sextb";
#[derive(Debug, Clone)]
@@ -242,18 +243,6 @@ impl SextbProvider {
.to_string()
}
fn mirror_url(&self, url: &str) -> String {
// `url` is already an absolute `https://sextb.net/...` URL, and
// MIRROR_PREFIX already supplies a scheme — concatenating them
// directly produces a malformed `.../http://https://...` target
// that Jina rejects, so strip the scheme first.
let stripped = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
format!("{MIRROR_PREFIX}{stripped}")
}
fn slugify(value: &str) -> String {
let mut output = String::new();
let mut needs_dash = false;
@@ -336,35 +325,36 @@ impl SextbProvider {
}
}
async fn fetch_markdown(&self, requester: &mut Requester, url: &str) -> Result<String> {
let mirror_url = self.mirror_url(url);
/// The site serves plain HTML; fetch it directly. (It used to be read
/// through the r.jina.ai markdown mirror, which now answers 403/429.)
async fn fetch_html(&self, requester: &mut Requester, url: &str) -> Result<String> {
requester
.get(&mirror_url, Some(Version::HTTP_11))
.get(url, Some(Version::HTTP_2))
.await
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
}
fn pagination_last_page(markdown: &str) -> Option<u16> {
let regex = Regex::new(r"\[(?P<page>\d+)\]\(https://sextb\.net/[^)]*pg-(?P=page)\)")
.ok()?;
fn pagination_last_page(html: &str) -> Option<u16> {
let regex = Regex::new(r#"/pg-(?P<page>\d+)['"]"#).ok()?;
regex
.captures_iter(markdown)
.captures_iter(html)
.filter_map(|captures| captures.name("page")?.as_str().parse::<u16>().ok())
.max()
}
fn parse_genre_options(markdown: &str) -> Result<Vec<FilterOption>> {
let regex = Self::regex(
r#"(?m)^\*\s+\[(?P<title>[^\]]+)\]\(https://sextb\.net/genre/(?P<slug>[^)\s]+)[^)]*\)\((?P<count>\d+)\)$"#,
)?;
fn collect_options(html: &str, pattern: &str) -> Result<Vec<FilterOption>> {
let regex = Self::regex(pattern)?;
let mut items = Vec::new();
for captures in regex.captures_iter(markdown) {
let title = Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
for captures in regex.captures_iter(html) {
let slug = captures
.name("slug")
.map(|value| value.as_str())
.unwrap_or_default()
.to_string();
.map(|value| value.as_str().trim().to_string())
.unwrap_or_default();
let raw = captures.name("title").map(|value| value.as_str()).unwrap_or_default();
let decoded = htmlentity::entity::decode(raw.as_bytes())
.to_string()
.unwrap_or_else(|_| raw.to_string());
let title = Self::normalize_title(decoded.trim_start_matches('#'));
if title.is_empty() || slug.is_empty() {
continue;
}
@@ -373,46 +363,25 @@ impl SextbProvider {
Ok(items)
}
fn parse_studio_options(markdown: &str) -> Result<Vec<FilterOption>> {
let regex = Self::regex(
r#"(?m)^\[(?P<title>[^\]]+)\]\(https://sextb\.net/studio/(?P<slug>[^)\s]+)[^)]*\)"#,
)?;
let mut items = Vec::new();
for captures in regex.captures_iter(markdown) {
let title =
Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
let slug = captures
.name("slug")
.map(|value| value.as_str())
.unwrap_or_default()
.to_string();
if title.is_empty() || slug.is_empty() {
continue;
}
items.push(FilterOption { id: slug, title });
}
Ok(items)
fn parse_genre_options(html: &str) -> Result<Vec<FilterOption>> {
Self::collect_options(
html,
r#"<li><i class="fas fa-folder"[^>]*></i>\s*<a href="/genre/(?P<slug>[^"?]+)"[^>]*>(?P<title>[^<]+)</a>"#,
)
}
fn parse_actress_options(markdown: &str) -> Result<Vec<FilterOption>> {
let regex = Self::regex(
r#"(?m)^\[\!\[Image \d+: (?P<title>[^\]]+)\]\(https://[^)]+\)\s+(?P=title)\s+[0-9.]+[KM]\s+\d{2}/\d{2}/\d{4}\]\(https://sextb\.net/actress/[^)\s]+\)$"#,
)?;
let mut items = Vec::new();
for captures in regex.captures_iter(markdown) {
let title =
Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
if title.is_empty() {
continue;
}
let slug_source = title.split('(').next().unwrap_or(&title).trim();
let slug = Self::slugify(slug_source);
if slug.is_empty() {
continue;
}
items.push(FilterOption { id: slug, title: slug_source.to_string() });
}
Ok(items)
fn parse_studio_options(html: &str) -> Result<Vec<FilterOption>> {
Self::collect_options(
html,
r#"<div class="tray-item tray-item-list">\s*<a href="/studio/(?P<slug>[^"?]+)"[^>]*>(?:<i[^>]*></i>)?\s*(?P<title>[^<]+)</a>"#,
)
}
fn parse_actress_options(html: &str) -> Result<Vec<FilterOption>> {
Self::collect_options(
html,
r#"<div class="tray-item-actress">\s*<a href="/actress/(?P<slug>[^"?]+)">.*?<div class="tray-item-actress-title">(?P<title>[^<]+)</div>"#,
)
}
async fn load_genres(base_url: &str, genres: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
@@ -423,8 +392,8 @@ impl SextbProvider {
actresses: Arc::new(RwLock::new(vec![])),
};
let mut requester = Requester::new();
let markdown = provider.fetch_markdown(&mut requester, &format!("{base_url}/genres")).await?;
for item in Self::parse_genre_options(&markdown)? {
let html = provider.fetch_html(&mut requester, &format!("{base_url}/genres")).await?;
for item in Self::parse_genre_options(&html)? {
Self::push_unique(&genres, item);
}
Ok(())
@@ -438,9 +407,9 @@ impl SextbProvider {
actresses: Arc::new(RwLock::new(vec![])),
};
let mut requester = Requester::new();
let markdown =
provider.fetch_markdown(&mut requester, &format!("{base_url}/list-studios")).await?;
for item in Self::parse_studio_options(&markdown)? {
let html =
provider.fetch_html(&mut requester, &format!("{base_url}/list-studios")).await?;
for item in Self::parse_studio_options(&html)? {
Self::push_unique(&studios, item);
}
Ok(())
@@ -458,17 +427,17 @@ impl SextbProvider {
};
let mut requester = Requester::new();
let first_page =
provider.fetch_markdown(&mut requester, &format!("{base_url}/list-actress")).await?;
provider.fetch_html(&mut requester, &format!("{base_url}/list-actress")).await?;
for item in Self::parse_actress_options(&first_page)? {
Self::push_unique(&actresses, item);
}
let last_page = Self::pagination_last_page(&first_page).unwrap_or(1).min(80);
for page in 2..=last_page {
let markdown = provider
.fetch_markdown(&mut requester, &format!("{base_url}/list-actress/pg-{page}"))
let html = provider
.fetch_html(&mut requester, &format!("{base_url}/list-actress/pg-{page}"))
.await?;
for item in Self::parse_actress_options(&markdown)? {
for item in Self::parse_actress_options(&html)? {
Self::push_unique(&actresses, item);
}
}
@@ -549,6 +518,28 @@ impl SextbProvider {
Target::Search { slug }
}
/// A free-form query that names an actress or a studio outside the loaded
/// catalogs goes to that archive (probed on the site), else keyword search.
async fn resolve_keyword(&self, slug: &str, options: &ServerOptions) -> Option<Target> {
if slug.is_empty() {
return None;
}
let candidates = [
Target::Actress { slug: slug.to_string() },
Target::Studio { slug: slug.to_string() },
];
let urls: Vec<String> = candidates
.iter()
.map(|t| self.build_url_for_target(t, 1))
.collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
let (index, _) = probe_first_archive(&mut requester, &urls, &format!("{}/", self.url), |body| {
body.contains("tray-item") && !body.contains("No Video were found")
})
.await?;
candidates.into_iter().nth(index)
}
fn build_url_for_target(&self, target: &Target, page: u16) -> String {
match target {
Target::Section { section, sort } => {
@@ -600,120 +591,63 @@ impl SextbProvider {
}
}
fn parse_list_prefix_tags(prefix: &str) -> Vec<String> {
let cleaned = Self::collapse_whitespace(prefix);
if cleaned.is_empty() {
return vec![];
fn parse_list_items(html: &str) -> Result<Vec<ListItemSeed>> {
// A no-hit search still renders a "recommended" tray under the notice.
if html.contains("No Video were found") {
return Ok(vec![]);
}
let bracket_regex = Regex::new(r"\[(?P<tag>[^\]]+)\]").ok();
let mut tags = Vec::new();
if let Some(regex) = bracket_regex {
for captures in regex.captures_iter(&cleaned) {
if let Some(tag) = captures.name("tag").map(|value| value.as_str().trim()) {
if !tag.is_empty() {
tags.push(tag.to_string());
}
}
}
}
let without_brackets = Regex::new(r"\[[^\]]+\]")
.ok()
.map(|regex| regex.replace_all(&cleaned, " ").to_string())
.unwrap_or(cleaned.clone());
for token in without_brackets.split(" ") {
let value = Self::collapse_whitespace(token);
if !value.is_empty() {
tags.push(value);
}
}
tags.sort();
tags.dedup();
tags
}
fn parse_list_items(markdown: &str) -> Result<Vec<ListItemSeed>> {
let regex = Self::regex(
r#"(?s)\[\!\[Image \d+: (?P<alt>.*?)\]\((?P<thumb>https?://[^)]+)\)(?P<body>.*?)\]\((?P<url>https://sextb\.net/[^)]+)\)"#,
)?;
let meta_regex = Self::regex(
r#"(?s)^(?P<head>.*?)\s+(?P<quality>HD|SD)\s+(?P<duration>\d+)\s+min\s+(?P<code>[A-Za-z0-9\-]+)$"#,
)?;
let mut items = Vec::new();
for captures in regex.captures_iter(markdown) {
let url = captures
.name("url")
.map(|value| value.as_str())
.unwrap_or_default()
.to_string();
if !url.starts_with("https://sextb.net/") {
// Cards are `<div class="tray-item">`, optionally with modifiers such
// as `tray-item tray-item-uncensored`; `tray-item-title` etc. must not match.
let card_start = Self::regex(r#"<div class="tray-item(?: [^"]*)?">"#)?;
for card in card_start.split(html).skip(1) {
let Some(href) = Self::attr_between(card, "<a href=\"", "\"") else {
continue;
};
let slug = href.trim_matches('/').to_string();
if slug.is_empty() || slug.contains('/') {
continue;
}
let url = format!("{BASE_URL}/{slug}");
let body = Self::collapse_whitespace(
captures.name("body").map(|value| value.as_str()).unwrap_or_default(),
let title_raw = Self::attr_between(card, "class=\"tray-item-title\">", "</div>")
.or_else(|| Self::attr_between(card, "alt=\"", "\" "))
.unwrap_or_default();
let title = Self::normalize_title(
&htmlentity::entity::decode(title_raw.as_bytes())
.to_string()
.unwrap_or_else(|_| title_raw.to_string()),
);
let meta = match meta_regex.captures(&body) {
Some(meta) => meta,
None => continue,
};
let alt_title = Self::normalize_title(
captures.name("alt").map(|value| value.as_str()).unwrap_or_default(),
);
let head = Self::normalize_title(meta.name("head").map(|value| value.as_str()).unwrap_or_default());
let title = if !alt_title.is_empty() && head.contains(&alt_title) {
alt_title.clone()
} else if !alt_title.is_empty() && !alt_title.eq_ignore_ascii_case("Julia") {
alt_title.clone()
} else {
head.clone()
};
if title.is_empty() {
continue;
}
let prefix = if !alt_title.is_empty() && head.contains(&alt_title) {
let prefix = head.split(&alt_title).next().unwrap_or_default();
prefix.to_string()
} else {
String::new()
};
let slug = url
.trim_start_matches("https://sextb.net/")
.trim_end_matches('/')
.to_string();
if slug.is_empty() {
continue;
}
let thumb = captures
.name("thumb")
.map(|value| value.as_str())
let thumb = Self::attr_between(card, "data-src=\"", "\"")
.or_else(|| Self::attr_between(card, "data-poster=\"", "\""))
.unwrap_or_default()
.to_string();
if thumb.is_empty() {
continue;
}
let quality = meta
.name("quality")
.map(|value| value.as_str().to_string())
.filter(|value| !value.is_empty());
let code = meta
.name("code")
.map(|value| value.as_str().to_string())
.filter(|value| !value.is_empty());
let duration = meta
.name("duration")
.map(|value| Self::parse_duration(value.as_str()))
let quality = if card.contains("class=\"hd\"") {
Some("HD".to_string())
} else if card.contains("class=\"sd\"") {
Some("SD".to_string())
} else {
None
};
let duration = Self::attr_between(card, "class=\"tray-item-runtime\">", "</div>")
.map(|value| value.replace("min", ""))
.map(|value| Self::parse_duration(&value))
.unwrap_or(0);
let code = Self::attr_between(card, "class=\"tray-item-code\">", "</div>")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut tags = Self::parse_list_prefix_tags(&prefix);
let mut tags = Vec::new();
if let Some(value) = quality.clone() {
tags.push(value);
}
@@ -739,13 +673,30 @@ impl SextbProvider {
Ok(items)
}
fn extract_link_texts(line: &str) -> Result<Vec<(String, String)>> {
let regex = Self::regex(r#"\[(?P<title>[^\]]+)\]\((?P<url>https://sextb\.net/[^)\s]+)"#)?;
fn attr_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
let from = text.find(start)? + start.len();
let rest = &text[from..];
Some(&rest[..rest.find(end)?])
}
/// Returns `(title, absolute-url)` for every `<a ...><strong>Title</strong></a>`.
fn extract_link_texts(fragment: &str) -> Result<Vec<(String, String)>> {
let regex = Self::regex(
r#"<a href="(?P<url>[^"]+)"[^>]*>\s*<strong>(?P<title>[^<]+)</strong>\s*</a>"#,
)?;
Ok(regex
.captures_iter(line)
.captures_iter(fragment)
.filter_map(|captures| {
let title = captures.name("title")?.as_str().trim().trim_matches('*').to_string();
let url = captures.name("url")?.as_str().to_string();
let raw = captures.name("title")?.as_str().trim();
let title = htmlentity::entity::decode(raw.as_bytes())
.to_string()
.unwrap_or_else(|_| raw.to_string());
let url = captures.name("url")?.as_str();
let url = if url.starts_with('/') {
format!("{BASE_URL}{url}")
} else {
url.to_string()
};
Some((title, url))
})
.collect())
@@ -757,27 +708,30 @@ impl SextbProvider {
Some(path.trim_start_matches('/').trim_end_matches('/').to_string())
}
fn parse_detail(markdown: &str) -> Result<DetailMetadata> {
fn strong_text(fragment: &str) -> String {
Self::attr_between(fragment, "<strong>", "</strong>")
.map(|value| value.trim().to_string())
.unwrap_or_default()
}
fn parse_detail(html: &str) -> Result<DetailMetadata> {
let mut metadata = DetailMetadata::default();
let aliases_regex = Self::regex(r#"(?m)^# \*\*(?P<body>.+?)\*\*$"#)?;
if let Some(captures) = aliases_regex.captures(markdown) {
let body = captures.name("body").map(|value| value.as_str()).unwrap_or_default();
let link_regex = Self::regex(r#"\[(?P<alias>[^\]]+)\]\(https://sextb\.net/search/[^)]+\)"#)?;
for alias_match in link_regex.captures_iter(body) {
if let Some(alias) = alias_match.name("alias").map(|value| value.as_str().trim()) {
if !alias.is_empty() {
metadata.aliases.push(alias.to_string());
}
}
}
}
let tag_regex = Self::regex(r"<[^>]+>")?;
let line_regex = Self::regex(r#"(?m)^\s*(?P<key>Director|Label|Studio|Cast\(s\)|Genre\(s\)|Quality|Release Date|Runtimes|Added|Viewed|Description):\s*(?P<value>.+)$"#)?;
for captures in line_regex.captures_iter(markdown) {
let key = captures.name("key").map(|value| value.as_str()).unwrap_or_default();
let value = captures.name("value").map(|value| value.as_str()).unwrap_or_default().trim();
match key {
// Info rows are `<div class="description"><i ..></i> Key: <strong>..</strong></div>`.
for block in html.split("<div class=\"description").skip(1) {
let block = block.split("</div>").next().unwrap_or(block);
// Drop the remainder of the opening tag (extra classes, `>`).
let Some((_, block)) = block.split_once('>') else {
continue;
};
let Some((key_part, value)) = block.split_once(':') else {
continue;
};
let key = tag_regex.replace_all(key_part, " ").trim().to_string();
let value = value.trim();
match key.as_str() {
"Director" => {
metadata.director = Self::extract_link_texts(value)?
.first()
@@ -796,71 +750,48 @@ impl SextbProvider {
}
}
"Cast(s)" => {
let mut cast = Vec::new();
for (title, url) in Self::extract_link_texts(value)? {
let slug = Self::detail_link_slug(&url, "actress")
.unwrap_or_else(|| Self::slugify(&title));
cast.push((title, slug));
metadata.cast.push((title, slug));
}
metadata.cast = cast;
}
"Genre(s)" => {
let mut genres = Vec::new();
for (title, url) in Self::extract_link_texts(value)? {
let slug = Self::detail_link_slug(&url, "genre")
.unwrap_or_else(|| Self::slugify(&title));
genres.push((title, slug));
metadata.genres.push((title, slug));
}
metadata.genres = genres;
}
"Quality" => {
let quality = value.trim_matches('*').trim().to_string();
let quality = Self::strong_text(value);
if !quality.is_empty() {
metadata.quality = Some(quality);
}
}
"Release Date" => {
let cleaned = value.trim_matches('*').trim();
metadata.release_date = Self::parse_absolute_date(cleaned);
metadata.release_date = Self::parse_absolute_date(&Self::strong_text(value));
}
"Runtimes" => {}
"Added" => {
let cleaned = value.trim_matches('*').trim();
metadata.added_at = Self::parse_relative_or_date(cleaned);
metadata.added_at = Self::parse_relative_or_date(&Self::strong_text(value));
}
"Viewed" => {
metadata.views = Self::parse_abbrev(value.trim());
let text = tag_regex.replace_all(value, " ").to_string();
metadata.views = Self::parse_abbrev(text.trim());
}
"Description" => {}
_ => {}
}
}
let stats_regex =
Self::regex(r#"(?m)(?P<likes>[0-9.]+[KM]?)\s+Liked(?:\s+(?P<dislikes>[0-9.]+[KM]?)\s+Disliked)?(?:\s+(?P<favorites>[0-9.]+[KM]?)\s+Favorited)?\s+(?P<views>[0-9.]+[KM]?)\s+Views"#)?;
if let Some(captures) = stats_regex.captures(markdown) {
if metadata.views.is_none() {
metadata.views = captures
.name("views")
.and_then(|value| Self::parse_abbrev(value.as_str()));
}
if let (Some(likes), Some(dislikes)) = (
captures
.name("likes")
.and_then(|value| Self::parse_abbrev(value.as_str())),
captures
.name("dislikes")
.and_then(|value| Self::parse_abbrev(value.as_str())),
) {
let total = likes + dislikes;
if total > 0 {
metadata.rating = Some((likes as f32 / total as f32) * 100.0);
}
}
if metadata.views.is_none() {
let views_regex = Self::regex(r"(?P<views>[0-9][0-9.,]*[KM]?)\s+Views")?;
metadata.views = views_regex
.captures(html)
.and_then(|captures| captures.name("views"))
.and_then(|value| Self::parse_abbrev(value.as_str()));
}
metadata.aliases.sort();
metadata.aliases.dedup();
// Like/dislike counts are filled in client-side, so no rating here.
Ok(metadata)
}
@@ -1078,12 +1009,12 @@ impl SextbProvider {
let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_seed");
let detail_fetch = timeout(
StdDuration::from_secs(10),
self.fetch_markdown(&mut requester, &seed.url),
self.fetch_html(&mut requester, &seed.url),
)
.await;
let mut item = match detail_fetch {
Ok(Ok(markdown)) => match Self::parse_detail(&markdown) {
Ok(Ok(html)) => match Self::parse_detail(&html) {
Ok(detail) => self.apply_detail_metadata(seed.clone(), detail),
Err(error) => {
report_provider_error_background(CHANNEL_ID, "parse_detail", &error.to_string());
@@ -1130,14 +1061,14 @@ impl SextbProvider {
}
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_items_for_url");
let markdown = timeout(
let html = timeout(
StdDuration::from_secs(12),
self.fetch_markdown(&mut requester, &url),
self.fetch_html(&mut requester, &url),
)
.await
.map_err(|_| Error::from(format!("list request timed out for {url}")))??;
let seeds = Self::parse_list_items(&markdown)?
let seeds = Self::parse_list_items(&html)?
.into_iter()
.take(per_page_limit.max(1))
.collect::<Vec<_>>();
@@ -1184,7 +1115,12 @@ impl SextbProvider {
per_page_limit: usize,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let target = self.resolve_query_target(query, sort);
let mut target = self.resolve_query_target(query, sort);
if let Target::Search { slug } = &target {
if let Some(found) = self.resolve_keyword(slug, &options).await {
target = found;
}
}
let url = self.build_url_for_target(&target, page);
self.fetch_items_for_url(cache, url, per_page_limit, &options)
.await
@@ -1266,15 +1202,6 @@ mod tests {
);
}
#[test]
fn builds_jina_mirror_url_without_double_scheme() {
let url = provider().mirror_url("https://sextb.net/censored?genre=all");
assert_eq!(
url,
"https://r.jina.ai/http://sextb.net/censored?genre=all"
);
}
#[test]
fn builds_search_page_two_url() {
let url = provider().build_url_for_target(
@@ -1308,33 +1235,81 @@ mod tests {
#[test]
fn parses_detail_metadata_block() {
let markdown = r#"
57 Liked 16 Disliked 88 Favorited 45.2K Views
let html = r#"
<div class="col-7">
<div class="description"> <i class="fa fa-user"></i> Director: <a href="/director/himurokku" title="Himurokku"><strong>Himurokku</strong></a> <i class="fas fa-heart favorite-director" data-id="1"></i> </div>
<div class="description"> <i class="fa fa-tag"></i> Label: <a href="/label/madonna-x1" title="Madonna"><strong>Madonna</strong></a> </div>
<div class="description"> <i class="fa fa-camera"></i> Studio: <a href="/studio/madonna-abc123" title="MADONNA"><strong>Madonna</strong></a> </div>
<div class="description"> <i class="fa fa-users"></i> Cast(s): <a href="/actress/miyuu-imai" title="Miyuu Imai"><strong>Miyuu Imai</strong></a> <i class="fas fa-heart"></i> </div>
<div class="description"> <i class="fa fa-list"></i> Genre(s): <a href="/genre/big-tits-aa11" title="Big Tits"><strong>Big Tits</strong></a>, <a href="/genre/creampie-bb22" title="Creampie"><strong>Creampie</strong></a> </div>
<div class="description"><i class="fa fa-film"></i> Quality: <strong>HD</strong></div>
<div class="description"><i class="fa fa-calendar"></i> Release Date: <strong>Mar. 24, 2026</strong></div>
<div class="description"><i class="fa fa-calendar"></i> Added: <strong>2 days ago</strong></div>
<div class="description visible-sm"><i class="fa fa-eye"></i> Viewed: 45.2K</div>
</div>"#;
Director: [**Himurokku**](https://sextb.net/director/himurokku "Himurokku")
Label: [**Madonna**](https://sextb.net/label/madonna "Madonna")
Studio: [**Madonna**](https://sextb.net/studio/madonna "MADONNA")
Cast(s): [**Miyuu Imai**](https://sextb.net/actress/miyuu-imai "Miyuu Imai")
Genre(s): [**Big Tits**](https://sextb.net/genre/big-tits "Big Tits"), [**Creampie**](https://sextb.net/genre/creampie "Creampie")
Quality: **HD**
Release Date: **Mar. 24, 2026**
Added: **2 days ago**
Viewed: 45.2K
"#;
let detail = SextbProvider::parse_detail(markdown).expect("detail should parse");
let detail = SextbProvider::parse_detail(html).expect("detail should parse");
assert_eq!(detail.director.as_deref(), Some("Himurokku"));
assert_eq!(detail.label.as_deref(), Some("Madonna"));
assert_eq!(detail.studio.as_ref().map(|value| value.0.as_str()), Some("Madonna"));
assert_eq!(detail.cast.len(), 1);
assert_eq!(
detail.studio,
Some(("Madonna".to_string(), "madonna-abc123".to_string()))
);
assert_eq!(detail.cast, vec![("Miyuu Imai".to_string(), "miyuu-imai".to_string())]);
assert_eq!(detail.genres.len(), 2);
assert_eq!(detail.genres[0].1, "big-tits-aa11");
assert_eq!(detail.views, Some(45200));
assert!(detail.rating.unwrap_or_default() > 70.0);
assert_eq!(detail.quality.as_deref(), Some("HD"));
assert!(detail.release_date.is_some());
assert!(detail.added_at.is_some());
}
#[test]
fn parses_listing_cards() {
let html = r#"<div class="tray-content"><div class="tray-item"> <a href="/beb-036"> <img class="tray-item-thumbnail lazy" src="/images/default-cover.jpg" data-src="https://cdn001.imggle.net/webp/poster/a.webp" alt="Alt"> <div class="tray-item-description"> <div class="tray-item-title">Hara &amp; Museum</div> <div class="tray-item-meta-info"> <div class="tray-item-quality"><span class="hd">HD</span></div> <div class="tray-item-runtime">120 min</div> <div class="tray-item-code">BEB-036</div> </div> </div> </a> <div class="tray-item-trailer" data-url="x" data-link="/beb-036" data-poster="https://cdn001.imggle.net/thumb/a.jpg"></div> </div>
<div class="tray-item tray-item-uncensored"> <a href="/akdl-234"> <img data-src="https://cdn001.imggle.net/webp/poster/b.webp" alt="B"> <div class="tray-item-title">Second</div><div class="tray-item-runtime">150 min</div><div class="tray-item-code">AKDL-234</div> </a> </div></div>"#;
let items = SextbProvider::parse_list_items(html).expect("listing should parse");
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "beb-036");
assert_eq!(items[0].url, "https://sextb.net/beb-036");
assert_eq!(items[0].title, "Hara & Museum");
assert_eq!(items[0].duration, 7200);
assert_eq!(items[0].quality.as_deref(), Some("HD"));
assert_eq!(items[0].code.as_deref(), Some("BEB-036"));
assert_eq!(items[0].thumb, "https://cdn001.imggle.net/webp/poster/a.webp");
assert_eq!(items[1].duration, 9000);
}
#[test]
fn empty_search_result_ignores_recommendation_tray() {
let html = r#"<div class="tray-content"><h3 class="notice msg-result">Sorry, No Video were found that matched your search query.</h3>
<div class="tray-item"> <a href="/abc-001"> <img data-src="https://x/a.webp"> <div class="tray-item-title">Rec</div></a></div></div>"#;
assert!(SextbProvider::parse_list_items(html).unwrap().is_empty());
}
#[test]
fn parses_filter_option_lists() {
let genres = r#"<li><i class="fas fa-folder" style="x"></i> <a href="/genre/30-years-old-7c1gtr1u" title="Genre 30 Years Old">30 Years Old</a> <span>(1138)</span></li>"#;
let g = SextbProvider::parse_genre_options(genres).unwrap();
assert_eq!(g[0].id, "30-years-old-7c1gtr1u");
assert_eq!(g[0].title, "30 Years Old");
let studios = r##"<div class="tray-item tray-item-list"> <a href="/studio/only-amateurs-29p3k0ax" title="Label"><i class="fas fa-folder-open"></i> #only amateurs</a> <span class="total">13</span> </div>"##;
let s = SextbProvider::parse_studio_options(studios).unwrap();
assert_eq!(s[0].id, "only-amateurs-29p3k0ax");
assert_eq!(s[0].title, "only amateurs");
let actresses = r#"<div class="tray-item-actress"> <a href="/actress/th1bdtq81"> <img alt="Yumi"> <div class="tray-item-actress-description"> <div class="tray-item-actress-title">Yumi Kazama</div> </div></a></div>"#;
let a = SextbProvider::parse_actress_options(actresses).unwrap();
assert_eq!(a[0].id, "th1bdtq81");
assert_eq!(a[0].title, "Yumi Kazama");
assert_eq!(
SextbProvider::pagination_last_page("<a href='/list-actress/pg-2'>2</a><a href='/list-actress/pg-73'>73</a>"),
Some(73)
);
}
#[test]
fn parses_dd_player_buttons_from_detail_html() {
let html = r#"

View File

@@ -406,6 +406,53 @@ impl ThaipornTvProvider {
Ok(formats)
}
/// Current player attribute: `data-video-sources` holds the base64 of a
/// JSON array (`[{"quality":"720p","url":"..."}]`) written backwards.
/// Falls back to the older XOR-keyed `data-enc` payload.
fn decode_video_sources(encoded: &str) -> Result<Vec<VideoFormat>> {
let reversed: String = encoded.trim().chars().rev().collect();
let padding = (4 - reversed.len() % 4) % 4;
let padded = format!("{}{}", reversed, "=".repeat(padding));
let parsed = general_purpose::STANDARD
.decode(&padded)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|json| json.as_array().cloned());
let Some(entries) = parsed else {
return Self::decode_data_enc(encoded);
};
let mut formats = Vec::new();
for entry in entries {
let Some(url) = entry
.get("url")
.or_else(|| entry.get("u"))
.and_then(|v| v.as_str())
else {
continue;
};
let quality = entry
.get("quality")
.or_else(|| entry.get("q"))
.and_then(|v| v.as_str())
.unwrap_or("auto");
let mut format = VideoFormat::new(
url.to_string(),
quality.to_string(),
"application/x-mpegURL".to_string(),
)
.format_id(format!("m3u8-{quality}"));
format.add_http_header("Referer".to_string(), BASE_URL.to_string());
formats.push(format);
}
if formats.is_empty() {
return Err(Error::from("no formats in video sources"));
}
Ok(formats)
}
fn parse_card(&self, card: ElementRef<'_>) -> Option<VideoItem> {
// Selectors for the Tailwind-based redesign
let playthumb_sel = Self::selector("a.playthumb").ok()?;
@@ -528,11 +575,15 @@ impl ThaipornTvProvider {
.map_err(|_| Error::from(format!("detail request timed out for {}", item.url)))??;
let document = Html::parse_document(&detail_fetch);
let video_element_selector = Self::selector("video[data-enc]")?;
let video_element_selector = Self::selector("video[data-video-sources], video[data-enc]")?;
if let Some(video_element) = document.select(&video_element_selector).next() {
if let Some(data_enc) = video_element.value().attr("data-enc") {
match Self::decode_data_enc(data_enc) {
let attr = video_element
.value()
.attr("data-video-sources")
.or_else(|| video_element.value().attr("data-enc"));
if let Some(data_enc) = attr {
match Self::decode_video_sources(data_enc) {
Ok(formats) => {
item.formats = Some(formats);
},
@@ -731,6 +782,31 @@ mod tests {
assert_eq!(json["http_headers"]["Referer"], BASE_URL);
}
#[test]
fn decodes_reversed_video_sources() {
use base64::Engine as _;
let json = r#"[{"hd":false,"quality":"480p","url":"https://web.techvids.top/m3u8/x_480p.m3u8"},{"hd":true,"quality":"720p","url":"https://web.techvids.top/m3u8/x_720p.m3u8"}]"#;
let encoded: String = general_purpose::STANDARD
.encode(json)
.trim_end_matches('=')
.chars()
.rev()
.collect();
let formats = ThaipornTvProvider::decode_video_sources(&encoded).unwrap();
assert_eq!(formats.len(), 2);
assert_eq!(formats[1].url, "https://web.techvids.top/m3u8/x_720p.m3u8");
let json = serde_json::to_value(&formats[1]).unwrap();
assert_eq!(json["quality"], "720p");
assert_eq!(json["http_headers"]["Referer"], BASE_URL);
}
#[test]
fn video_sources_falls_back_to_legacy_data_enc() {
let encoded = "C1BwKX4pMSljf397eDEkJHxuaSV_bmhjfWJveCV_ZHskZjh-MyQ6PT4zVD8zO3slZjh-MyknKXopMSk_Mzt7KScpYykxbWpneG52Vg";
let formats = ThaipornTvProvider::decode_video_sources(encoded).unwrap();
assert_eq!(formats[0].url, "https://web.techvids.top/m3u8/1658_480p.m3u8");
}
#[test]
fn parses_duration() {
assert_eq!(parse_duration_mm_ss("50:47"), 3047);

View File

@@ -1,7 +1,8 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, build_proxy_url, report_provider_error, requester_or_default,
Provider, build_proxy_url, probe_first_archive, report_provider_error, requester_or_default,
slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
@@ -367,6 +368,26 @@ impl ThepornbunnyProvider {
}
}
/// A free-form query that names a pornstar, studio or tag goes to that
/// archive (probed on the site; unknown names redirect home), else keyword
/// search.
async fn resolve_keyword(query: &str, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let urls: Vec<String> = ["pornstar", "studio", "tag"]
.iter()
.map(|kind| format!("{BASE_URL}/{kind}/{slug}/"))
.collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
let (index, _) = probe_first_archive(&mut requester, &urls, &format!("{BASE_URL}/"), |body| {
body.contains("th-description")
})
.await?;
Some(Target::Archive(urls[index].trim_end_matches('/').to_string()))
}
fn resolve_option_target(options: &ServerOptions, sort: &str) -> Target {
if let Some(cat) = options.categories.as_deref() {
if cat.starts_with(BASE_URL) && cat != "all" {
@@ -476,10 +497,15 @@ impl Provider for ThepornbunnyProvider {
let page = page.parse::<u16>().unwrap_or(1).max(1);
let per_page_limit = per_page.parse::<usize>().unwrap_or(10).clamp(1, 60);
let target = match query {
let mut target = match query {
Some(ref q) if !q.trim().is_empty() => Self::resolve_query_target(q.trim()),
_ => Self::resolve_option_target(&options, &sort),
};
if let Target::Search(query) = &target {
if let Some(found) = Self::resolve_keyword(query, &options).await {
target = found;
}
}
match self
.fetch_target(cache, target, page, per_page_limit, options)

View File

@@ -1,8 +1,12 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, build_proxy_url, report_provider_error, requester_or_default};
use crate::providers::{
Provider, build_proxy_url, probe_first_archive, proxy_aylo_thumb, report_provider_error,
requester_or_default, slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::requester::Requester;
use crate::util::parse_abbreviated_number;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
@@ -134,6 +138,25 @@ impl Tube8Provider {
]
}
/// A free-form query that names a pornstar or a channel goes to that
/// archive (probed on the site; unknown names 404), else keyword search.
async fn resolve_keyword(&self, requester: &mut Requester, query: &str) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let candidates = [
Target::Pornstar { slug: slug.clone() },
Target::Channel { slug },
];
let urls: Vec<String> = candidates.iter().map(|t| self.build_url(t, 1)).collect();
let (index, _) = probe_first_archive(requester, &urls, &format!("{BASE_URL}/"), |body| {
body.contains("video-box")
})
.await?;
candidates.into_iter().nth(index)
}
fn target_from_request(query: Option<&str>, sort: &str) -> Target {
if let Some(q) = query.map(str::trim).filter(|v| !v.is_empty()) {
let lower = q.to_ascii_lowercase();
@@ -257,11 +280,24 @@ impl Tube8Provider {
let mut items = Vec::new();
for card in document.select(&card_sel) {
let id = card
.value()
.attr("data-video-id")
.map(|v| v.to_string())
.filter(|v| !v.is_empty())
// The card's own link is authoritative: sponsored `channel` cards
// carry a `data-video-id` that 404s while the link target
// (`/porn-video/<other id>/`) is the live page.
let link_id = link_sel
.as_ref()
.and_then(|s| card.select(s).next())
.and_then(|v| v.value().attr("href"))
.and_then(|href| href.split("/porn-video/").nth(1))
.and_then(|rest| rest.split('/').next())
.filter(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()))
.map(|v| v.to_string());
let id = link_id
.or_else(|| {
card.value()
.attr("data-video-id")
.map(|v| v.to_string())
.filter(|v| !v.is_empty())
})
.unwrap_or_default();
if id.is_empty() {
continue;
@@ -296,6 +332,7 @@ impl Tube8Provider {
.or_else(|| v.value().attr("src"))
})
.map(|v| self.normalize_url(v))
.map(|v| proxy_aylo_thumb(options, &v))
.unwrap_or_default();
// Preview flipbook clip from the thumbnail link's data-mediabook
@@ -423,7 +460,13 @@ impl Provider for Tube8Provider {
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u16>().unwrap_or(1).max(1);
let target = Self::target_from_request(query.as_deref(), &sort);
let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos");
let mut target = Self::target_from_request(query.as_deref(), &sort);
if let Target::Search { query } = &target {
if let Some(found) = self.resolve_keyword(&mut requester, query).await {
target = found;
}
}
let url = self.build_url(&target, page);
let old_items = match cache.get(&url) {
@@ -434,7 +477,6 @@ impl Provider for Tube8Provider {
None => vec![],
};
let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos");
let text = match requester
.get_with_headers(&url, Self::html_headers(), None)
.await
@@ -536,6 +578,27 @@ mod tests {
);
}
#[test]
fn uses_link_target_id_when_card_video_id_is_stale() {
let p = provider();
let html = r#"
<article class="video-box pc js_video-box js-pop" data-video-id="170549881"
data-uploader-type="channel" aria-label="Sponsored clip">
<a href="/porn-video/67452091/" data-testid="plw_video_thumbnail_link">
<img class="thumb-image js_lazy" data-src="https://ei-ph.t8cdn.com/t.jpg"/>
</a>
</article>"#;
let opts = ServerOptions {
client_version: None, featured: None, category: None, sites: None, filter: None,
language: None, public_url_base: None, requester: None, network: None, stars: None,
categories: None, duration: None, sort: None, sexuality: None,
};
let items = p.parse_items(html, &opts);
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "67452091");
assert_eq!(items[0].url, "https://www.tube8.com/porn-video/67452091/");
}
#[test]
fn parses_video_cards() {
let p = provider();

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

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

View File

@@ -1568,11 +1568,28 @@ impl Provider for VjavProvider {
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let target = self.pick_target(normalized_query.as_deref(), &options).await;
// A generic keyword can exactly match a category or member name
// ("girl" is a member with no uploads), which would hijack the search
// and return nothing. Remember to fall back to keyword search then.
let keyword_fallback = normalized_query
.as_ref()
.filter(|_| matches!(target, Target::Uploader { .. } | Target::Tag { .. }))
.map(|query| Target::Search {
query: query.clone(),
});
match self
let result = self
.fetch_target_items(target, &sort, page, per_page, &options)
.await
{
.await;
let result = match (result, keyword_fallback) {
(Ok(items), Some(fallback)) if items.is_empty() => {
self.fetch_target_items(fallback, &sort, page, per_page, &options)
.await
}
(result, _) => result,
};
match result {
Ok(items) => items,
Err(error) => {
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;

View File

@@ -1,6 +1,8 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{
Provider, probe_first_archive, report_provider_error, requester_or_default, slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoItem};
@@ -426,6 +428,23 @@ impl XgroovyProvider {
Target::Search(trimmed.to_string())
}
/// A free-form query that names a pornstar or a channel goes to that
/// archive (probed on the site; unknown names 404), else keyword search.
async fn resolve_keyword(query: &str, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let candidates = [Target::Pornstar(slug.clone()), Target::Channel(slug)];
let urls: Vec<String> = candidates.iter().map(|t| Self::target_url(t, 1)).collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
let (index, _) = probe_first_archive(&mut requester, &urls, &format!("{BASE_URL}/"), |body| {
body.contains("data-video-id")
})
.await?;
candidates.into_iter().nth(index)
}
fn resolve_option_target(&self, options: &ServerOptions) -> Target {
if let Some(cat) = options.categories.as_deref() {
if cat != "all" && !cat.is_empty() {
@@ -488,10 +507,15 @@ impl Provider for XgroovyProvider {
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 {
let mut target = match query {
Some(q) if !q.trim().is_empty() => self.resolve_query_target(q.trim()),
_ => self.resolve_option_target(&options),
};
if let Target::Search(query) = &target {
if let Some(found) = Self::resolve_keyword(query, &options).await {
target = found;
}
}
match self
.fetch_target(cache, target, page, per_page, options)

View File

@@ -1,6 +1,8 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{
Provider, probe_first_archive, report_provider_error, requester_or_default, slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::parse_abbreviated_number;
@@ -94,6 +96,7 @@ enum Target {
Search(String),
Category(String),
Channel(String),
Pornstar(String),
}
#[derive(Debug, Clone)]
@@ -208,6 +211,7 @@ impl XhamsterProvider {
}
Target::Category(slug) => format!("{BASE_URL}/categories/{slug}"),
Target::Channel(slug) => format!("{BASE_URL}/channels/{slug}"),
Target::Pornstar(slug) => format!("{BASE_URL}/pornstars/{slug}"),
};
if page <= 1 {
base
@@ -385,6 +389,7 @@ impl XhamsterProvider {
match kind.trim().to_ascii_lowercase().as_str() {
"cat" | "category" => return Target::Category(slug),
"channel" | "channels" => return Target::Channel(slug),
"pornstar" | "pornstars" | "star" | "model" => return Target::Pornstar(slug),
_ => {}
}
}
@@ -401,6 +406,23 @@ impl XhamsterProvider {
Target::Search(trimmed.to_string())
}
/// A free-form query that names a pornstar or a channel goes to that
/// archive (probed on the site; unknown names 404), else keyword search.
async fn resolve_keyword(query: &str, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let candidates = [Target::Pornstar(slug.clone()), Target::Channel(slug)];
let urls: Vec<String> = candidates.iter().map(|t| Self::target_url(t, 1)).collect();
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
let (index, _) = probe_first_archive(&mut requester, &urls, &format!("{BASE_URL}/"), |body| {
body.contains("video-thumb-info__name")
})
.await?;
candidates.into_iter().nth(index)
}
fn resolve_sort_target(sort: &str) -> Target {
match sort.trim().to_ascii_lowercase().as_str() {
"popular" | "viewed" | "most_viewed" | "mostviewed" => Target::MostViewed,
@@ -472,10 +494,15 @@ impl Provider for XhamsterProvider {
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 {
let mut target = match query {
Some(q) if !q.trim().is_empty() => self.resolve_query_target(q.trim()),
_ => self.resolve_option_target(&options, &sort),
};
if let Target::Search(query) = &target {
if let Some(found) = Self::resolve_keyword(query, &options).await {
target = found;
}
}
match self
.fetch_target(cache, target, page, per_page, options)

View File

@@ -1,6 +1,8 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{
Provider, probe_first_archive, report_provider_error, requester_or_default, slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::parse_abbreviated_number;
@@ -86,6 +88,8 @@ enum Target {
Best,
Search(String),
Archive(String),
/// Channel / pornstar profile, served as JSON by `/channels/{slug}/videos/new/{n}`.
Channel(String),
}
#[derive(Debug, Clone)]
@@ -238,6 +242,9 @@ impl XvideosProvider {
format!("{BASE_URL}/?k={encoded}&p={}", page - 1)
}
}
Target::Channel(slug) => {
format!("{BASE_URL}/channels/{slug}/videos/new/{}", page.saturating_sub(1))
}
Target::Archive(base_url) => {
let base = base_url.trim_end_matches('/');
if page <= 1 {
@@ -528,9 +535,8 @@ impl XvideosProvider {
let slug = value.replace(' ', "_");
return Target::Archive(format!("{BASE_URL}/c/{slug}"));
}
"uploader" | "channel" | "profile" => {
let slug = value.replace(' ', "_").to_ascii_lowercase();
return Target::Archive(format!("{BASE_URL}/{slug}"));
"uploader" | "channel" | "profile" | "pornstar" | "star" | "model" => {
return Target::Channel(slug_dashed(value));
}
_ => {}
}
@@ -545,6 +551,83 @@ impl XvideosProvider {
Target::Search(trimmed.to_string())
}
/// A free-form query that names a channel / pornstar profile goes to its
/// archive (probed on the site; unknown names land on an empty profile),
/// else keyword search.
async fn resolve_keyword(query: &str, options: &ServerOptions) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let target = Target::Channel(slug);
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
probe_first_archive(
&mut requester,
&[Self::target_url(&target, 1)],
&format!("{BASE_URL}/"),
|body| body.contains("\"videos\":[{"),
)
.await
.map(|_| target)
}
/// Parses the JSON served for a channel / profile video list.
fn parse_channel_json(json: &str, limit: usize) -> Result<Vec<VideoItem>> {
let root: serde_json::Value = serde_json::from_str(json)
.map_err(|e| Error::from(format!("channel json: {e}")))?;
let Some(videos) = root.get("videos").and_then(|v| v.as_array()) else {
return Ok(vec![]);
};
let text = |v: &serde_json::Value, key: &str| -> String {
v.get(key).and_then(|x| x.as_str()).unwrap_or_default().trim().to_string()
};
let mut items = Vec::new();
for video in videos {
let eid = text(video, "eid");
let thumb = text(video, "i");
let title = {
let full = text(video, "tf");
if full.is_empty() { text(video, "t") } else { full }
};
if eid.is_empty() || thumb.is_empty() || title.is_empty() {
continue;
}
let id = video
.get("id")
.map(|x| x.to_string().trim_matches('"').to_string())
.unwrap_or_else(|| eid.clone());
let mut item = VideoItem::new(
id,
Self::decode_html(&title),
format!("{BASE_URL}/video.{eid}/x"),
CHANNEL_ID.to_string(),
thumb,
Self::parse_duration(&text(video, "d")),
);
item.views = Self::parse_views(&text(video, "n"));
item.rating = text(video, "r").trim_end_matches('%').parse::<f32>().ok();
let preview = text(video, "ipu");
if !preview.is_empty() {
item.preview = Some(preview);
}
let name = text(video, "pn");
let slug = text(video, "pu");
if !name.is_empty() {
item.uploader = Some(name);
let slug = slug.trim_matches('/').to_string();
if !slug.is_empty() {
item.uploaderUrl = Some(format!("{BASE_URL}/{slug}"));
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
}
}
items.push(item);
if items.len() >= limit {
break;
}
}
Ok(items)
}
fn resolve_option_target(options: &ServerOptions, sort: &str) -> Target {
if let Some(cat) = options.categories.as_deref() {
if cat != "all" && !cat.is_empty() {
@@ -602,7 +685,11 @@ impl XvideosProvider {
return Ok(vec![]);
}
let items = Self::parse_listing(&html, per_page)?;
let items = if matches!(target, Target::Channel(_)) {
Self::parse_channel_json(&html, per_page)?
} else {
Self::parse_listing(&html, per_page)?
};
if !items.is_empty() {
cache.insert(cache_key, items.clone());
}
@@ -625,10 +712,15 @@ impl Provider for XvideosProvider {
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 {
let mut target = match query {
Some(q) if !q.trim().is_empty() => Self::resolve_query_target(q.trim()),
_ => Self::resolve_option_target(&options, &sort),
};
if let Target::Search(query) = &target {
if let Some(found) = Self::resolve_keyword(query, &options).await {
target = found;
}
}
match self
.fetch_target(cache, target, page, per_page, options)

View File

@@ -52,6 +52,23 @@ impl XxdbxProvider {
provider
}
/// Resolves the card's asset URLs, which the site serves protocol-relative
/// (`//prev.xxdbx.com/..`) or, now, site-relative (`/1234.jpg`).
fn absolute_url(raw: &str) -> String {
let raw = raw.trim();
if raw.is_empty() {
String::new()
} else if raw.starts_with("http://") || raw.starts_with("https://") {
raw.to_string()
} else if let Some(rest) = raw.strip_prefix("//") {
format!("https://{rest}")
} else if raw.starts_with('/') {
format!("https://xxdbx.com{raw}")
} else {
format!("https://xxdbx.com/{raw}")
}
}
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
let _ = clientversion;
Channel {
@@ -272,26 +289,24 @@ impl XxdbxProvider {
.unwrap_or_default()
.to_string();
let thumb = format!(
"https:{}",
video_segment
.split("<img ")
.collect::<Vec<&str>>()
.get(1)
.copied()
.unwrap_or_default()
.split("src=\"")
.collect::<Vec<&str>>()
.last()
.copied()
.unwrap_or_default()
.split("\"")
.collect::<Vec<&str>>()
.get(0)
.copied()
.unwrap_or_default()
.to_string()
);
let thumb_raw = video_segment
.split("<img ")
.collect::<Vec<&str>>()
.get(1)
.copied()
.unwrap_or_default()
.split("src=\"")
.collect::<Vec<&str>>()
.last()
.copied()
.unwrap_or_default()
.split("\"")
.collect::<Vec<&str>>()
.get(0)
.copied()
.unwrap_or_default()
.to_string();
let thumb = Self::absolute_url(&thumb_raw);
let raw_duration = video_segment
.split("<div class=\"v_dur\">")
.collect::<Vec<&str>>()
@@ -306,21 +321,19 @@ impl XxdbxProvider {
.to_string();
let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32;
let preview = format!(
"https:{}",
video_segment
.split("data-preview=\"")
.collect::<Vec<&str>>()
.get(1)
.copied()
.unwrap_or_default()
.split("\"")
.collect::<Vec<&str>>()
.get(0)
.copied()
.unwrap_or_default()
.to_string()
);
let preview_raw = video_segment
.split("data-preview=\"")
.collect::<Vec<&str>>()
.get(1)
.copied()
.unwrap_or_default()
.split("\"")
.collect::<Vec<&str>>()
.get(0)
.copied()
.unwrap_or_default()
.to_string();
let preview = Self::absolute_url(&preview_raw);
let tags = video_segment
.split("<div class=\"v_tags\">")
.collect::<Vec<&str>>()
@@ -439,3 +452,25 @@ impl Provider for XxdbxProvider {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::XxdbxProvider;
#[test]
fn resolves_site_relative_protocol_relative_and_absolute_assets() {
assert_eq!(
XxdbxProvider::absolute_url("/7004779764736.jpg"),
"https://xxdbx.com/7004779764736.jpg"
);
assert_eq!(
XxdbxProvider::absolute_url("//prev.xxdbx.com/700477973230.mp4"),
"https://prev.xxdbx.com/700477973230.mp4"
);
assert_eq!(
XxdbxProvider::absolute_url("https://cdn.example/a.jpg"),
"https://cdn.example/a.jpg"
);
assert_eq!(XxdbxProvider::absolute_url(""), "");
}
}

View File

@@ -30,6 +30,7 @@ const RESOLVE_CONCURRENCY: usize = 8;
// xxxtik's listing endpoints are cursor-based (the numeric `id` of the last item
// returned), not page-number based, so reaching page N requires walking N
// sequential requests from the start of the feed.
const MAX_REFILL_FETCHES: usize = 4;
const MAX_PAGE_WALK: u16 = 25;
// xxxtik exposes ~60k tags through /tag/options, far too many to background-load
@@ -423,18 +424,43 @@ impl XxxtikProvider {
let mut items = Vec::new();
for _ in 0..page {
let batch = self.fetch_one_page(path, cursor, per_page, options).await?;
if batch.is_empty() {
// Tag and creator pages mix in posts whose source is not RedGifs
// (erome links, empty sources); those cannot be resolved here and
// used to leave whole pages empty ("girl" returned nothing). Keep
// pulling raw batches, bounded, until a page has enough
// resolvable posts.
let mut collected: Vec<Post> = Vec::new();
let mut exhausted = false;
for _ in 0..MAX_REFILL_FETCHES {
let batch = self.fetch_one_page(path, cursor, per_page, options).await?;
if batch.is_empty() {
exhausted = true;
break;
}
cursor = batch.last().map(|post| post.id).unwrap_or(cursor);
let short_batch = batch.len() < per_page;
collected.extend(batch.into_iter().filter(Self::is_redgifs_post));
if collected.len() >= per_page || short_batch {
break;
}
}
if exhausted && collected.is_empty() {
items = Vec::new();
break;
}
cursor = batch.last().map(|post| post.id).unwrap_or(cursor);
items = batch;
items = collected;
}
Ok(items)
}
fn is_redgifs_post(post: &Post) -> bool {
post.source
.as_deref()
.map(|source| source.contains("redgifs.com/"))
.unwrap_or(false)
}
async fn fetch_target_posts(
&self,
target: &Target,
@@ -791,6 +817,25 @@ impl Provider for XxxtikProvider {
#[cfg(test)]
mod tests {
#[test]
fn only_redgifs_sources_are_resolvable() {
let mk = |source: Option<&str>| super::Post {
id: 1,
uuid: "u".to_string(),
description: None,
source: source.map(str::to_string),
status: None,
created_at: String::new(),
author: Default::default(),
tags: vec![],
};
assert!(XxxtikProvider::is_redgifs_post(&mk(Some("https://www.redgifs.com/watch/abc"))));
assert!(XxxtikProvider::is_redgifs_post(&mk(Some("https://redgifs.com/watch/abc"))));
assert!(!XxxtikProvider::is_redgifs_post(&mk(Some("https://www.erome.com/a/CE5z46ne#39"))));
assert!(!XxxtikProvider::is_redgifs_post(&mk(Some(""))));
assert!(!XxxtikProvider::is_redgifs_post(&mk(None)));
}
use super::*;
#[test]

View File

@@ -1,8 +1,11 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::providers::{
Provider, probe_first_archive, report_provider_error, requester_or_default, slug_dashed,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::requester::Requester;
use crate::util::parse_abbreviated_number;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoItem};
@@ -248,6 +251,33 @@ impl YoupornProvider {
}
}
/// A free-form query that names a pornstar, channel or amateur goes to that
/// archive (probed on the site; unknown names 404), else keyword search.
async fn resolve_keyword(
&self,
requester: &mut Requester,
query: &str,
sort: &str,
) -> Option<Target> {
let slug = slug_dashed(query);
if slug.is_empty() {
return None;
}
let sort = Self::normalized_sort(sort).to_string();
let candidates = [
Target::Pornstar { slug: slug.clone(), sort: sort.clone() },
Target::Channel { slug: slug.clone(), sort: sort.clone() },
Target::Amateur { slug, sort },
];
let urls: Vec<String> = candidates.iter().map(|t| self.build_url(t, 1)).collect();
let referer = format!("{}/", self.url.trim_end_matches('/'));
let (index, _) = probe_first_archive(requester, &urls, &referer, |body| {
body.contains("js_video-box")
})
.await?;
candidates.into_iter().nth(index)
}
fn build_url(&self, target: &Target, page: u8) -> String {
match target {
Target::Latest { sort } => format!(
@@ -543,7 +573,13 @@ impl Provider for YoupornProvider {
) -> Vec<VideoItem> {
let query = query.unwrap_or_default();
let page = page.parse::<u8>().unwrap_or(1);
let target = self.target_from_query(&query, &sort);
let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos");
let mut target = self.target_from_query(&query, &sort);
if let Target::Search { query } = &target {
if let Some(found) = self.resolve_keyword(&mut requester, query, &sort).await {
target = found;
}
}
let video_url = self.build_url(&target, page);
let old_items = match cache.get(&video_url) {
@@ -554,7 +590,6 @@ impl Provider for YoupornProvider {
None => vec![],
};
let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos");
let referer = format!("{}/", self.url.trim_end_matches('/'));
let text = match requester
.get_with_headers(&video_url, Self::html_headers(&referer), Some(Version::HTTP_11))

View File

@@ -229,63 +229,90 @@ impl ArchivebateProxy {
embed_url: &str,
requester: &mut Requester,
) -> Option<String> {
let response = requester
.get_raw_with_headers_timeout(
embed_url,
Self::html_headers(detail_url),
Some(StdDuration::from_secs(8)),
None,
)
.await
.ok()?;
if !response.status().is_success() {
return None;
// The embed host is sometimes slow on a first (cold) request and fast on
// the next, so one timed-out attempt is retried.
for _ in 0..2 {
let Ok(response) = requester
.get_raw_with_headers_timeout(
embed_url,
Self::html_headers(detail_url),
Some(StdDuration::from_secs(10)),
None,
)
.await
else {
continue;
};
if !response.status().is_success() {
return None;
}
let html = response.text().await.ok()?;
return Self::extract_mixdrop_media_url(&html);
}
let html = response.text().await.ok()?;
Self::extract_mixdrop_media_url(&html)
None
}
}
/// A resolved Mixdrop media file plus the embed page it came from (the CDN is
/// played with that page as `Referer`).
#[derive(Debug, Clone)]
pub struct ResolvedMedia {
pub media_url: String,
pub embed_url: String,
}
/// Resolves an archivebate watch page (`https://archivebate.com/watch/{id}`)
/// to the real media URL: watch page -> Mixdrop embed -> unpacked
/// `MDCore.wurl`. Shared by the lazy `/proxy/archivebate` redirect and by the
/// provider, which hands the app the media URL directly.
pub async fn resolve_media(requester: &mut Requester, detail_url: &str) -> Option<ResolvedMedia> {
let detail_html = requester
.get_with_headers(
detail_url,
ArchivebateProxy::html_headers("https://archivebate.com/"),
Some(Version::HTTP_11),
)
.await
.unwrap_or_default();
if detail_html.is_empty() {
return None;
}
let mut embeds: Vec<String> = Vec::new();
if let Some(iframe_url) = ArchivebateProxy::first_iframe_source_from_html(&detail_html)
.map(|value| ArchivebateProxy::absolute_url(&value))
{
if ArchivebateProxy::is_mixdrop_host(&iframe_url) {
embeds.push(iframe_url);
}
}
if let Some(embed_url) = ArchivebateProxy::download_fid_from_detail_html(&detail_html)
.map(|value| ArchivebateProxy::absolute_url(&value))
.and_then(|value| ArchivebateProxy::mixdrop_embed_url_from_download_url(&value))
{
embeds.push(embed_url);
}
for embed_url in embeds {
if let Some(media_url) =
ArchivebateProxy::resolve_mixdrop_media_from_embed(detail_url, &embed_url, requester).await
{
return Some(ResolvedMedia { media_url, embed_url });
}
}
None
}
impl crate::proxies::Proxy for ArchivebateProxy {
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
let Some(detail_url) = Self::normalize_detail_request(&url) else {
return String::new();
};
let mut requester = requester.get_ref().clone();
let detail_html = requester
.get_with_headers(
&detail_url,
Self::html_headers("https://archivebate.com/"),
Some(Version::HTTP_11),
)
resolve_media(&mut requester, &detail_url)
.await
.unwrap_or_default();
if detail_html.is_empty() {
return String::new();
}
if let Some(iframe_url) = Self::first_iframe_source_from_html(&detail_html).map(|value| Self::absolute_url(&value)) {
if Self::is_mixdrop_host(&iframe_url) {
if let Some(media_url) =
Self::resolve_mixdrop_media_from_embed(&detail_url, &iframe_url, &mut requester).await
{
return media_url;
}
}
}
if let Some(download_fid) = Self::download_fid_from_detail_html(&detail_html).map(|value| Self::absolute_url(&value)) {
if let Some(embed_url) = Self::mixdrop_embed_url_from_download_url(&download_fid) {
if let Some(media_url) =
Self::resolve_mixdrop_media_from_embed(&detail_url, &embed_url, &mut requester).await
{
return media_url;
}
}
}
String::new()
.map(|resolved| resolved.media_url)
.unwrap_or_default()
}
}

126
src/proxies/aylothumb.rs Normal file
View File

@@ -0,0 +1,126 @@
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use ntex::{
http::Response,
web::{self, HttpRequest, error},
};
use url::Url;
use crate::util::requester::Requester;
/// Referer the image CDN expects, or `None` when the host is not allowed.
///
/// The signed `pix-cdn77.*` thumbnail hosts used by tube8 and the pornhub
/// shorties feed answer 403 without a matching Referer, so apps that load
/// the URL directly get broken images.
fn referer_for_host(host: &str) -> Option<&'static str> {
let host = host.to_ascii_lowercase();
if host == "t8cdn.com" || host.ends_with(".t8cdn.com") {
Some("https://www.tube8.com/")
} else if host == "phncdn.com" || host.ends_with(".phncdn.com") {
Some("https://www.pornhub.com/")
} else {
None
}
}
fn is_image_path(path: &str) -> bool {
let path = path.to_ascii_lowercase();
// The signed thumbnail paths end in processing options such as
// `/plain/rs:fit:320:180/vts:3`, not a file extension, so accept those too.
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"]
.iter()
.any(|ext| path.ends_with(ext))
|| path.contains("/plain/")
}
fn upstream_url(req: &HttpRequest) -> Option<(String, &'static str)> {
let endpoint = req.match_info().query("endpoint").trim_start_matches('/');
let mut image_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string()
} else {
format!("https://{endpoint}")
};
// The signed URL's own query (`hash`, `validto`) arrives as the request query.
let query = req.query_string();
if !query.is_empty() && !image_url.contains('?') {
image_url.push('?');
image_url.push_str(query);
}
let parsed = Url::parse(&image_url).ok()?;
if parsed.scheme() != "https" {
return None;
}
let referer = referer_for_host(parsed.host_str()?)?;
if !is_image_path(parsed.path()) {
return None;
}
Some((image_url, referer))
}
pub async fn get_image(
req: HttpRequest,
requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let Some((image_url, referer)) = upstream_url(&req) else {
return Ok(web::HttpResponse::BadRequest().finish());
};
let upstream = match requester
.get_ref()
.clone()
.get_raw_with_headers(
image_url.as_str(),
vec![("Referer".to_string(), referer.to_string())],
)
.await
{
Ok(response) => response,
Err(_) => return Ok(web::HttpResponse::NotFound().finish()),
};
let status = upstream.status();
let headers = upstream.headers().clone();
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
let mut resp = Response::build(status);
if let Some(ct) = headers.get(CONTENT_TYPE) {
if let Ok(ct_str) = ct.to_str() {
resp.set_header(CONTENT_TYPE, ct_str);
}
}
if let Some(cl) = headers.get(CONTENT_LENGTH) {
if let Ok(cl_str) = cl.to_str() {
resp.set_header(CONTENT_LENGTH, cl_str);
}
}
Ok(resp.body(bytes.to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_hosts_to_referers_and_rejects_others() {
assert_eq!(
referer_for_host("pix-cdn77.t8cdn.com"),
Some("https://www.tube8.com/")
);
assert_eq!(
referer_for_host("pix-cdn77.phncdn.com"),
Some("https://www.pornhub.com/")
);
assert_eq!(referer_for_host("evil.example.com"), None);
assert_eq!(referer_for_host("t8cdn.com.evil.example"), None);
}
#[test]
fn accepts_signed_processing_paths_and_extensions() {
assert!(is_image_path("/c6531/videos/1/original.mov/plain/rs:fit:320:180/vts:3"));
assert!(is_image_path("/videos/thumb.JPG"));
assert!(!is_image_path("/videos/clip.mp4"));
}
}

View File

@@ -63,6 +63,10 @@ impl DoodstreamProxy {
| "www.streamhg.com"
| "doodstream.com"
| "www.doodstream.com"
| "myvidplay.com"
| "www.myvidplay.com"
| "playmogo.com"
| "www.playmogo.com"
)
}

196
src/proxies/eporner.rs Normal file
View File

@@ -0,0 +1,196 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use futures::StreamExt;
use http_body_util::BodyExt;
use ntex::http::Method;
use ntex::http::body::{Body, SizedStream};
use ntex::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
use ntex::util::Bytes;
use ntex::web::{self, HttpRequest};
use wreq::Client;
use wreq::redirect::Policy;
use wreq_util::Emulation;
use crate::util::eporner_resolve::resolve_ranked;
use crate::util::requester::Requester;
/// The signed CDN links expire two hours after resolving; renew well before.
const LINK_TTL: Duration = Duration::from_secs(90 * 60);
const LINK_CACHE_SIZE: usize = 2000;
/// `{alnum_id}/{height}` -> (resolved at, signed CDN URL).
fn links() -> &'static Mutex<HashMap<String, (Instant, String)>> {
static LINKS: OnceLock<Mutex<HashMap<String, (Instant, String)>>> = OnceLock::new();
LINKS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Redirects are not followed so the CDN's `na.mp4` decoy shows up as a 302.
fn cdn_client() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.tls_cert_verification(false)
.emulation(Emulation::Firefox151)
.redirect(Policy::none())
.build()
.expect("eporner CDN client")
})
}
/// `{alnum_id}[/{height}][.mp4]`.
fn parse_endpoint(endpoint: &str) -> Option<(String, Option<u32>)> {
let endpoint = endpoint.trim_matches('/').trim_end_matches(".mp4");
let mut parts = endpoint.split('/');
let alnum_id = parts
.next()
.filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()))?;
let height = parts.next().and_then(|h| h.parse::<u32>().ok());
Some((alnum_id.to_string(), height))
}
async fn resolve_link(
requester: &web::types::State<Requester>,
alnum_id: &str,
height: Option<u32>,
) -> Option<String> {
let page_url = format!("https://www.eporner.com/video-{alnum_id}/");
let mut requester = requester.get_ref().clone();
let ranked = resolve_ranked(&mut requester, alnum_id, &page_url).await?;
height
.and_then(|h| ranked.iter().find(|(height, _)| *height == h))
.or_else(|| ranked.first())
.map(|(_, format)| format.url.clone())
}
async fn cached_link(
requester: &web::types::State<Requester>,
alnum_id: &str,
height: Option<u32>,
fresh: bool,
) -> Option<String> {
let key = format!("{alnum_id}/{}", height.unwrap_or(0));
if !fresh {
if let Ok(links) = links().lock() {
if let Some((time, url)) = links.get(&key) {
if time.elapsed() < LINK_TTL {
return Some(url.clone());
}
}
}
}
let url = resolve_link(requester, alnum_id, height).await?;
if let Ok(mut links) = links().lock() {
if links.len() >= LINK_CACHE_SIZE {
links.retain(|_, (time, _)| time.elapsed() < LINK_TTL);
if links.len() >= LINK_CACHE_SIZE {
links.clear();
}
}
links.insert(key, (Instant::now(), url.clone()));
}
Some(url)
}
/// Streams the mp4 through this server. The signed CDN links only serve the
/// IP that resolved them (checked from other networks: they answer every
/// other client with a 302 to `static.eporner.com/na.mp4`, a one-minute
/// "watch it on eporner.com" clip), so a redirect to them only ever works for
/// a player that shares the server's public IP.
pub async fn serve_media(
req: HttpRequest,
requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let endpoint = req.match_info().query("endpoint").to_string();
let Some((alnum_id, height)) = parse_endpoint(&endpoint) else {
return Ok(web::HttpResponse::BadRequest().finish());
};
let range = req
.headers()
.get(RANGE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let head = req.method() == Method::HEAD;
let mut upstream = None;
for fresh in [false, true] {
let Some(url) = cached_link(&requester, &alnum_id, height, fresh).await else {
break;
};
let mut request = if head {
cdn_client().head(&url)
} else {
cdn_client().get(&url)
};
request = request.header("Referer", "https://www.eporner.com/");
if let Some(range) = &range {
request = request.header("Range", range);
}
match request.send().await {
// A redirect is the decoy for an expired or foreign link.
Ok(response) if !response.status().is_redirection() => {
upstream = Some(response);
break;
}
_ => continue,
}
}
let Some(upstream) = upstream else {
return Ok(web::HttpResponse::BadGateway().finish());
};
let status = upstream.status();
let headers = upstream.headers().clone();
let mut builder = web::HttpResponse::build(status);
builder.header(
CONTENT_TYPE,
headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("video/mp4"),
);
builder.header(ACCEPT_RANGES, "bytes");
if let Some(value) = headers.get(CONTENT_RANGE.as_str()).and_then(|v| v.to_str().ok()) {
builder.header(CONTENT_RANGE, value);
}
if head {
// `finish()` would send `Content-Length: 0` over the upstream length.
return Ok(builder.body(Body::None));
}
let length = headers
.get(CONTENT_LENGTH.as_str())
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
let stream = Box::pin(upstream.into_data_stream().map(|chunk| {
chunk
.map(|bytes| Bytes::copy_from_slice(bytes.as_ref()))
.map_err(|e| std::io::Error::other(e.to_string()))
}));
let Some(length) = length else {
return Ok(builder.streaming(stream));
};
// A sized body keeps `Content-Length` on the (206) responses; a chunked
// one is what AVPlayer refuses to seek in.
let stream = stream.map(|chunk| chunk.map_err(|e| Rc::new(e) as Rc<dyn std::error::Error>));
Ok(builder.body(Body::from_message(SizedStream::new(length, stream))))
}
#[cfg(test)]
mod tests {
use super::parse_endpoint;
#[test]
fn parses_id_and_optional_height() {
assert_eq!(
parse_endpoint("exfeTXHuL1x/720.mp4"),
Some(("exfeTXHuL1x".to_string(), Some(720)))
);
assert_eq!(
parse_endpoint("/exfeTXHuL1x/"),
Some(("exfeTXHuL1x".to_string(), None))
);
assert_eq!(parse_endpoint("../etc"), None);
}
}

71
src/proxies/hanime.rs Normal file
View File

@@ -0,0 +1,71 @@
use ntex::http::Method;
use ntex::http::header::LOCATION;
use ntex::web::{self, HttpRequest};
use crate::util::hanime_resolve::{fetch_sources, pick_source, source_url};
use crate::util::requester::Requester;
/// `{slug}[/{height}][.m3u8]`.
fn parse_endpoint(endpoint: &str) -> Option<(String, Option<u32>)> {
let endpoint = endpoint.trim_matches('/').trim_end_matches(".m3u8");
let mut parts = endpoint.split('/');
let slug = parts
.next()
.filter(|slug| {
!slug.is_empty()
&& slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})?;
let height = parts.next().and_then(|h| h.parse::<u32>().ok());
Some((slug.to_string(), height))
}
/// hanime's stream links expire, so the channel hands out this stable URL and a
/// fresh handshake happens on every play: redirects to the current playlist.
pub async fn redirect_to_media(
req: HttpRequest,
requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let endpoint = req.match_info().query("endpoint").to_string();
let Some((slug, height)) = parse_endpoint(&endpoint) else {
return Ok(web::HttpResponse::BadRequest().finish());
};
if req.method() == Method::HEAD {
return Ok(web::HttpResponse::Ok().finish());
}
let mut requester = requester.get_ref().clone();
let Ok(sources) = fetch_sources(&mut requester, &slug).await else {
return Ok(web::HttpResponse::BadGateway().finish());
};
let Some(source) = pick_source(&sources, height) else {
return Ok(web::HttpResponse::NotFound().finish());
};
Ok(web::HttpResponse::Found()
.header(LOCATION, source_url(source))
.finish())
}
#[cfg(test)]
mod tests {
use super::parse_endpoint;
#[test]
fn parses_slug_and_height() {
assert_eq!(
parse_endpoint("natsu-zuma-2/720.m3u8"),
Some(("natsu-zuma-2".to_string(), Some(720)))
);
assert_eq!(
parse_endpoint("/natsu-zuma-2"),
Some(("natsu-zuma-2".to_string(), None))
);
}
#[test]
fn rejects_unsafe_slugs() {
assert_eq!(parse_endpoint("../etc/720.m3u8"), None);
assert_eq!(parse_endpoint(""), None);
}
}

View File

@@ -12,8 +12,16 @@ fn normalize_image_url(endpoint: &str) -> String {
endpoint.to_string()
} else if endpoint.starts_with("hanime-cdn.com/") || endpoint == "hanime-cdn.com" {
format!("https://{endpoint}")
} else {
} else if endpoint
.split('/')
.next()
.is_some_and(|first| first.contains('.'))
{
// `other-host.tld/path`: the caller already supplied a host.
format!("https://{endpoint}")
} else {
// Site-relative path such as `images/covers/x.png`.
format!("https://hanime-cdn.com/{endpoint}")
}
}

View File

@@ -18,12 +18,15 @@ use crate::proxies::lulustream::LulustreamProxy;
use crate::proxies::thaiporntv::ThaipornTvProxy;
pub mod allpornstream;
pub mod aylothumb;
pub mod melonstube;
pub mod animeidhentai;
pub mod archivebate;
pub mod clapdat;
pub mod doodstream;
pub mod eporner;
pub mod fikfapthumb;
pub mod hanime;
pub mod hanimecdn;
pub mod hanimethumb;
pub mod heavyfetish;

View File

@@ -105,9 +105,11 @@ impl NoodlemagazineProxy {
fn has_allowed_image_extension(path: &str) -> bool {
let path = path.to_ascii_lowercase();
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"]
.iter()
.any(|ext| path.ends_with(ext))
// `.../i.mycdn.me/getVideoPreview?id=..` previews have no extension.
path.ends_with("/getvideopreview")
|| [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"]
.iter()
.any(|ext| path.ends_with(ext))
}
fn is_disallowed_thumb_host(host: &str) -> bool {
@@ -292,20 +294,23 @@ pub async fn get_image(
requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let endpoint = req.match_info().query("endpoint").to_string();
let image_url = NoodlemagazineProxy::normalize_image_url(&endpoint);
let mut image_url = NoodlemagazineProxy::normalize_image_url(&endpoint);
// The preview's own query (`id`, `idx`, `type`, `tkn`, `fn`) arrives as the
// request query string, not as part of the matched path.
let query = req.query_string();
if !query.is_empty() && !image_url.contains('?') {
image_url.push('?');
image_url.push_str(query);
}
if !NoodlemagazineProxy::is_allowed_thumb_url(&image_url) {
return Ok(web::HttpResponse::BadRequest().finish());
}
// No site warm-up first: the image CDN answers directly (~0.1s). Warming
// via noodlemagazine.com cost 10-30s through the requester's fallbacks;
// the retry below still warms up when the first fetch fails.
let mut requester = requester.get_ref().clone();
let _ = requester
.get_with_headers(
NoodlemagazineProxy::root_referer(),
NoodlemagazineProxy::root_html_headers(),
Some(Version::HTTP_11),
)
.await;
let mut headers = NoodlemagazineProxy::image_headers(&requester, image_url.as_str());
let mut upstream = requester
@@ -409,6 +414,9 @@ mod tests {
#[test]
fn allows_https_image_thumbs_but_rejects_local_or_non_images() {
assert!(NoodlemagazineProxy::is_allowed_thumb_url(
"https://img.pvvstream.pro/preview/a/-1_2/240/i.mycdn.me/getVideoPreview?id=1&idx=0&type=39&tkn=x"
));
assert!(NoodlemagazineProxy::is_allowed_thumb_url(
"https://noodlemagazine.com/thumbs/example.webp"
));

View File

@@ -116,9 +116,14 @@ mod tests {
#[test]
fn normalizes_detail_request_with_path_only() {
let (url, video_id) = VidaraProxy::normalize_detail_request("video/1000/demo")
let (url, video_id) = VidaraProxy::normalize_detail_request("v/eJ9O4QqG1Ln2")
.expect("detail request should parse");
assert_eq!(url, "https://vidara.so/video/1000/demo");
assert_eq!(video_id, "1000");
assert_eq!(url, "https://vidara.so/v/eJ9O4QqG1Ln2");
assert_eq!(video_id, "eJ9O4QqG1Ln2");
}
#[test]
fn rejects_paths_outside_the_embed_route() {
assert!(VidaraProxy::normalize_detail_request("video/1000/demo").is_none());
}
}

View File

@@ -20,6 +20,40 @@ use crate::proxies::thaiporntv::ThaipornTvProxy;
use crate::proxies::*;
use crate::util::requester::Requester;
/// Proxies that answer with a `302` to the real media instead of streaming it.
/// The Hot Tub app cannot follow a redirect inside `formats[].url`, so for app
/// clients a format pointing at one of these is moved to `video.url` (see
/// `move_redirect_formats_to_url`). Keep in sync with the `proxy2redirect`
/// routes below; `redirect_proxy_list_matches_routes` enforces it.
pub const REDIRECT_PROXIES: &[&str] = &[
"archivebate",
"clapdat",
"doodstream",
"lulustream",
"sxyprn",
"javtiful",
"spankbang",
"porndish",
"hqporner",
"heavyfetish",
"vjav",
"pornhd3x",
"vidara",
"pimpbunny",
"thaiporntv",
"tube8",
"supjav",
];
/// True for a `/proxy/<redirect proxy>/...` URL (absolute or host-relative).
pub fn is_redirect_proxy_url(url: &str) -> bool {
let Some(rest) = url.split_once("/proxy/").map(|(_, rest)| rest) else {
return false;
};
let name = rest.split('/').next().unwrap_or_default();
REDIRECT_PROXIES.contains(&name)
}
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/archivebate/{endpoint}*")
@@ -123,6 +157,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route(web::post().to(crate::proxies::fikfapthumb::get_image))
.route(web::get().to(crate::proxies::fikfapthumb::get_image)),
)
.service(
web::resource("/hanime/{endpoint}*")
.route(web::get().to(crate::proxies::hanime::redirect_to_media))
.route(web::head().to(crate::proxies::hanime::redirect_to_media)),
)
.service(
web::resource("/hanime-cdn/{endpoint}*")
.route(web::post().to(crate::proxies::hanimecdn::get_image))
@@ -149,7 +188,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route(web::get().to(crate::proxies::hotbunnythumb::get_image)),
);
cfg.service(
web::resource("/proxy/pornhub-thumb/{endpoint}*")
web::resource("/aylo-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::aylothumb::get_image))
.route(web::get().to(crate::proxies::aylothumb::get_image)),
);
cfg.service(
web::resource("/pornhub-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::pornhubthumb::get_image))
.route(web::get().to(crate::proxies::pornhubthumb::get_image)),
);
@@ -189,6 +233,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route(web::post().to(crate::proxies::melonstube::serve))
.route(web::get().to(crate::proxies::melonstube::serve)),
);
cfg.service(
web::resource("/eporner/{endpoint}*")
.route(web::get().to(crate::proxies::eporner::serve_media))
.route(web::head().to(crate::proxies::eporner::serve_media)),
);
cfg.service(
web::resource("/xiaoshenke/{endpoint}*")
.route(web::post().to(crate::proxies::xiaoshenke::serve_media))
@@ -234,3 +283,39 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
_ => None,
}
}
#[cfg(test)]
mod redirect_proxy_tests {
use super::*;
#[test]
fn recognises_redirect_proxy_urls() {
assert!(is_redirect_proxy_url("http://h/proxy/archivebate/archivebate.com/watch/1"));
assert!(is_redirect_proxy_url("/proxy/hqporner/x"));
assert!(!is_redirect_proxy_url("http://h/proxy/noodlemagazine/x"));
assert!(!is_redirect_proxy_url("https://cdn.example/video.mp4"));
}
#[test]
fn redirect_proxy_list_matches_routes() {
let source = include_str!("proxy.rs");
let config_body = source
.split("pub fn config(")
.nth(1)
.and_then(|rest| rest.split("async fn proxy2redirect(").next())
.expect("config body");
let mut from_routes: Vec<String> = Vec::new();
for block in config_body.split("web::resource(\"/").skip(1) {
let name = block.split("/{endpoint}").next().unwrap_or_default();
let until_next = block.split("web::resource(").next().unwrap_or(block);
if until_next.contains("proxy2redirect") {
from_routes.push(name.to_string());
}
}
let mut listed: Vec<String> = REDIRECT_PROXIES.iter().map(|s| s.to_string()).collect();
from_routes.sort();
from_routes.dedup();
listed.sort();
assert_eq!(listed, from_routes);
}
}

View File

@@ -12,7 +12,7 @@
//! always compiled regardless of provider selection.
use crate::util::requester::Requester;
use crate::videos::VideoFormat;
use crate::videos::{ServerOptions, VideoFormat};
use regex::Regex;
use std::sync::OnceLock;
use wreq::Version;
@@ -101,11 +101,18 @@ fn calc_hash(hash: &str) -> Option<String> {
/// when playback doesn't carry the header the CDN wants. Each format
/// therefore declares an explicit `Referer` so the client's own default (or
/// lack thereof) can't accidentally trip this check.
pub async fn resolve_formats(
///
/// The signed CDN URLs also expire two hours after this call (the leading
/// number in `/{expiry}_{ip}_{n}/` is that deadline) and only serve the IP
/// embedded there; anything else gets the same `na.mp4` decoy.
/// Anything handed to a client should therefore be a
/// [`resolve_proxied_formats`] URL, streamed through this server.
/// Returns `(height, format)`, best first.
pub async fn resolve_ranked(
requester: &mut Requester,
alnum_id: &str,
page_url: &str,
) -> Option<Vec<VideoFormat>> {
) -> Option<Vec<(u32, VideoFormat)>> {
let detail_html = requester
.get_with_headers(page_url, html_headers(page_url), Some(Version::HTTP_11))
.await
@@ -151,25 +158,56 @@ pub async fn resolve_formats(
return None;
}
ranked.sort_by(|a, b| b.0.cmp(&a.0));
Some(ranked.into_iter().map(|(_, f)| f).collect())
Some(ranked)
}
/// Resolves eporner.com direct mp4 formats for an arbitrary eporner detail
/// page URL, extracting the alnum id from the URL itself. For use by
/// providers that only have a raw destination URL (e.g. a meta-search
/// aggregator's out-link), not a pre-parsed listing card.
pub async fn resolve_public_formats(
/// Resolves the qualities a video offers, but points each one at the
/// `/proxy/eporner/{alnum_id}/{height}.mp4` streaming proxy: the CDN URLs
/// expire after two hours and only serve the IP that resolved them.
pub async fn resolve_proxied_formats(
requester: &mut Requester,
options: &ServerOptions,
alnum_id: &str,
page_url: &str,
) -> Option<Vec<VideoFormat>> {
let alnum_id = alnum_id_from_href(page_url)?;
resolve_formats(requester, &alnum_id, page_url).await
let ranked = resolve_ranked(requester, alnum_id, page_url).await?;
let base = options.public_url_base.as_deref().unwrap_or("");
Some(proxied_formats(base, alnum_id, ranked))
}
/// Builds the URLs like `providers::build_proxy_url`, which this always
/// compiled module cannot rely on in single-provider builds.
fn proxied_formats(
public_url_base: &str,
alnum_id: &str,
ranked: Vec<(u32, VideoFormat)>,
) -> Vec<VideoFormat> {
let base = public_url_base.trim_end_matches('/');
ranked
.into_iter()
.map(|(height, mut format)| {
format.url = format!("{base}/proxy/eporner/{alnum_id}/{height}.mp4");
format
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proxied_formats_point_at_the_streaming_proxy() {
let ranked = vec![(
720,
VideoFormat::new("https://vid.cdn/x-720p.mp4".into(), "720p".into(), "mp4".into()),
)];
let formats = proxied_formats("https://hottub.example/", "exfeTXHuL1x", ranked);
assert_eq!(formats[0].url, "https://hottub.example/proxy/eporner/exfeTXHuL1x/720.mp4");
// Streamed, not redirected: app clients must keep it in `formats`.
assert!(!crate::proxy::is_redirect_proxy_url(&formats[0].url));
}
#[test]
fn calc_hash_matches_site_algorithm() {
// Reverse-engineered from vjs.js; verified against a live page/xhr pair.

208
src/util/hanime_resolve.rs Normal file
View File

@@ -0,0 +1,208 @@
//! hanime.tv stream resolution, shared by the `hanime` channel and its
//! `/proxy/hanime/` redirect proxy.
//!
//! Lives here (always compiled) rather than in `providers/hanime.rs` because
//! `HOT_TUB_PROVIDER=<id>` single-provider builds only compile the selected
//! provider, while the proxy routes are always registered.
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::Engine;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::util::requester::Requester;
const HANDSHAKE_URL: &str = "https://auth.hanime.tv/api/v11/handshake";
pub const SITE_ORIGIN: &str = "https://hanime.tv";
/// Envelope encryption used for the handshake request/response bodies.
const HANDSHAKE_KEY_SEED: &[u8] = b"htv-insecure-handshake-v1";
const HANDSHAKE_AAD: &[u8] = b"htv-insecure-v1";
/// Pieces of the `x-signature` pre-image, as baked into the site's wasm module.
const SIGNATURE_SECRET: &str = "Xkdi29";
const SIGNATURE_SALT: &str = "mn2";
type Result<T> = std::result::Result<T, String>;
fn err(message: impl Into<String>) -> String {
message.into()
}
#[derive(serde::Deserialize, Debug)]
pub struct HanimeHandshakePayload {
#[serde(default)]
pub sources: Vec<HanimeSource>,
}
#[derive(serde::Deserialize, Debug, Clone)]
pub struct HanimeSource {
#[serde(default)]
pub src: String,
#[serde(default)]
pub height: u32,
#[serde(default)]
pub label: String,
/// `"normal"` for the free streams, `"promotion"` for the premium-only teaser
/// entries which always carry an empty `src`.
#[serde(default)]
pub kind: String,
}
fn b64() -> base64::engine::general_purpose::GeneralPurpose {
base64::engine::general_purpose::URL_SAFE_NO_PAD
}
fn b64_decode(value: &str) -> Result<Vec<u8>> {
b64()
.decode(value.trim_end_matches('='))
.map_err(|e| err(format!("base64url decode failed: {e}")))
}
fn handshake_cipher() -> Aes256Gcm {
let digest = Sha256::digest(HANDSHAKE_KEY_SEED);
let key = Key::<Aes256Gcm>::from_slice(digest.as_slice());
Aes256Gcm::new(key)
}
/// `{"v":1,"alg":"AES-256-GCM","iv":..,"tag":..,"data":..}`, base64url encoded.
fn seal_envelope(plaintext: &[u8]) -> Result<String> {
let mut iv = [0u8; 12];
rand::fill(&mut iv);
let sealed = handshake_cipher()
.encrypt(
Nonce::from_slice(&iv),
Payload {
msg: plaintext,
aad: HANDSHAKE_AAD,
},
)
.map_err(|e| err(format!("handshake encrypt failed: {e}")))?;
if sealed.len() < 16 {
return Err(err("handshake ciphertext too short"));
}
let (data, tag) = sealed.split_at(sealed.len() - 16);
let envelope = json!({
"v": 1,
"alg": "AES-256-GCM",
"iv": b64().encode(iv),
"tag": b64().encode(tag),
"data": b64().encode(data),
});
Ok(b64().encode(envelope.to_string().as_bytes()))
}
fn open_envelope(token: &str) -> Result<Vec<u8>> {
let envelope: serde_json::Value = serde_json::from_slice(&b64_decode(token)?)
.map_err(|e| err(format!("handshake envelope is not JSON: {e}")))?;
let field = |name: &str| -> Result<Vec<u8>> {
let raw = envelope
.get(name)
.and_then(|v| v.as_str())
.ok_or_else(|| err(format!("handshake envelope missing {name}")))?;
b64_decode(raw)
};
let iv = field("iv")?;
let mut ciphertext = field("data")?;
ciphertext.extend_from_slice(&field("tag")?);
handshake_cipher()
.decrypt(
Nonce::from_slice(&iv),
Payload {
msg: &ciphertext,
aad: HANDSHAKE_AAD,
},
)
.map_err(|e| err(format!("handshake decrypt failed: {e}")))
}
fn signature_for(timestamp: u64) -> String {
let pre_image =
format!("{timestamp},{SIGNATURE_SECRET},{SITE_ORIGIN},{SIGNATURE_SALT},{timestamp}");
hex::encode(Sha256::digest(pre_image.as_bytes()))
}
/// Returns the free `sources` for a slug, best quality first. The auth host
/// occasionally refuses one of a burst of parallel connections, so retry once.
pub async fn fetch_sources(requester: &mut Requester, slug: &str) -> Result<Vec<HanimeSource>> {
match handshake(requester, slug).await {
Ok(sources) => Ok(sources),
Err(_) => {
ntex::time::sleep(ntex::time::Millis(500)).await;
handshake(requester, slug).await
}
}
}
async fn handshake(requester: &mut Requester, slug: &str) -> Result<Vec<HanimeSource>> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let token = seal_envelope(
json!({
"timestamp_unix": timestamp,
"directive": "htv_player_handshake",
"slug": slug,
})
.to_string()
.as_bytes(),
)?;
let response = requester
.post_json(
HANDSHAKE_URL,
&json!({ "token": token }),
vec![
("Origin".to_string(), SITE_ORIGIN.to_string()),
("Referer".to_string(), format!("{SITE_ORIGIN}/")),
("Accept".to_string(), "application/json".to_string()),
("x-signature-version".to_string(), "web2".to_string()),
("x-signature".to_string(), signature_for(timestamp)),
("x-time".to_string(), timestamp.to_string()),
],
)
.await
.map_err(|e| err(format!("handshake request failed: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(err(format!("handshake returned HTTP {status}")));
}
// The interesting part of the answer travels in a response header, not the body.
let encrypted = response
.headers()
.get("x-token")
.and_then(|v| v.to_str().ok())
.map(|v| v.to_string())
.ok_or_else(|| err("handshake response is missing x-token"))?;
let payload: HanimeHandshakePayload = serde_json::from_slice(&open_envelope(&encrypted)?)
.map_err(|e| err(format!("Failed to parse handshake payload: {e}")))?;
let mut sources: Vec<HanimeSource> = payload
.sources
.into_iter()
.filter(|s| s.kind == "normal" && !s.src.is_empty())
.collect();
sources.sort_by(|a, b| b.height.cmp(&a.height));
match sources.is_empty() {
true => Err(err("handshake returned no playable sources")),
false => Ok(sources),
}
}
pub fn source_url(source: &HanimeSource) -> String {
match source.src.starts_with("http") {
true => source.src.clone(),
false => format!("{SITE_ORIGIN}{}", source.src),
}
}
/// The source of the requested height, else the best one.
pub fn pick_source(sources: &[HanimeSource], height: Option<u32>) -> Option<&HanimeSource> {
height
.and_then(|h| sources.iter().find(|s| s.height == h))
.or_else(|| sources.first())
}

View File

@@ -6,6 +6,7 @@ pub mod discord;
pub mod eporner_resolve;
pub mod flaresolverr;
pub mod flow_debug;
pub mod hanime_resolve;
pub mod hoster_proxy;
pub mod proxy;
pub mod requester;

View File

@@ -87,7 +87,7 @@ impl Requester {
}
}
fn remembered_flaresolverr_user_agent(url: &str) -> Option<String> {
pub(crate) fn remembered_flaresolverr_user_agent(url: &str) -> Option<String> {
let host = Self::host_key(url)?;
Self::flaresolverr_user_agents()
.read()
@@ -218,10 +218,19 @@ impl Requester {
.unwrap_or_else(|| "none".to_string())
}
fn looks_like_cloudflare_challenge(body: &str) -> bool {
pub(crate) fn looks_like_cloudflare_challenge(body: &str) -> bool {
body.contains("Just a moment...")
|| body.contains("cf-turnstile-response")
|| body.contains("Enable JavaScript and cookies to continue")
|| Self::looks_like_ddos_guard_challenge(body)
}
/// DDoS-Guard interstitials (rule34video) are short JS-check pages, never real content.
fn looks_like_ddos_guard_challenge(body: &str) -> bool {
body.len() < 20_000
&& (body.contains("/.well-known/ddos-guard/")
|| body.contains("check.ddos-guard.net")
|| body.contains("DDoS-Guard</title>"))
}
fn jina_mirror_url(url: &str) -> Option<String> {
@@ -496,6 +505,10 @@ impl Requester {
timeout: Option<Duration>,
http_version: Option<Version>,
) -> Result<Response, wreq::Error> {
// Replay the solved FlareSolverr identity here too: a token minted
// through `get_with_headers` under that user agent is rejected (401)
// when the follow-up raw request goes out with the default one.
self.adopt_remembered_identity(url);
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
@@ -657,6 +670,74 @@ impl Requester {
Ok(response)
}
fn path_of(url: &str) -> Option<String> {
url::Url::parse(url).ok().map(|u| u.path().to_string())
}
/// `Url` percent-encodes a non-ASCII path ("/tags/pok%C3%A9mon/") while the
/// response URI keeps it raw ("/tags/pokémon/"): compare them decoded.
fn same_path(asked: &str, landed: &str) -> bool {
let normalize = |path: &str| {
percent_encoding::percent_decode_str(path)
.decode_utf8_lossy()
.trim_end_matches('/')
.to_lowercase()
};
normalize(asked) == normalize(landed)
}
/// Existence probe for archive pages (pornstar / studio / tag / category).
///
/// Reads the raw response so a real 404/410 answers immediately with
/// `Ok(None)` instead of escalating through the Jina/FlareSolverr chain
/// (minutes per miss). A Cloudflare challenge (403/503/"Just a moment...")
/// is handed to the full chain so FlareSolverr solves it, and 429/5xx are
/// retried once.
pub async fn get_probe(
&mut self,
url: &str,
headers: Vec<(String, String)>,
) -> Result<Option<String>, AnyErr> {
let mut last_err = String::new();
for attempt in 0..2 {
if attempt > 0 {
// An immediate retry of a 429 is all but certain to hit it again.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
match self.get_raw_with_headers(url, headers.clone()).await {
Ok(response) => {
let status = response.status().as_u16();
// An unknown archive often redirects to the home page or a
// search page instead of answering 404: a redirect that
// lands on a different path is a miss.
let redirected_away = Self::path_of(url)
.zip(Some(response.uri().path().to_string()))
.is_some_and(|(asked, landed)| !Self::same_path(&asked, &landed));
let body = response.text().await.unwrap_or_default();
if status == 404 || status == 410 {
return Ok(None);
}
if status == 403
|| status == 503
|| Self::looks_like_cloudflare_challenge(&body)
{
return self.get_with_headers(url, headers, None).await.map(Some);
}
if redirected_away {
return Ok(None);
}
if status >= 500 || status == 429 {
last_err = format!("HTTP {status}");
continue;
}
return Ok(Some(body));
}
Err(e) => last_err = e.to_string(),
}
}
Err(format!("probe failed for {url}: {last_err}").into())
}
pub async fn get(
&mut self,
url: &str,
@@ -826,7 +907,9 @@ impl Requester {
rate_limit_retries,
MAX_RATE_LIMIT_RETRIES
);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// 1s, 2s, 4s: a site-wide limit needs longer than a second to lift.
tokio::time::sleep(std::time::Duration::from_secs(1 << (rate_limit_retries - 1)))
.await;
continue;
} else {
crate::flow_debug!(
@@ -960,6 +1043,14 @@ impl Requester {
mod tests {
use super::Requester;
#[test]
fn probe_paths_compare_percent_decoded() {
let asked = Requester::path_of("https://rule34video.com/tags/pokémon-species/").unwrap();
assert!(Requester::same_path(&asked, "/tags/pokémon-species/"));
assert!(Requester::same_path("/Models/X", "/models/x/"));
assert!(!Requester::same_path(&asked, "/search/"));
}
#[test]
fn new_requesters_share_cookie_jar() {
let a = Requester::new();
@@ -975,4 +1066,88 @@ mod tests {
assert!(cookie_header.contains("shared_cookie=1"));
}
async fn serve(listener: tokio::net::TcpListener, reply: impl Fn(&str) -> String + Send + 'static) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let mut buf = vec![0u8; 16384];
let mut len = 0;
// read headers, then the declared body
loop {
let Ok(n) = sock.read(&mut buf[len..]).await else { break };
if n == 0 { break; }
len += n;
let text = String::from_utf8_lossy(&buf[..len]).to_string();
if let Some(head_end) = text.find("\r\n\r\n") {
let want = text
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
.unwrap_or(0);
if len >= head_end + 4 + want { break; }
}
}
let req = String::from_utf8_lossy(&buf[..len]).to_string();
let _ = sock.write_all(reply(&req).as_bytes()).await;
let _ = sock.shutdown().await;
}
}
fn http(status: &str, ctype: &str, extra: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status}\r\ncontent-type: {ctype}\r\n{extra}content-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
/// DDoS-Guard answers either a bare `403 Access denied.` (blocked fingerprint/IP) or a
/// short JS-check page. Both must end up at FlareSolverr and return the real listing.
#[tokio::test(flavor = "multi_thread")]
async fn ddos_guard_blocks_are_solved_through_flaresolverr() {
// distinct hosts: the cookie jar is process-wide, so a solved cookie would leak across passes
for (interstitial, host) in [(false, "127.0.0.1"), (true, "localhost")] {
let origin = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let flare = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin_port = origin.local_addr().unwrap().port();
let flare_port = flare.local_addr().unwrap().port();
tokio::spawn(serve(origin, move |req| {
if req.contains("ddg_ok=1") {
http("200 OK", "text/html", "", "<div class=\"item thumb video_1\">real listing</div>")
} else if interstitial {
http(
"200 OK",
"text/html",
"server: ddos-guard\r\n",
"<html><head><title>DDoS-Guard</title><script src=\"/.well-known/ddos-guard/check?context=free_splash\"></script></head></html>",
)
} else {
http("403 Forbidden", "text/plain", "server: ddos-guard\r\n", "Access denied.")
}
}));
tokio::spawn(serve(flare, move |req| {
let body = if req.contains("sessions.create") {
r#"{"status":"ok","message":"","session":"mock"}"#.to_string()
} else if req.contains("sessions.destroy") {
r#"{"status":"ok","message":""}"#.to_string()
} else {
format!(
r#"{{"status":"ok","message":"","startTimestamp":0,"endTimestamp":1,"version":"mock","solution":{{"url":"http://127.0.0.1:{origin_port}/","status":200,"response":"<div class=\"item thumb video_1\">real listing</div>","headers":{{}},"userAgent":"Mozilla/5.0 mock","cookies":[{{"name":"ddg_ok","value":"1","domain":"","path":"/"}}]}}}}"#
)
};
http("200 OK", "application/json", "", &body)
}));
unsafe { std::env::set_var("FLARE_URL", format!("http://127.0.0.1:{flare_port}/v1")) };
let mut requester = Requester::new();
let body = requester
.get(&format!("http://{host}:{origin_port}/?mode=async"), None)
.await
.expect("requester should fall through to FlareSolverr");
assert!(
body.contains("real listing"),
"interstitial={interstitial}: got {body:?}"
);
}
}
}