diff --git a/app/connectors/__init__.py b/app/connectors/__init__.py
index a4494a3..de14ea1 100644
--- a/app/connectors/__init__.py
+++ b/app/connectors/__init__.py
@@ -40,6 +40,7 @@ def get_movie_connectors() -> list[tuple[str, type]]:
)
from app.connectors.freeomovie import FreeoMovieConnector
from app.connectors.paradisehill import ParadisehillConnector
+ from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content —
@@ -58,4 +59,12 @@ def get_movie_connectors() -> list[tuple[str, type]]:
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
("freeomovie", FreeoMovieConnector),
+ # yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28.
+ # SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
+ # pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
+ # których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
+ # Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
+ # id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
+ # gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
+ ("yourdailypornmovies", YourDailyPornMoviesConnector),
]
diff --git a/app/connectors/yourdailypornmovies.py b/app/connectors/yourdailypornmovies.py
new file mode 100644
index 0000000..5e8b3c3
--- /dev/null
+++ b/app/connectors/yourdailypornmovies.py
@@ -0,0 +1,225 @@
+"""yourdailypornmovies.ws — konektor filmów. Dodany 2026-07-28.
+
+WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod
+`DooplayConnector` jak mangoporn/pandamovies/streamporn.
+
+**Sprawdzony na duplikat przed dodaniem** (2026-07-28), bo poprzedni kandydat
+(speedporn.net) okazał się kolejną nakładką na `pandanetwork.club`, czyli 0 nowych
+filmów. Tutaj z 40 filmów ze strony głównej: 13 mieliśmy po tytule, 9 po id produktu
+Adult Empire, a **18 (45%) było nowych**. Inna pula treści: miniaturki to oficjalne
+okładki DVD z `imgs1cdn.adultempire.com`, nie re-hosty z pandanetwork.
+
+Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga
+2003 roku, strona 1 ma bieżący rok.
+
+**Pułapka: `/tag/` w całym HTML to 2177 linków** — to chmura tagów w sidebarze,
+identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków
+`div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci.
+
+**`/director/` trzyma w praktyce STUDIO**, nie reżysera („New Sensations", „Brazzers").
+Bierzemy to jako studio, bo tak jest użyte, a reżyserów strona i tak nie podaje.
+
+Czego strona nie ma: długości filmu.
+
+Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują
+istniejącymi modułami (`app/extractors/hosters/voe.py` → m3u8, `mixdrop.py` → mp4).
+`vidara.to` jest nam nieznany — zapisujemy go jako zwykły embed, telefon otworzy go
+w WebView, ale nie liczymy na niego.
+"""
+from __future__ import annotations
+
+import html as html_mod
+import logging
+import re
+from collections.abc import Iterator
+from datetime import datetime
+from urllib.parse import urlparse
+
+from app.connectors.base import (
+ BaseMovieConnector,
+ RawMovie,
+ RawPerformer,
+ RawPlaybackSource,
+ RawStudio,
+ RawTag,
+)
+from app.extractors import browser_get
+from app.models.source import SourceKind
+from app.normalize.text import slugify
+
+log = logging.getLogger(__name__)
+
+_BASE = "https://yourdailypornmovies.ws"
+
+# Karta listingu: id produktu Adult Empire w miniaturce + URL + tytuł + rok.
+_CARD_RE = re.compile(
+ r'
(?P[^<]+)
\s*
(?P\d{4})',
+ re.DOTALL,
+)
+
+# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd — patrz pułapka w docstringu.
+_XMLL_RE = re.compile(r'
.*?
', re.DOTALL)
+_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)')
+_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)')
+_DIRECTOR_RE = re.compile(r'href="[^"]*/director/[^"]+"[^>]*>([^<]+)')
+_YEAR_RE = re.compile(r'href="[^"]*/fecha-estreno/(\d{4})/"')
+_OG_DESC_RE = re.compile(r'
]+src="((?:https?:)?//[^"]+)"', re.IGNORECASE)
+
+# Hosty w iframe, które NIE są odtwarzaczem (reklamy).
+_AD_HOSTS = ("realsrv", "magsrv", "exosrv", "juicyads", "google", "doubleclick")
+
+
+def _host_label(url: str) -> str | None:
+ host = (urlparse(url).hostname or "").lower().replace("www.", "")
+ if not host:
+ return None
+ parts = host.split(".")
+ return parts[-2] if len(parts) >= 2 else parts[0]
+
+
+class YourDailyPornMoviesConnector(BaseMovieConnector):
+ kind = SourceKind.scraper
+ name = "yourdailypornmovies"
+ base_url = _BASE
+
+ _MAX_PAGES_DELTA = 3
+ _MAX_PAGES_FULL = 40
+
+ def __init__(self, *, timeout: float = 30.0) -> None:
+ self._timeout = timeout
+
+ def close(self) -> None:
+ pass
+
+ def _fetch(self, url: str) -> str:
+ if not url.startswith("http"):
+ url = _BASE + url
+ r = browser_get(url, timeout=self._timeout, follow_redirects=True)
+ if r.status_code >= 400:
+ raise RuntimeError(f"{r.status_code} for {url}")
+ return r.text
+
+ def fetch_movies(
+ self, *, since: datetime | None = None, limit: int | None = None
+ ) -> Iterator[RawMovie]:
+ seen = 0
+ seen_urls: set[str] = set()
+ max_pages = self._MAX_PAGES_DELTA if since is not None else self._MAX_PAGES_FULL
+ for page in range(1, max_pages + 1):
+ path = "/" if page == 1 else f"/page/{page}/"
+ try:
+ listing = self._fetch(path)
+ except Exception as e:
+ log.warning("%s listing page=%d failed: %s", self.name, page, e)
+ return
+ cards = list(_CARD_RE.finditer(listing))
+ if not cards:
+ log.info("%s: pusta strona=%d, stop", self.name, page)
+ return
+ for m in cards:
+ url = m.group("url")
+ if url in seen_urls:
+ continue
+ seen_urls.add(url)
+ try:
+ movie = self._parse_detail(m, url)
+ except Exception as e:
+ log.warning("%s detail failed %s: %s", self.name, url, e)
+ continue
+ if movie is None:
+ continue
+ yield movie
+ seen += 1
+ if limit is not None and seen >= limit:
+ return
+
+ def _parse_detail(self, card: re.Match[str], url: str) -> RawMovie | None:
+ detail = self._fetch(url)
+ title = html_mod.unescape(card.group("title")).strip()
+ if not title:
+ return None
+
+ # Playback najpierw — bez niego film jest bezużyteczny, nie tworzymy orphana.
+ playback: list[RawPlaybackSource] = []
+ seen_host: set[str] = set()
+ for m in _IFRAME_RE.finditer(detail):
+ embed = m.group(1)
+ if embed.startswith("//"):
+ embed = "https:" + embed
+ host = _host_label(embed)
+ if not host or host in seen_host or any(a in embed for a in _AD_HOSTS):
+ continue
+ seen_host.add(host)
+ playback.append(
+ RawPlaybackSource(
+ origin=f"{self.name}:{host}",
+ page_url=embed,
+ embed_url=embed,
+ )
+ )
+ if not playback:
+ return None
+
+ # Obsada, tagi i studio WYŁĄCZNIE z bloków `div.xmll` (chmura tagów z sidebara
+ # ma 2177 linków i zaśmieciłaby każdy film tym samym zestawem).
+ blocks = " ".join(_XMLL_RE.findall(detail))
+
+ performers: list[RawPerformer] = []
+ seen_perf: set[str] = set()
+ for name in _ACTOR_RE.findall(blocks):
+ name = html_mod.unescape(name).strip()
+ slug = slugify(name)
+ if slug and slug not in seen_perf:
+ seen_perf.add(slug)
+ performers.append(
+ RawPerformer(external_id=f"{self.name}:performer:{slug}", name=name)
+ )
+
+ tags: list[RawTag] = []
+ seen_tag: set[str] = set()
+ for name in _TAG_RE.findall(blocks):
+ name = html_mod.unescape(name).strip()
+ slug = slugify(name)
+ if slug and slug not in seen_tag:
+ seen_tag.add(slug)
+ tags.append(RawTag(external_id=f"{self.name}:tag:{slug}", name=name, slug=slug))
+
+ studio = None
+ dm = _DIRECTOR_RE.search(blocks)
+ if dm:
+ sname = html_mod.unescape(dm.group(1)).strip()
+ if sname and slugify(sname):
+ studio = RawStudio(
+ external_id=f"{self.name}:studio:{slugify(sname)}",
+ name=sname,
+ slug=slugify(sname),
+ )
+
+ ym = _YEAR_RE.search(detail)
+ year = int(ym.group(1)) if ym else int(card.group("year"))
+
+ desc = None
+ om = _OG_DESC_RE.search(detail)
+ if om:
+ desc = html_mod.unescape(om.group(1)).strip() or None
+
+ slug = urlparse(url).path.strip("/").split("/")[-1]
+ return RawMovie(
+ external_id=slug,
+ title=title,
+ description=desc,
+ release_year=year,
+ url=url,
+ poster_url=card.group("poster"),
+ studio=studio,
+ performers=performers,
+ tags=tags,
+ playback_sources=playback,
+ # id produktu Adult Empire — mocniejsza kotwica do dedupu niż tytuł
+ # (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
+ raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
+ )