diff --git a/app/api/scenes.py b/app/api/scenes.py index d48102d..0ad433d 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -442,6 +442,19 @@ def get_scene( return _build_scene_out(session, scene, device_id=device_id) +_SXYPRN_POST_RE = re.compile(r"sxyprn\.com/post/([0-9a-f]{6,40})", re.IGNORECASE) + + +def _sxyprn_thumb_url(page_url: str | None) -> str | None: + """Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera + (`/proxy/sxyprn-thumb/`) zamiast martwego trafficdeposit URL — token żyje ~1h, + więc poster resolvujemy przy serwowaniu (bug 2026-06-10).""" + if not page_url: + return None + m = _SXYPRN_POST_RE.search(page_url) + return f"/proxy/sxyprn-thumb/{m.group(1)}" if m else None + + def _is_rotting_thumb(url: str) -> bool: """sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po ~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze @@ -573,7 +586,12 @@ def _build_scenes_out_batch( thumb_fallback: dict = {} anim_by_scene: dict = {} for sid, thumb, anim, page_url in pb_light: - if thumb: + sxy = _sxyprn_thumb_url(page_url) + if sxy: + # sxyprn → żywy on-demand resolver (martwy stored URL ignorujemy), + # tier fallback: użyty tylko gdy scena nie ma stabilniejszej miniatury. + thumb_fallback.setdefault(sid, (sxy, page_url)) + elif thumb: if _is_rotting_thumb(thumb): thumb_fallback.setdefault(sid, (thumb, page_url)) elif sid not in thumb_by_scene: diff --git a/app/api/stream_proxy.py b/app/api/stream_proxy.py index 5c56279..58b71c7 100644 --- a/app/api/stream_proxy.py +++ b/app/api/stream_proxy.py @@ -335,6 +335,67 @@ async def proxy_image( ) +# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token +# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować — resolvujemy bieżący +# og:image ze strony /post/ przy serwowaniu. Cache resolved poster URL ~40min +# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/ → cache'uje bajty na +# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post. +_SXYPRN_POSTER_CACHE: dict[str, tuple[str, float]] = {} +_SXYPRN_POSTER_TTL = 2400 +_OG_IMG_RE = re.compile(r"og:image[\"'][^>]*content=[\"']([^\"']+)", re.IGNORECASE) +_OG_IMG_RE2 = re.compile(r"content=[\"']([^\"']+)[\"'][^>]*property=[\"']og:image", re.IGNORECASE) +_VID_POSTER_RE = re.compile(r"]*poster=[\"']([^\"']+)", re.IGNORECASE) +_SXYPRN_PID_RE = re.compile(r"^[0-9a-f]{6,40}$") + + +@router.get("/sxyprn-thumb/{post_id}") +async def sxyprn_thumb(post_id: str) -> Response: + """On-demand poster sxyprn. URL stabilny per post_id (klient cache'uje bajty); + backend resolvuje bieżący og:image (token ~1h) i streamuje z Refererem.""" + pid = post_id.split(".")[0] # zdejmij ewentualne .jpg + if not _SXYPRN_PID_RE.match(pid): + raise HTTPException(status_code=400, detail="bad post_id") + now = time.time() + cached = _SXYPRN_POSTER_CACHE.get(pid) + poster = cached[0] if (cached and cached[1] > now) else None + timeout = httpx.Timeout(connect=10.0, read=20.0, write=10.0, pool=5.0) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + if poster is None: + try: + r = await client.get( + f"https://sxyprn.com/post/{pid}.html", headers=_build_headers(None) + ) + except Exception as e: + log.info("sxyprn-thumb page fetch failed %s: %s", pid, e) + return Response(content=b"", status_code=502, media_type="image/jpeg") + html = r.text + if "Post Not Found" in html: + return Response(content=b"", status_code=404, media_type="image/jpeg") + m = _OG_IMG_RE.search(html) or _OG_IMG_RE2.search(html) or _VID_POSTER_RE.search(html) + if not m: + return Response(content=b"", status_code=404, media_type="image/jpeg") + poster = m.group(1).strip() + if poster.startswith("//"): + poster = "https:" + poster + _SXYPRN_POSTER_CACHE[pid] = (poster, now + _SXYPRN_POSTER_TTL) + if len(_SXYPRN_POSTER_CACHE) > 8000: + for k in [k for k, v in list(_SXYPRN_POSTER_CACHE.items()) if v[1] < now]: + _SXYPRN_POSTER_CACHE.pop(k, None) + try: + pr = await client.get(poster, headers=_build_headers("https://sxyprn.com/")) + except Exception as e: + log.info("sxyprn-thumb poster fetch failed %s: %s", pid, e) + return Response(content=b"", status_code=502, media_type="image/jpeg") + if pr.status_code >= 400: + _SXYPRN_POSTER_CACHE.pop(pid, None) # stale token → re-resolve next time + return Response(content=b"", status_code=502, media_type="image/jpeg") + return Response( + content=pr.content, + media_type=pr.headers.get("content-type", "image/jpeg"), + headers={"Cache-Control": "public, max-age=604800"}, + ) + + async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None: """Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl. Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).