more providers

This commit is contained in:
Simon
2026-09-07 15:25:03 +00:00
parent 5941a6e607
commit 86c8faca44
10 changed files with 4432 additions and 1 deletions

View File

@@ -5,7 +5,7 @@ This folder is the fastest handoff path for anyone adding or repairing a channel
Start here:
1. Read `architecture.md` for the server flow, request lifecycle, and core types.
2. Read `provider-playbook.md` for the exact process to add a new provider or proxy.
2. Read `provider-playbook.md` — its top **"Best Practices (Living)"** section is the cross-cutting rule set to internalize first, then follow the rest of the playbook for the exact process to add a new provider or proxy.
3. Use `provider-catalog.md` to find the closest existing implementation to copy.
4. Use `docs/hottubapp/*.html` when you need the client-facing API contract for status, videos, or uploaders.
5. Only then touch `prompts/new-channel.md`; it assumes the docs above exist.

View File

@@ -15,16 +15,19 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `chaturbate` | `live-cams` | no | no | Live cam channel. |
| `clapdat` | `amateur-homemade` | no | yes | Svelte/JSON-hydrated provider using home/recent/trending routes, Meilisearch keyword search, and `/proxy/clapdat/...` redirect playback resolution. |
| `erome` | `amateur-homemade` | no | no | HTML album scraper with hot/new feeds, keyword search, and uploader-slug shortcuts (`uploader:<name>`). |
| `fapello` | `onlyfans` | no | no | HTML + embedded-JSON provider for fapello.com (OnlyFans-style photo/video leak aggregator; the site is fundamentally a *photo* model archive — `/`, `/new/`, `/hot/`, `/trending/`, `/tags/`, `/search_v2/` — and the much smaller `/videos/` section is what this channel surfaces). Cloudflare-gated; the shared requester's Jina mirror fallback resolves every listing and detail page (`r.jina.ai/http://fapello.com/...`, `X-Return-Format: html`). Latest feed `GET /videos/` (page 1, ~6-8 cards), `GET /ajax/videos/page-{N}/` (page 2+, ~4 cards) — cards expose the numeric id, the `fapello.com/content/.../{model}_{NNNN}_300px.jpg` thumbnail, and the `/{model_slug}/` uploader link. Each detail page `GET /video/new/{id}/` embeds a complete `var BOOT = { items: [...], next_page, page_size }` JSON block (preserved by the Jina mirror) — every BOOT item carries the resolved `video_url` (`https://cdn.fapello.com/content/.../{N}/{model}_{NNNN}.mp4`), `poster_url` (the matching `.jpg`, no `_300px` suffix — provider swaps that in for the full-size thumb), `duration` ("HH:MM:SS" → seconds), `width`/`height`/`aspect`/`orientation`, `count_likes` (→ `views`), `name`/`model_url` (uploader), and `tags[]`. Per-card BOOT enrichment is bounded via `buffer_unordered(4)` with a 20s timeout — listing-only metadata (id, thumb, uploader) is preserved on timeout so a Jina-throttled page still produces usable cards. The Jina mirror does NOT trigger JS, so the `/popular_videos/{twelve_hours,month,all_time}/` routes and `/search_v2/?type=...` are empty (search returns *photo models*, not videos; the site has no native "videos matching keyword" search) — those filters are deliberately not exposed; only `sort:new` is offered. `video.url` is the detail page (not yt-dlp-resolvable — no extractor exists), so `formats[0]` carries the direct `cdn.fapello.com` mp4 with `Referer: https://fapello.com/` (the CDN serves direct without it, but the header matches the on-site context). Media and thumbnails (`fapello.com/content/.../*.jpg`, `cdn.fapello.com/content/.../*.mp4`) are range-served, no auth/cookies/JA3 emulation required, so no proxy route is needed. `check.py`'s `_CF_PROTECTED_HOSTS` allowlist includes `fapello.com` and `cdn.fapello.com` so its plain-requests 403s for `video.url` and the format URL downgrade to warnings instead of errors. No `/api/uploaders` (fapello has stable model slugs but no dedicated "videos by this uploader" feed — only the mixed photo+video model page). |
| `fikfap` | `tiktok` | yes | yes (thumbs only) | JSON-API provider for fikfap.com (TikTok-style swipe short clips); anonymous auth via a client-generated `Authorization-Anonymous` UUID header (no real login needed); listing via `GET api.fikfap.com/posts?sort=new\|trending\|random&amount=N&afterId=<lastPostId>` (cursor pagination — page N costs N sequential requests); search via `GET search?q=` (single fixed-size batch, no pagination — page 2+ returns empty); hashtag feeds via `GET hashtags/label/{label}/posts` and creator feeds via `GET profile/username/{user}/posts`, both also cursor-paginated; `tag:`/`hashtag:`/`#` and `user:`/`uploader:` query prefixes route directly; `categories` option exposes a small curated static hashtag list (no full catalog endpoint exists anonymously); `video.url` is the `fikfap.com/post/{id}` page (a client-rendered SPA, not yt-dlp-resolvable on its own); `videoStreamUrl` from the JSON response is sent directly as `formats[0].url` (signed Bunny CDN HLS `.m3u8`, ~24h token expiry) with `httpHeaders: {Referer: https://fikfap.com/}` — Hot Tub clients apply a format's `http_headers` across the whole HLS playback session (manifest, sub-playlists, and segments), so no proxying of the media itself is needed; thumbnails have no per-field header mechanism, so they're proxied via `/proxy/fikfap-thumb/...` to inject the same Referer; `get_uploader` implemented (`fikfap:<username>` IDs) using `GET profile/username/{user}`. |
| `freepornvideosxxx` | `studio-network` | no | no | Studio-style scraper. |
| `fyptt` | `tiktok` | no | no | HTML scraper for fyptt.to (Beaver Builder/WordPress short-form TikTok-style vertical porn); card selector `.fl-post-grid-post[class*="post-ID"]` with `category-{slug}` CSS class doubling as both listing tag and category-archive route; latest feed `/` (page N: `/page/N/`), search `/?s=query` (page N: `/page/N/?s=query`), category archives at bare top-level slugs like `/tiktok-ass/` (12 hardcoded categories exposed via the `categories` filter option, or via an explicit `cat:`/`category:` query prefix — bare keyword queries always go to WordPress search, never a category archive, because the category names ("sexy", "ass", "tiktok", "live", ...) are also the most common search terms); per-item enrichment fetches the detail page for the JSON-LD `embedURL` (one of three on-site player endpoints: `fypttstr.php`, `fypttjwstr.php`, or `fypttjwstrhls.php`) and `datePublished`, then fetches that embed URL to extract the actual signed `stream.fyptt.to` mp4 or `/hls/*.m3u8` URL (token expires ~2h, no Referer required) for `formats`; thumbnails (`fyptt.to/wp-content/uploads/...webp`) need no proxy; no duration metadata available on listing or detail pages (set to 0); no real uploader/model identity (the `girl-{slug}` CSS class is cosmetic only, not a linkable archive) so `/api/uploaders` is not implemented; `video.url` is the detail page URL (not yt-dlp resolvable directly — the player is sandboxed-iframe-only) so `formats` are populated instead; no proxy needed. |
| `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. |
| `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. |
| `heavyfetish` | `fetish-kink` | no | no | Direct media handling. |
| `hdporn92` | `mainstream-tube` | no | no | WordPress retrotube-theme aggregator for hdporn92.com (network studio releases — MyPervyFamily, MYLF, StepSiblings, …). Latest feed `/` (page 1) and `/page/N/` (page N≥2); search `/?s={query}` (page 1) and `/page/N/?s={query}` (page N≥2) — the path with the `s` parameter must come after the `page/N` segment, not before. Pagination is by `article.loop-video` cards; the post id comes from the `post-{N}` CSS class. Two-step enrichment (page URL + Referer-locked m3u8 in `formats`): detail page → `<IFRAME SRC="https://morencius.com/embed/{id}">` (case-insensitive; the page emits an uppercase `<IFRAME SRC=...>` tag) → morencius embed → embedded `eval(function(p,a,c,k,e,d){…}(p,a,c,k.split('|')))` P.A.C.K.E.R. obfuscation block → deobfuscate in pure Rust (reverse-iterate the `c`-word `|`-delimited dictionary, base-`a` numeric tokens, `\b<token>\b` regex substitution) → `"hls4":"<value>"` field in the decoded JS gives the morencius stream URL `https://morencius.com/stream/{token}/{secret}/{expiry_ts}/{file_id}/master.m3u8`; `duration:"<value>"` gives the runtime. `video.url` is the hdporn92 page (no yt-dlp extractor exists for the site); `formats[0]` is the morencius `m3u8` carrying `Referer: https://morencius.com/embed/{id}` + a desktop Chrome `User-Agent` — these headers must travel with the manifest, sub-playlists, and segment requests. **Known limitation:** the morencius m3u8 path contains an `expiry_ts` segment (~12h from page render) — the cache TTL (`cacheDuration: 1800`, same as fyptt) caps the staleness window. Real playback still requires the webview to click through the morencius ad overlay; the manifest itself resolves to a 1280x720 HLS variant via `yt-dlp -j --referer <embed-url>`. No `/api/uploaders` (no uploader identity on the listing). |
| `hentaihaven` | `hentai-animation` | no | no | HTML scraper for hentaihaven.xxx (WordPress/Madara theme), Cloudflare-protected so the provider is gated behind `FLARE_URL` in `skip_reason_for_provider` (mod.rs); the shared requester clears CF directly (wreq Firefox136 emulation currently passes for the listing/search/watch/episode/`player.php` GETs) and falls back to Jina/FlareSolverr. Latest feed `/hentai/page/{N}/`, search `/?s={query}` (search is single-page — page>1 returns empty); listing/search cards link to series watch pages `https://hentaihaven.xxx/watch/{slug}/`. Per-series media resolution (the UUID exists nowhere in page HTML, so enrichment is unavoidable): watch page → episode links `…/watch/{slug}/episode-K` (in `manga-chapters-holder`) → episode page → `<iframe src="…/wp-content/plugins/player-logic/player.php?data=…">``player.php``<meta name="x-secure-token" content="sha512-…">` → decode token (strip `sha512-`, then 3× of rot13→base64-decode, then `JSON.parse`) → `{en, iv, uri, hot_domains, …}` → POST `…/wp-content/plugins/player-logic/api.php` with `action=zarat_get_data_player_ajax&a={en}&b={iv}` (urlencoded; this one POST uses a dedicated `wreq` Chrome137 client, not the shared requester) → `{"status":true,"data":{"sources":[{"src":"…m3u8"}],"isOctopus":bool}}`. A multi-episode series collapses into one `VideoItem` titled `"… (N Episodes)"` with one `m3u8` `VideoFormat` per episode (`format_note`/`format_id` = "Episode K"); each format carries `Referer`/`Origin: https://hentaihaven.xxx` + a Firefox `User-Agent`. `video.url` is the `watch/{slug}/` page (no yt-dlp extractor exists for the site, so `formats` are populated rather than relying on `video.url`). Two CDN shapes are returned: newer content-addressed `octopusmanifest.org/{uuid}/playlist.m3u8` (no token, portable across IPs) and older signed `master-lengs.org/api/v3/hh/{slug}/master.m3u8?hash=…` (~2.5h). **Gotcha:** both CDNs (same IP) aggressively per-IP rate-limit/ban with a TCP RST on 80/443 once tripped — looks like "host down" but is an IP ban; browsers play fine over HTTP/3 (QUIC) while TCP clients (curl/yt-dlp/wreq) get refused, so segment fetches can fail from a tripped IP even though the manifest URL is valid. Tags from the series "Genre(s)" block; `views` from the "Viewed … Total" counter; thumbnails (`img.hentaihaven.xxx`) load directly (no proxy/referer). Resolution is slow (each listing page = ~25 series × multi-episode player-API calls), so the provider is DB-first: it fetches the listing once for the ordered watch URLs, serves already-resolved `VideoItem`s from the `videos` SQLite table instantly (`db::upsert_video` to avoid duplicate-row staleness), and `spawn_refresh`es the whole page in the background (in-memory `VideoCache` soft-TTL 1h / hard-TTL 24h, per-listing in-flight guard). No `/api/uploaders` (no uploader identity), no proxy. |
| `hentaitv` | `hentai-animation` | no | yes | Next.js hentai site (hentai.tv) backed by a clean JSON API: `GET /api/browse?page=N&sort=<Label>&genres=<ExactName>` (`{videos:[28],total,pages}`, real pagination) and `GET /api/search?q=Q` (`{videos:[...]}`, single-page — `page` is ignored, so page>1 returns empty). Unlike `animeidhentai`, browse honors both `sort` (labels `Most Recent`/`Most Viewed`/`Trending`, mapped from option ids `new`/`views`/`trending`) and `genres` (the **exact case-sensitive** stored genre name, e.g. `Big Boobs`, `incest`), so genre archives go through `/api/browse?genres=` and paginate. The 68-genre catalogue (exact names) is background-loaded from the `/browse` page HTML (`"genres":[{"name","count"}]`, not exposed by the JSON API) and powers the `categories` filter plus keyword→genre routing. Each episode JSON has `slug`, `title`/`ep`, `tags[]`, `views`, `rating` (0-10 → ×10), `duration` ("MM:SS"), `brand` (studio → `uploader`), `thumb`/`backdrop`/`cover` (relative, served from `hentai.tv/uploads/...`, no referer), and `embedUrl=https://nhplayer.com/v/{embedId}/`. `video.url` is the reachable watch page `https://hentai.tv/hentai/{slug}`; `genre:`/`cat:`/`category:` prefixes and bare keywords that exactly match a genre route to the genre archive, everything else to search. Playback shares the **same nhplayer→`r2.1hanime.com` signed-CDN backend as `animeidhentai`**: `/proxy/hentaitv/{embedId}.mp4` is a redirect proxy that replicates nhplayer's PoW+DOM challenge (`player.php``player-core-v2.php``get-video-url-v2.php`, SHA-256-first-byte-zero PoW, ≥700ms dwell, fixed fingerprint) to mint a signed `?verify=<ts>-<sig>` URL — HEAD→200, GET→302 to the CDN URL (cached 150s). The CF wall is JA3-based not IP-based, so the signed URL is verifiable from anywhere with `yt-dlp --impersonate chrome` even though plain `curl`/`wreq` get 403. `src/proxies/hentaitv.rs` is a near-copy of `src/proxies/animeidhentai.rs` (only `SITE_REFERER` differs). No `/api/uploaders` (brand is studio-only). |
| `homoxxx` | `gay-male` | no | no | Gay category grouping example. |
| `hotbunny` | `ai` | no | yes (thumbs only) | JSON-API provider for hotbunny.ai (AI-generated hentai community). Feeds: `GET /api/post/feed?limit=N` (hot), `/api/post/feed/popular`, `/api/post/feed/recents`, `/api/post/category/{cuid}?limit=N`, `/api/post/search?query=Q&limit=N`, `/api/post/author/{name}/all?limit=N`. Pagination: fetch `page × per_page` items in one call and slice (cursor-based API, but limit works up to 200 total). Key fields: `id`, `imageUrl` (relative thumbnail path on CDN), `authorName`, `likes` (→ `views`), `sharedVideos[].videoUrl` (first video path, relative to CDN), `width`/`height`, `galleries[].label` (used as title prefix). 15 hardcoded categories from `/api/categories` (stable CUIDs). `video.url` is the post page `https://hotbunny.ai/post/{id}` (React SPA, not yt-dlp-resolvable); `formats[0].url` = `https://assets.hotbunny.ai/{sharedVideos[0].videoUrl}` with `Referer: https://hotbunny.ai/` header. `assets.hotbunny.ai` is Cloudflare bot-management protected (returns 404 to plain curl/yt-dlp; iOS AVFoundation passes with real Apple TLS — no proxy wrapping needed for video). Thumbnails proxied via `/proxy/hotbunny-thumb/...` to inject the required `Referer`. Supports `uploader:`/`author:`, `cat:`/`category:`/`#tag` query shortcuts, `categories` filter option, and `sort` option (feed/popular/recent). |
| `hornyleak` | `amateur-homemade` | no | no | HTML scraper for hornyleak.tv (KVS / Kernel Video Sharing — leaked-amateur and OnlyFans-creator clips). Listing cards `div.thumb.thumb_rel.item` (24/page) with `a.img[href*="/video/"]` (href→`/video/{id}/{slug}/`, `title` attr→title), `img.thumb` (`data-webp` preferred, falls back to `data-src`/`src`), `div.duration` (text → seconds), `div.thumb-item > span` views text, `a.avatar` uploader (often empty — `-` placeholder on the listing), `div.rating` percent; `/videos/{N}/` is the path-based pagination for `Latest` and the per-archive pages. The detail page is yt-dlp-resolvable (page URL → generic extractor → `https://www.hornyleak.tv/video/{id}/{slug}/` → 1 format), so `video.url` is set to that detail page (NOT a proxy URL) and per-card enrichment is optional — but most clients want a stable HLS link, so the provider enriches each card to populate `formats[]`. Two-step media resolution chain: (1) `GET /embed/{id}/` (Referer: detail page) → regex on `hdplayer\.gives/embed/([A-Za-z0-9]+)` to find the per-video hash; (2) `GET https://hdplayer.gives/embed/{hash}/` (Referer: hornyleak.tv embed URL) → regex on `file:"(https://hdplayer\.gives/list/enc[^"]+)"` to extract the signed list/enc token URL. The enc URL is a real m3u8 with three variants (FULLHD 1920x1080 / HD 1280x720 / SD 842x480) pointing to `https://hdplayer.gives/m3u/...` sub-playlists and `https://swa3.top/upload/videos/...` segments. `formats[0]` is the m3u8 with `Referer: https://hdplayer.gives/` and the hornyleak.tv user-agent. `video.url` is left as the page URL (yt-dlp's generic extractor resolves it to 1 format on a non-JA3-blocked client; the HLS in `formats[]` is the primary path for clients that honor `http_headers`). `sort` option: new/popular/rated/viewed/longest (path-based: `/latest-updates/`, `/most-popular/`, `/top-rated/`, `/most-viewed/`, `/longest/`). `cat:`/`category:`/`tag:`/`model:`/`uploader:`/`pornstar:`/`star:` query prefixes route to the matching archive (`/categories/{slug}/`, `/tags/{slug}/`, `/models/{slug}/`). Search is path-1 `GET /search/?q={query}` (returns search results HTML directly) and path-≥2 `GET /search/?q={query}&mode=async&function=get_block&block_id=custom_list_videos_videos_list_search_result&from_videos={N}&from_albums={N}` (XHR pagination — the search results page itself is paged via the `custom_list_videos_videos_list_search_result` block). Background-loaded category filter (≈ 100 entries scraped from `/categories/`) via the shared `Arc<RwLock<Vec<FilterOption>>>` pattern, with title→slug map for bare-query short-circuit to a category archive when the keyword exactly matches a category title. Tags from detail `.tags-row a, .categories-row a`; thumbnails `https://www.hornyleak.tv/contents/videos_screenshots/{N}00/{id}/preview.jpg` (direct, no proxy/referer); no proxy route needed. `uploaderId` is namespaced as `hornyleak:<model-slug>`. **Known limitation:** the `hdplayer.gives/list/enc...` token is request-bound to the player-page TLS context; yt-dlp's generic extractor gets HTTP 404 when probing the URL directly with curl_cffi, so the validator's `_CF_PROTECTED_HOSTS` allowlist includes `hdplayer.gives` to downgrade the 404 to a warning (the HLS body itself returns 200 with valid m3u8 to plain curl, and iOS AVFoundation + Hot Tub clients play it directly through the `formats[]` entry). No `/api/uploaders` profile (KVS uploader pages exist but have no public stats). |
| `hqporner` | `studio-network` | no | yes | Uses thumb and redirect proxy helpers. |
| `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. |
| `hypnotube` | `fetish-kink` | no | no | Fetish/tube hybrid. |
@@ -35,6 +38,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `okporn` | `mainstream-tube` | no | no | Simple mainstream archive. |
| `okxxx` | `mainstream-tube` | no | no | Mainstream search/archive pattern. |
| `omgxxx` | `studio-network` | yes | no | Best template for sites/networks/stars filter catalogs. |
| `notfans` | `onlyfans` | no | no | HTML scraper for notfans.com (OnlyFans / Fansly / Patreon / Manyvids leak aggregator); site is behind Cloudflare but does NOT issue a JS challenge to direct requests (the shared `Requester` with Chrome 120 emulation gets a 200), so detail-page enrichment is safe and there's no `FLARE_URL` gate. Card parser splits the listing on the literal `<div class="item ">` delimiter (the two trailing spaces match the card class exactly and skip the sponsored `class="item avd"` variant — homoxxx uses the same convention); per card: `<a href="…/videos/{id}/{slug}/" title="…">` for the id/url/title, plus `<img class="thumb " src="…/contents/videos_screenshots/{id3}/{id}/320x180/N.jpg">` for the listing thumb. Feeds: latest `/latest-updates/{N}/` (23/page, last page 30990), most-viewed `/most-popular/{N}/` (24/page), top-rated `/top-rated/{N}/` (24/page), and period tops `/day/{N}/`, `/week/{N}/`, `/month/{N}/`, `/year/{N}/` (all 12-24/page); search `/search/{query}/{N}/`; tag `/tags/{tag}/{N}/` reachable via the `tag:`/`category:`/`tags:` query prefix. Per-card enrichment (page 1 only, `buffer_unordered(4)`, 8s timeout) fetches the detail page `…/videos/{id}/{slug}/` and pulls: `Duration: <em>HH:MM:SS</em>``parse_time_to_seconds`, `Views: <em>N</em>` → u32, `Tags: <a href="/tags/…">name</a>…</div>` block (regex over `/tags/…/>(name)</a>`), `Submitted: <em>N (minute|hour|day|week|month|year)s? ago</em>` → relative unix timestamp (`Yesterday`/`just now` are handled), `<meta property="og:image" content="…/preview.jpg">` for `preview` (the 720p still used by the player), and the first `Download:` link matching `get_file/6/{hash}/{id3}/{id}/{id}.mp4/?…` for the format URL (a `Referer: https://notfans.com/` and a matching `User-Agent` are attached as `http_headers`; the URL serves a 302 to `big1.phoenixcdn.lol/remote_control.php?…&file=…/{id}.mp4` which 200s `Content-Type: video/mp4` with the bytes — the 302 is followed automatically by clients, no proxy needed). 720p is the only available quality (the site shows one `MP4 720p, N Mb` row). Listing pages are cached 15 min in `VideoCache`. The site has no `/uploaders/{name}/` route and no `categories` system (the tag cloud is a 50-entry footer list dominated by stop-words like "a"/"and"/"the", and `/categories/` is a 0-item stub), so no `/api/uploaders` and no category/tag filter option. No proxy. |
| `paradisehill` | `mainstream-tube` | no | no | Simple page scraper. |
| `perfectgirls` | `studio-network` | no | no | Studio archive. |
| `perverzija` | `studio-network` | no | no | WordPress (Generatepress/quick-view) HTML scraper for tube.perverzija.com (mirrors top-tier studio releases: MommysGirl, PureTaboo, VXN, AdultTime, etc). Default feed `/` (page N: `/page/N/`), `featured=featured` option swaps in `featured-scenes/` prefix before the page segment. Search is native WordPress `/?s=query` (page N: `/page/N/?s=query`), but `tag:`/`stars:`/`studio:`/`genre:` query prefixes and a background-learned title→slug map (populated from `studio`/`stars`/`tag`/`genre` links seen on every listing/detail page, normalized lowercase) route a bare keyword straight to the matching `/{kind}/{slug}/page/N/` archive when it matches — e.g. `milf` resolves via the learned `stars-milf` class to `/stars/milf/` rather than falling back to `?s=milf`. Listing cards (`class="video-item post..."`) carry everything needed (`data-url` = canonical WP detail page, `data-embed`-encoded `<iframe src="…xtremestream.xyz/player/index.php?data=ID">`, `tag-*`/`stars-*` CSS classes, `class="time_dur"` duration, studio link) so the default/tag/page feeds need no per-item enrichment; only the generic `?s=` search path is card-light (no embed/tags inline) and fetches each detail page concurrently (`futures::join_all`) for the iframe src plus `Studio:`/`Stars:`/`Tags:`/`Genres:` `<strong>` blocks — resolved detail data is cached in the `videos` SQLite table (`db::insert_video`/`get_video`) keyed by the WP page URL to skip re-fetching on repeat searches. `video.url` is the WordPress detail page (`data-url`/card href) — confirmed NOT yt-dlp-resolvable (the player loads via the quick-view AJAX iframe, not static HTML) — so `formats` carries the real media: the embed `index.php` path is rewritten to `xs1.php` (returns an HLS `.m3u8` master) and requires `Referer: https://xtremestream.xyz/` on every request (segments and master alike) or the host CDN returns `403`; **regression note**: an earlier revision set `video.url` to this same `xs1.php` media link, which then 403'd in `check.py`/any plain `GET` because the bare `url` field carries no header contract — fixed by separating `video.url` (page) from `formats[0].url` (media+Referer). Thumbnails (`tube.perverzija.com/wp-content/uploads/...jpg`) load directly, no proxy/referer. No `/api/uploaders` (studio/stars are folded into `tags`, no dedicated profile page schema confirmed). No proxy needed. |

View File

@@ -2,6 +2,40 @@
This is the implementation checklist for adding a working channel with the least guessing.
## Best Practices (Living)
These rules cut across every step below. They are intentionally grouped at the top so the rest of the playbook can reference them, and they are **living** — add, refine, or reorder as the codebase learns new failure modes. The provider catalog (`docs/provider-catalog.md`) is the ground truth for which patterns have actually held up in production; if a rule here contradicts a catalog note, the catalog wins and this list gets updated.
### Investigation
- **Always use an up-to-date client with a real TLS fingerprint when investigating a site.** A `curl` paste from a terminal looks fine and then 403s the moment the real fetcher goes near it. Use the same `wreq` emulation stack the server ships with (currently `Emulation::Firefox151` in the shared `Requester`, `Emulation::Firefox136` / `Chrome120` in older per-provider paths) and confirm a plain `wreq` GET against the live host returns 200 *before* trusting any URL or header you read off a page. If the investigation client differs from the production client, every "obvious" header you copy is suspect.
- Start from the listing page, not the detail page or the network tab of a single video. The listing tells you the real card shape, pagination, sort, and tag surfaces. Detail pages lie about what is enumerable.
- Confirm each URL shape with at least two pages: home, search page 2, one tag archive, one uploader archive, one detail. A pattern that holds for one page often breaks on the second.
- Record a real browser session only when `wreq` cannot reach the site. If the shared requester plus Jina/FlareSolverr can fetch the page, the production path is the one you are investigating.
### Parser Discipline
- Prefer `serde_json` over regex/HTML scraping whenever the site exposes a JSON shape (Next.js `__NEXT_DATA__`, JSON-LD, hydration blobs, `RSC` payloads). When the site ships an API, scrape the API, not the rendered DOM.
- Treat card metadata as the source of truth for `id`, `title`, `url`, `thumb`, `duration`. Only enrich detail pages for fields the card does not expose, and bound the concurrency (`futures::stream` + `buffer_unordered`).
- Keep `title` text, `id` slug, and uploader identity as separate fields. A mushed title is a debugging nightmare.
### Networking
- Use the shared `Requester` from `ServerOptions`. Local clients drop cookies, lose Burp proxying, and bypass the Jina / FlareSolverr fallbacks. The only legitimate reason to build a fresh `wreq::Client` is a one-off TLS requirement that the shared emulation cannot meet (e.g. a specific Chrome JA3 that the r2.1hanime.com / nhplayer path needs).
- After every fix to a CF/JA3 block, re-validate with `wreq` on the production emulation, not with `curl`. A/B probe with both the old and new fingerprint so you do not confuse a coincidence with a fix.
### Output Shape
- 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.
### Operational
- Add new providers to `build.rs` first, then write the file. A missing entry compiles fine and silently drops the channel.
- After any code change, run `cargo check -q` once for the full build, then `HOT_TUB_PROVIDER=<id> cargo check -q` for the single-provider build. If only the second runs, you will not catch a regression that lives in another provider.
- After modifying the code, refresh the knowledge graph with `graphify update .` so the next investigator can find what you just added without re-deriving it.
## Definition Of Done
A provider is not done when it compiles. It is done when: