diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py
index 1f075e4..1f40a6e 100644
--- a/app/connectors/direct_scrapers/__init__.py
+++ b/app/connectors/direct_scrapers/__init__.py
@@ -43,6 +43,7 @@ from app.connectors.direct_scrapers.youporn_browse import YouPornBrowseScraper
from app.connectors.direct_scrapers.siska import SiskaScraper
from app.connectors.direct_scrapers.sxyland import SxyLandScraper
from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper
+from app.connectors.direct_scrapers.fullvideosporn import FullVideosPornScraper
from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
@@ -171,6 +172,12 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
PornBusyScraper,
+ # FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
+ # = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
+ # katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
+ # sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
+ # Tytuł z `vit` playera (og:title to AI-spin SEO), bramka cookie 429 na fetchach.
+ FullVideosPornScraper,
# Browse równolegle do istniejącego search scrapera (wzorzec xvideos/eporner):
# search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość
# wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request).
diff --git a/app/connectors/direct_scrapers/fullvideosporn.py b/app/connectors/direct_scrapers/fullvideosporn.py
new file mode 100644
index 0000000..8ce4867
--- /dev/null
+++ b/app/connectors/direct_scrapers/fullvideosporn.py
@@ -0,0 +1,204 @@
+"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ.
+
+To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
+pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
+
+**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
+obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
+Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
+Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem — wtedy sygnał jest dobry: 88-89%
+nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
+~25-35% katalogu (~150-220k scen), czyli i tak dużo.
+
+Pozostałe pułapki, wszystkie obsłużone niżej:
+ - **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** — te bywają
+ AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
+ tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
+ - **obsadę czytamy TYLKO z `
Porn-stars: …
`** — na całej stronie
+ jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
+ - sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE ``, więc
+ parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
+ - listing wymaga bramki cookie (429 → challenge → retry), stąd własny `crawl_page`
+ zamiast domyślnego `browser_get` z bazy.
+
+Playback: extractor `fullvideosporn` (TXXX → get_file → 302 → znvcdn, portable cross-IP).
+"""
+from __future__ import annotations
+
+import html
+import logging
+import re
+from datetime import UTC, datetime, timedelta
+
+from app.connectors.base import (
+ RawPerformer,
+ RawPlaybackSource,
+ RawScene,
+ RawStudio,
+ RawTag,
+)
+from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
+from app.extractors.tubes.fullvideosporn import _new_session, gated_get
+from app.normalize.text import slugify
+
+log = logging.getLogger(__name__)
+
+_BASE = "https://fullvideosporn.com"
+
+_SCENE_URL_RE = re.compile(r'href="(/en/video/(\d+)/[a-z0-9\-_]+/)"', re.IGNORECASE)
+_VIT_RE = re.compile(r'vit:\s*"([^"]+)"')
+_OGTITLE_RE = re.compile(r'property="og:title" content="([^"]+)"', re.IGNORECASE)
+_DUR_RE = re.compile(r'og:video:duration" content="([0-9]+)"', re.IGNORECASE)
+_THUMB_RE = re.compile(r'property="og:image" content="([^"]+)"', re.IGNORECASE)
+# Każda sekcja to osobny
+_SECTION_RE = re.compile(r'(.*?)
', re.IGNORECASE | re.DOTALL)
+_ANCHOR_RE = re.compile(r">([^<>]{2,60})")
+_SUBMITTED_RE = re.compile(
+ r"Submitted:\s*\s*(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago",
+ re.IGNORECASE,
+)
+_UNIT_DAYS = {
+ "second": 0, "minute": 0, "hour": 0,
+ "day": 1, "week": 7, "month": 30, "year": 365,
+}
+
+
+def _submitted_to_date(detail_html: str):
+ """`Submitted: 3 days ago` → data. Strona nie podaje daty wprost, a
+ to jest data uploadu na tube (nie premiery studia) — traktujemy jak inne tuby."""
+ m = _SUBMITTED_RE.search(detail_html)
+ if not m:
+ return None
+ n, unit = int(m.group(1)), m.group(2).lower()
+ days = n * _UNIT_DAYS.get(unit, 0)
+ return (datetime.now(UTC) - timedelta(days=days)).date()
+
+
+class FullVideosPornScraper(BaseBrowseScraper):
+ sitetag = "fullvideosporn"
+
+ def _listing_url(self, page: int) -> str:
+ return f"{_BASE}/en/videos.php?p={page}&s=l"
+
+ def _extract_scene_urls(self, listing_html: str) -> list[str]:
+ seen: set[str] = set()
+ out: list[str] = []
+ for m in _SCENE_URL_RE.finditer(listing_html):
+ url = _BASE + m.group(1)
+ if url not in seen:
+ seen.add(url)
+ out.append(url)
+ return out
+
+ def crawl_page(self, page: int) -> list[RawScene] | None:
+ """Własna implementacja, bo listing i detale wymagają bramki cookie (429 →
+ challenge → retry) i wspólnej sesji, czego `browser_get` z bazy nie robi."""
+ session = _new_session()
+ r = gated_get(session, self._listing_url(page), timeout=self._timeout)
+ if r is None or r.status_code != 200:
+ log.warning(
+ "fullvideosporn listing page=%d status=%s", page, getattr(r, "status_code", None)
+ )
+ return None
+ urls = self._extract_scene_urls(r.text)
+ if not urls:
+ return []
+
+ out: list[RawScene] = []
+ gated = 0
+ for scene_url in urls:
+ d = gated_get(session, scene_url, timeout=self._timeout)
+ if d is None or d.status_code != 200:
+ continue
+ try:
+ raw = self._parse_detail(scene_url, d.text)
+ except Exception as e:
+ log.warning("fullvideosporn detail parse failed %s: %s", scene_url, e)
+ continue
+ if raw is None:
+ gated += 1
+ continue
+ out.append(raw)
+ if gated:
+ log.info(
+ "fullvideosporn page=%d: %d/%d scen pominietych (brak obsady)",
+ page, gated, len(urls),
+ )
+ return out
+
+ def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
+ # Sekcje metadanych — każda w swoim .
+ performers: list[RawPerformer] = []
+ tags: list[RawTag] = []
+ studio: RawStudio | None = None
+ seen_p: set[str] = set()
+ seen_t: set[str] = set()
+
+ for sec in _SECTION_RE.findall(detail_html):
+ label = re.sub(r"<[^>]+>", " ", sec).strip().lower()
+ names = [html.unescape(x).strip() for x in _ANCHOR_RE.findall(sec)]
+ if label.startswith("porn-stars"):
+ for name in names:
+ sl = slugify(name)
+ if sl and sl not in seen_p:
+ seen_p.add(sl)
+ performers.append(
+ RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
+ )
+ elif label.startswith("porn site"):
+ if names and studio is None:
+ sname = names[0]
+ studio = RawStudio(
+ external_id=f"{self.sitetag}:studio:{slugify(sname)}",
+ name=sname,
+ slug=slugify(sname),
+ )
+ elif label.startswith("porn categories"):
+ for name in names:
+ sl = slugify(name)
+ if sl and sl not in seen_t:
+ seen_t.add(sl)
+ tags.append(
+ RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
+ )
+
+ # BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to
+ # spunowane tytuły bez performera, które weszłyby jako puste orphany).
+ if not performers:
+ return None
+
+ # Tytuł z playera; og:title/h1 bywa AI-owym spinem SEO.
+ tm = _VIT_RE.search(detail_html)
+ title = html.unescape(tm.group(1)).strip() if tm else ""
+ if not title:
+ om = _OGTITLE_RE.search(detail_html)
+ title = html.unescape(om.group(1)).strip() if om else ""
+ if not title:
+ return None
+
+ dm = _DUR_RE.search(detail_html)
+ duration_sec = int(dm.group(1)) if dm else None
+ thm = _THUMB_RE.search(detail_html)
+ thumbnail_url = thm.group(1) if thm else None
+
+ # Tag == nazwisko performera (np. „Octavia Red" bywa i kategorią) → wytnij.
+ tags = [t for t in tags if t.slug not in seen_p]
+
+ return RawScene(
+ external_id=f"{self.sitetag}:{scene_url}",
+ title=title,
+ release_date=_submitted_to_date(detail_html),
+ duration_sec=duration_sec,
+ url=scene_url,
+ studio=studio,
+ performers=performers,
+ 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 2d4a625..9e7c805 100644
--- a/app/extractors/__init__.py
+++ b/app/extractors/__init__.py
@@ -30,6 +30,7 @@ from app.extractors.tubes import (
eporner,
freshporno,
fullmovies,
+ fullvideosporn,
galaxyporn,
hdporngg,
hqfap,
@@ -117,6 +118,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
"pornbusycom": pornbusy.extract,
+ # fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
+ # Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
+ # (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
+ "fullvideosporn": fullvideosporn.extract,
"siskavideo": _embed_iframe.extract,
"porn4dayspw": _embed_iframe.extract,
"porndishcom": _embed_iframe.extract,
diff --git a/app/extractors/tubes/_txxx.py b/app/extractors/tubes/_txxx.py
new file mode 100644
index 0000000..3ddb042
--- /dev/null
+++ b/app/extractors/tubes/_txxx.py
@@ -0,0 +1,39 @@
+"""Wspólne elementy sieci TXXX (vjav, fullvideosporn/sextu, …).
+
+Silnik TXXX oddaje URL pliku przez `GET /api/videofile.php?video_id=&lifetime=N`
+w polu `video_url`, zaciemnionym DWIEMA warstwami:
+ 1. wielkie/małe litery łacińskie podmienione na cyrylickie homoglify (М→M, С→C, …),
+ 2. custom alfabet base64: `,`→`/`, `~`→`=`, `-`→`+`.
+
+Po odkręceniu obu i b64decode dostajemy `/get_file/...` (czasem absolutny URL).
+Wyniesione tutaj, żeby vjav i fullvideosporn nie trzymały dwóch kopii dekodera.
+"""
+from __future__ import annotations
+
+import base64
+
+# Cyrylickie homoglify → łacina. Bez tego b64decode dostaje śmieci.
+HOMOGLYPHS = str.maketrans(
+ {
+ "А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M",
+ "О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y",
+ "а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y",
+ }
+)
+
+
+def decode_video_url(obfuscated: str) -> str | None:
+ """Zaciemniony `video_url` → ścieżka/URL `get_file`. None gdy dekod padnie."""
+ if not obfuscated:
+ return None
+ clean = (
+ obfuscated.translate(HOMOGLYPHS)
+ .replace(",", "/")
+ .replace("~", "=")
+ .replace("-", "+")
+ )
+ clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4
+ try:
+ return base64.b64decode(clean).decode("utf-8", "ignore")
+ except Exception:
+ return None
diff --git a/app/extractors/tubes/fullvideosporn.py b/app/extractors/tubes/fullvideosporn.py
new file mode 100644
index 0000000..5210739
--- /dev/null
+++ b/app/extractors/tubes/fullvideosporn.py
@@ -0,0 +1,144 @@
+"""fullvideosporn.com (= sextu.com pod nową marką) — TXXX network, direct mp4.
+
+To NIE jest klon fullmovies.xxx (inny silnik, inny katalog — 0/140 pokrycia tytułów).
+
+**Bramka cookie**: pierwszy request z nowej sesji dostaje HTTP 429 + 342-bajtowy JS
+challenge ustawiający ciasteczko (`document.cookie = 'PxeA3f=; max-age=600'`).
+Wystarczy wyregexować parę nazwa=wartość i powtórzyć request — bez proxy, bez
+przeglądarki. Ciasteczko żyje 600 s, więc trzymamy je w sesji modułu.
+
+Stream (identyczny jak vjav, rodzina TXXX):
+ GET /api/videofile.php?video_id=&lifetime=8640000
+ → [{"format":"_lq.mp4","video_url":"",...}]
+ dekod (`_txxx.decode_video_url`) → `/get_file/...` (relatywny, prepend host)
+ GET get_file bez follow → 302 → `https://sextuN.znvcdn.com/t=.../....mp4`
+
+`_lq.mp4` w nazwie myli — to realnie 1280x720 (~1,29 Mbps), jedyny dostępny format.
+
+**Cross-IP**: finalny URL CDN wybity z IP VPS gra z residential (206, `video/mp4`,
+bez Referera) → token NIE jest IP-bound, `mobile_direct_ok`. ALE sam VPS dostaje od
+CDN 429 (reputacja IP datacenter), więc **health-check playbacku z VPS będzie
+fałszywie negatywny** — nie traktuj tego jako regresji.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import re
+from urllib.parse import urlparse
+
+from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI
+from app.extractors._models import StreamSource
+from app.extractors.tubes import _txxx
+
+log = logging.getLogger(__name__)
+
+_BASE = "https://fullvideosporn.com"
+_VIDEO_ID_RE = re.compile(r"/video/(\d+)/")
+# `