feat(sxyprn): on-demand thumbnail resolver (live posters, ~1h-TTL workaround)

trafficdeposit poster tokens live ~1h (hour-bucketed), so stored URLs can't persist.
New GET /proxy/sxyprn-thumb/{post_id}: resolves the current og:image from the live
/post/<id> page (cache resolved poster URL ~40min), streams bytes with Referer +
long client Cache-Control (URL is stable per post_id → client disk-caches the image,
backend fetches each post ~once). Deleted posts ("Post Not Found") → 404.

Scene grid now emits /proxy/sxyprn-thumb/<id> for sxyprn sources (derived from
page_url) instead of the dead stored trafficdeposit URL. Verified: live post → 200
image, deleted → 404, grid emits resolver URL. Backend-only, no OTA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-10 15:02:49 +02:00
parent f7670963df
commit 08079787da
2 changed files with 80 additions and 1 deletions

View file

@ -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/<id>`) 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:

View file

@ -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/<id> przy serwowaniu. Cache resolved poster URL ~40min
# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → 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"<video[^>]*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).