diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py
index 10d06da..05b810b 100644
--- a/app/connectors/direct_scrapers/__init__.py
+++ b/app/connectors/direct_scrapers/__init__.py
@@ -51,6 +51,7 @@ from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
from app.connectors.direct_scrapers.hentaimama import HentaiMamaScraper
from app.connectors.direct_scrapers.hentaigasm import HentaiGasmScraper
from app.connectors.direct_scrapers.hentaiplay import HentaiPlayScraper
+from app.connectors.direct_scrapers.rule34video import Rule34VideoScraper
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
@@ -218,6 +219,10 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# (hentaiplanet.info). Metadane z RSS-a, bo /genre/ w HTML to 2084 linki chmury,
# najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy.
HentaiPlayScraper,
+ # Rule34VideoScraper — dodany 2026-08-13, pierwsze zrodlo 3D (SFM/Blender).
+ # Bramka >=60s odrzuca ~47% katalogu (mediana 68s), bo apka i tak filtruje
+ # ponizej minuty. Tagi TYLKO z class=tag_item, `class=button` to chmura.
+ Rule34VideoScraper,
# FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
diff --git a/app/connectors/direct_scrapers/rule34video.py b/app/connectors/direct_scrapers/rule34video.py
new file mode 100644
index 0000000..0488cca
--- /dev/null
+++ b/app/connectors/direct_scrapers/rule34video.py
@@ -0,0 +1,168 @@
+"""rule34video.com — browse scraper. Piąte źródło wertykału, pierwsze 3D. Dodany 2026-08-13.
+
+Inna charakterystyka niż cztery strony hentai ([[hentaimama]], [[xanimeporn]],
+[[hentaigasm]], [[hentaiplay]]): to nie są odcinki serii, tylko pojedyncze animacje
+SFM/Blender robione przez autorów. Kanon nie istnieje tak samo jak przy hentai, więc
+każda pozycja jest orphanem i to poprawne, bez obsady i bez canonical matcha.
+
+**Krótka treść wycina ~70% katalogu.** Rozkład 30 najnowszych: mediana 68 s, 14 pozycji
+poniżej minuty, najkrótsza 7 s. Dla animacji SFM taka pętla to kompletna praca, a nie
+wycinek (inaczej niż przy sexu, gdzie klipy były okrojoną wersją scen istniejących gdzie
+indziej), ale nie mieści się w jednostce treści, na której stoi reszta systemu.
+
+Bramkujemy tym samym progiem co potok (`min_ingest_duration_sec`, na prodzie 180 s),
+zamiast własną liczbą. Pierwsza wersja miała 60 s (tyle filtruje apka) i przez to liczniki
+kłamały: pilot na 3 stronach dał `seen 57, new 31, skipped 26`, bo `_skip_short_tube_scene`
+i tak dorzynał wszystko poniżej 3 minut. Przy zestrojonym progu `seen` mówi, ile realnie
+wchodzi, a to jest sygnał, którego używa watchdog i `source_stats`.
+
+**Pułapka tagów, ODWROTNA do intuicji.** Na stronie są dwa zestawy anchorów `/tags/`:
+
+ cell (dbz) ← chmura „Top Tags", 25 szt.
+ handjob ← realne tagi filmu, 4 szt.
+
+`class="button"` wygląda na „ten właściwy", a jest chmurą z sidebara: przy filmie o Lux
+z League of Legends siedzą tam „cell (dbz)" i „queen of pain (dota)". Bierzemy WYŁĄCZNIE
+`tag_item`, inaczej każdy film dostałby ten sam zestaw 25 śmieci.
+
+Autor i kategorie mają własną klasę `video_meta_pill` i występują po 1-2 razy, czyli tam
+chmury nie ma. **Autora zapisujemy jako studio**, bo dla 3D to on jest wytwórcą
+(Monarchnsfw, jackerman, derpixon), a nie żaden performer.
+
+Paginacja `/latest-updates/N/` jest chronologiczna i sprawdzona: strony 1, 2 i 3 mają
+zerowe pokrycie (w odróżnieniu od freshporno czy streamporn.vip, gdzie listing rotował).
+
+Playback: `get_file` z tokenem ZWIĄZANYM Z IP, patrz extractor `rule34video`.
+"""
+from __future__ import annotations
+
+import html as html_mod
+import logging
+import re
+from datetime import date
+
+from app.connectors.base import RawPlaybackSource, RawScene, RawStudio, RawTag
+from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
+from app.config import get_settings
+from app.normalize.text import slugify
+
+log = logging.getLogger(__name__)
+
+_BASE = "https://rule34video.com"
+
+#: Fallback, gdy `min_ingest_duration_sec` jest wyłączone (0): tyle filtruje apka.
+_APP_MIN_DURATION_SEC = 60
+
+
+def _min_duration() -> int:
+ """Próg zestrojony z potokiem, patrz docstring. Czytamy ustawienie zamiast wpisywać
+ liczbę, żeby zmiana `min_ingest_duration_sec` nie rozjechała się z tym scraperem."""
+ return getattr(get_settings(), "min_ingest_duration_sec", 0) or _APP_MIN_DURATION_SEC
+
+
+_SCENE_HREF_RE = re.compile(r'href="(https?://rule34video\.com/video/\d+/[^"]+)"')
+_TITLE_RE = re.compile(r'
\s*([^<]{1,200}?)\s*
')
+_DURATION_RE = re.compile(r'"duration"\s*:\s*"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"')
+_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
+_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*\[?\s*"([^"]{10,300})"')
+#: TYLKO `tag_item`, patrz odwrócona pułapka w docstringu.
+_TAG_RE = re.compile(r']*href="[^"]*/tags/\d+/"[^>]*>\s*([^<]{1,60}?)\s*')
+#: Autor i kategorie — `video_meta_pill`, nazwa bywa opakowana w zagnieżdżone tagi.
+_PILL_RE = re.compile(
+ r']*href="[^"]*/(models|categories)/([a-z0-9-]+)/"[^>]*>(.*?)',
+ re.DOTALL,
+)
+_STRIP_TAGS_RE = re.compile(r"<[^>]+>")
+
+
+def _text(fragment: str) -> str:
+ return html_mod.unescape(_STRIP_TAGS_RE.sub(" ", fragment)).strip()
+
+
+def _duration(detail_html: str) -> int | None:
+ m = _DURATION_RE.search(detail_html)
+ if not m:
+ return None
+ hours, minutes, seconds = (int(x or 0) for x in m.groups())
+ total = hours * 3600 + minutes * 60 + seconds
+ return total or None
+
+
+class Rule34VideoScraper(BaseBrowseScraper):
+ sitetag = "rule34video"
+
+ def _listing_url(self, page: int) -> str:
+ return f"{_BASE}/latest-updates/" if page <= 1 else f"{_BASE}/latest-updates/{page}/"
+
+ def _extract_scene_urls(self, listing_html: str) -> list[str]:
+ return list(dict.fromkeys(_SCENE_HREF_RE.findall(listing_html)))
+
+ def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
+ tm = _TITLE_RE.search(detail_html)
+ if not tm:
+ return None
+ title = html_mod.unescape(tm.group(1)).strip()
+ if not title:
+ return None
+
+ duration_sec = _duration(detail_html)
+ if duration_sec is None or duration_sec < _min_duration():
+ return None # krótka pętla, potok i tak by ją odrzucił jako trailer
+
+ release_date = None
+ dm = _UPLOAD_DATE_RE.search(detail_html)
+ if dm:
+ try:
+ release_date = date(int(dm.group(1)), int(dm.group(2)), int(dm.group(3)))
+ except ValueError:
+ pass
+
+ thumbnail_url = None
+ im = _THUMB_RE.search(detail_html)
+ if im:
+ thumbnail_url = im.group(1).strip().replace("\\/", "/")
+
+ tags: list[RawTag] = []
+ seen_tag: set[str] = set()
+ for raw_name in _TAG_RE.findall(detail_html):
+ name = html_mod.unescape(raw_name).strip()
+ slug = slugify(name)
+ if slug and slug not in seen_tag:
+ seen_tag.add(slug)
+ tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
+
+ # Autor animacji → studio (dla 3D to wytwórca, nie performer). Kategorie → tagi.
+ studio = None
+ for kind, _slug_in_url, inner in _PILL_RE.findall(detail_html):
+ name = _text(inner)
+ slug = slugify(name)
+ if not name or not slug:
+ continue
+ if kind == "models":
+ if studio is None:
+ studio = RawStudio(
+ external_id=f"{self.sitetag}:studio:{slug}", name=name, slug=slug
+ )
+ elif slug not in seen_tag:
+ seen_tag.add(slug)
+ tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
+
+ video_id = scene_url.rstrip("/").split("/video/")[-1].split("/")[0]
+ return RawScene(
+ external_id=f"{self.sitetag}:{video_id}",
+ title=title,
+ release_date=release_date,
+ duration_sec=duration_sec,
+ url=scene_url,
+ studio=studio,
+ performers=[], # animacja, obsada nie istnieje
+ tags=tags,
+ playback_sources=[
+ RawPlaybackSource(
+ origin=f"tube:{self.sitetag}",
+ page_url=scene_url,
+ duration_sec=duration_sec,
+ thumbnail_url=thumbnail_url,
+ )
+ ],
+ )
diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py
index e8eede5..3c228aa 100644
--- a/app/extractors/__init__.py
+++ b/app/extractors/__init__.py
@@ -40,6 +40,7 @@ from app.extractors.tubes import (
hentaigasm,
hentaiplay,
hentaimama,
+ rule34video,
xanimeporn,
kporner8,
sexu,
@@ -149,6 +150,11 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# xanimeporn — Fluid Player z PUSTYM , wideo wstrzykiwane w runtime.
# Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=.
# Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu.
+ # rule34video — KVS z JAWNYMI flashvars (4 jakosci), ale `v-acctoken` jest
+ # ZWIAZANY Z IP tego, kto pobral strone (test krzyzowy: proxy→VPS = 403).
+ # Dlatego BEZ mobile_direct_ok: telefon resolwuje sam, upfront, przez
+ # isGetFileUrl()/getfileResolver, tak jak hdporn.gg i fullmovies.xxx.
+ "rule34video": rule34video.extract,
"xanimeporn": xanimeporn.extract,
# fullporner, iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
diff --git a/app/extractors/tubes/rule34video.py b/app/extractors/tubes/rule34video.py
new file mode 100644
index 0000000..e8d92da
--- /dev/null
+++ b/app/extractors/tubes/rule34video.py
@@ -0,0 +1,69 @@
+"""rule34video.com — KVS `get_file` ZWIĄZANY Z IP. Dodany 2026-08-13.
+
+Silnik to KVS, ale bez zaciemniania: flashvars trzymają cztery gotowe URL-e, bez
+`function/0/` i bez potrzeby dekodowania `license_code`:
+
+ video_url: …/get_file/51//4543000/4543893/4543893_360.mp4/?v-acctoken=…
+ video_alt_url: … _480p.mp4 (video_alt_url_text: '480p')
+ video_alt_url2: … _720p.mp4 (video_alt_url2_text: '720p')
+ video_alt_url3: … _1080p.mp4 (video_alt_url3_text: '1080p')
+
+**`v-acctoken` jest związany z IP tego, kto pobrał STRONĘ.** Test krzyżowy 2026-08-13:
+URL wygenerowany przez proxy i pobrany z VPS-a → **403**; ten sam URL pobrany z IP, które
+go wygenerowało → 206, `ftypisom`, 70 MB. Po zdekodowaniu (`4533|163838|0|`) adresu
+nie widać wprost, w odróżnieniu od fpoxxx, gdzie IP siedziało w treści tokenu — czyli
+samo oglądanie tokenu NIE wystarcza, żeby to wykryć, trzeba zrobić test krzyżowy.
+
+Dlatego **NIE ma tu `mobile_direct_ok`**: URL zresolwowany na VPS-ie jest dla telefonu
+martwy. Zamiast tego oddajemy go jako zwykły link `get_file`, a resztę robi istniejąca
+maszyneria po stronie telefonu: `SceneDetailScreen` wykrywa `isGetFileUrl()` i JESZCZE
+PRZED wejściem do playera pobiera stronę z telefonu (token dla IP telefonu), a
+`getfileResolver` wyciąga z niej flashvars. Ten sam mechanizm obsługuje hdporn.gg
+i fullmovies.xxx, tam różnicą jest tylko `` zamiast flashvars.
+
+Backend zwraca więc URL-e po to, żeby apka znała listę jakości i etykiety, nie po to,
+żeby je odtwarzać z VPS-a.
+"""
+from __future__ import annotations
+
+import logging
+import re
+
+from app.extractors._fetch import fetch_tube_html
+from app.extractors._models import StreamSource
+
+log = logging.getLogger(__name__)
+
+_BASE = "https://rule34video.com"
+_URL_RE = re.compile(r"(video_(?:alt_)?url\d*)\s*:\s*'(https?://[^']+\.mp4[^']*)'")
+_LABEL_RE = re.compile(r"(video_(?:alt_)?url\d*)_text\s*:\s*'([^']{1,12})'")
+#: Kolejność od najlepszej jakości. 4K nie występuje, serwis kończy na 1080p.
+_QUALITY_RANK = {"1080p": 0, "720p": 1, "480p": 2, "360p": 3}
+
+
+def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
+ html_text = fetch_tube_html(page_url, timeout=timeout)
+ labels = dict(_LABEL_RE.findall(html_text))
+
+ out: list[StreamSource] = []
+ seen: set[str] = set()
+ for var, link in _URL_RE.findall(html_text):
+ if link in seen:
+ continue
+ seen.add(link)
+ out.append(
+ StreamSource(
+ link=link,
+ type="mp4",
+ quality=labels.get(var),
+ referer=_BASE + "/",
+ # Bez `mobile_direct_ok`: token jest IP-bound, telefon resolwuje sam.
+ raw={},
+ )
+ )
+ if not out:
+ log.info("rule34video: brak video_url we flashvars na %s", page_url)
+ return None
+
+ out.sort(key=lambda s: _QUALITY_RANK.get((s.quality or "").lower(), 99))
+ return out
diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts
index ec839f8..76d7d46 100644
--- a/mobile/src/changelog.ts
+++ b/mobile/src/changelog.ts
@@ -16,6 +16,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ id: '2026-08-13',
+ date: 'August 2026',
+ items: [
+ 'New: animated and 3D content. Five sources have been added, four hentai and one 3D animation, each with its own catalogue rather than reposts of the others.',
+ 'Playback for the 3D source is resolved on your phone, so you get the full quality ladder up to 1080p instead of a stripped-down stream.',
+ ],
+ },
{
id: '2026-08-11',
date: 'August 2026',
diff --git a/mobile/src/lib/getfileResolver.ts b/mobile/src/lib/getfileResolver.ts
index 4948b16..233d762 100644
--- a/mobile/src/lib/getfileResolver.ts
+++ b/mobile/src/lib/getfileResolver.ts
@@ -23,6 +23,14 @@ export function isGetFileUrl(url: string): boolean {
const _SOURCE_RE = /]*?(?:title|label)=['"]?([^'">]*)/gi;
const _SKIP_Q = /2160|1440|4k/i;
+// rule34video (KVS) NIE ma — cztery jakości siedzą we flashvars playera:
+// video_url: '…/get_file/…_360.mp4…' video_url_text: '360p'
+// video_alt_url2: '…_720p.mp4…' video_alt_url2_text: '720p'
+// Token `v-acctoken` jest zbindowany do IP tego, kto pobrał stronę, więc dokładnie tak
+// samo jak przy hdporn.gg musi to zrobić telefon, nie VPS.
+const _FLASHVAR_URL_RE = /(video_(?:alt_)?url\d*)\s*:\s*'([^']+\/get_file\/[^']+\.mp4[^']*)'/g;
+const _FLASHVAR_LABEL_RE = /(video_(?:alt_)?url\d*)_text\s*:\s*'([^']{1,12})'/g;
+
/**
* Pełny resolve dla hdporn.gg/fullmovies.xxx OD STRONY (nie od get_file URL).
*
@@ -62,6 +70,33 @@ export async function resolveGetFilePage(
}
if (!pick) pick = u; // pierwszy nie-4K jako fallback
}
+
+ // Brak → spróbuj flashvars (rule34video). Etykiety leżą w osobnych polach
+ // `_text`, więc najpierw je zbieramy, potem dopasowujemy po nazwie zmiennej.
+ if (!pick) {
+ const labels = new Map();
+ let lm: RegExpExecArray | null;
+ _FLASHVAR_LABEL_RE.lastIndex = 0;
+ while ((lm = _FLASHVAR_LABEL_RE.exec(html)) !== null) labels.set(lm[1], lm[2].trim());
+ // Najlepsza dostępna jakość jako fallback, gdy backend nie podał preferencji.
+ const rank = (q: string) => ({ '1080p': 0, '720p': 1, '480p': 2, '360p': 3 }[q.toLowerCase()] ?? 99);
+ let best: { url: string; q: string } | null = null;
+ let fm: RegExpExecArray | null;
+ _FLASHVAR_URL_RE.lastIndex = 0;
+ while ((fm = _FLASHVAR_URL_RE.exec(html)) !== null) {
+ const q = labels.get(fm[1]) || '';
+ if (_SKIP_Q.test(q)) continue;
+ let u = fm[2];
+ if (u.startsWith('//')) u = 'https:' + u;
+ if (wantQ && q && q.replace(/\s+/g, '').toLowerCase() === wantQ) {
+ best = { url: u, q };
+ break; // dokładne dopasowanie jakości
+ }
+ if (!best || rank(q) < rank(best.q)) best = { url: u, q };
+ }
+ if (best) pick = best.url;
+ }
+
if (!pick) return null;
const ref = referer || (() => {
try {