fix(sites): sortuj po dacie premiery z fallbackiem na date dodania + napraw miniaturki

Zgloszenie 1df50de3 ("hqporner: brak nowych scen ze strony, brak miniaturek").
Oba objawy mialy inne przyczyny i oba sie potwierdzily.

1. Kolejnosc. Sceny byly i ingest dzialal (91 nowych w 7 dni, ostatnia tego dnia),
ale tuby daty premiery zwykle NIE podaja: xhamster 99,2% scen bez daty, xnxx 97,7%,
porntrex 95,8%, hqporner 93,5%, sxyprn 85,7%. Przy release_date DESC NULLS LAST
wszystkie bezdatowe ladowaly ZA datowanymi, wiec 82 z tych 91 scen siedzialy za
3,4 tys. starszych pozycji. Teraz sortujemy po dacie premiery, a gdy jej brak, po
dniu trafienia do katalogu; sceny z prawdziwa data (kanon) bez zmian. Wyrazenie jest
immutable (jawne AT TIME ZONE 'UTC'), wiec wchodzi do indeksu, migracja 0033.

2. Miniaturki. Kafelki bez obrazka byly podpisane FULLPORNER, nie HQPORNER. Picker
bral pierwsze zrodlo alfabetycznie, a tube:fullporner < tube:hqpornercom. Dwie wady
naraz: czesc miniaturek fullporner to 153-bajtowa atrapa oddawana z kodem 200, a
czesc wskazuje CDN hqporner, ktory odrzuca Referer fullporner.com (ten sam plik:
Referer fullporner.com 403, wlasny host obrazka 200, 30 kB). Wiec: przegladany
serwis wygrywa wybor miniaturki, a proxy obrazkow po 403 ponawia raz z wlasnym
originem obrazka.

Pomiar na 40 kafelkach hqporner: przed 38 ladowalo i 1 nie, po 39 ladowalo i 0 nie,
wszystkie podpisane wlasciwym serwisem. Glowny feed bez zmian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-20 16:25:53 +02:00
parent 34276afb71
commit fa7fb668b6
3 changed files with 125 additions and 7 deletions

View file

@ -0,0 +1,48 @@
"""indeks pod sortowanie po dacie premiery z fallbackiem na date dodania
Zgłoszenie 1df50de3 („hqporner: brak nowych scen"). Sceny były i ingest działał, tylko
tuby daty premiery zwykle NIE podają: xhamster 99,2% scen bez daty, xnxx 97,7%,
porntrex 95,8%, hqporner 93,5%, sxyprn 85,7%. Przy `release_date DESC NULLS LAST`
wszystkie bezdatowe lądowały ZA datowanymi, więc 82 z 91 scen dodanych w hqporner
przez tydzień siedziały za 3,4 tys. starszych pozycji i użytkownik widział pustkę.
Sortujemy więc po dacie premiery, a gdy jej nie ma, po dniu trafienia sceny do
katalogu. Sceny z prawdziwą datą (kanon z TPDB/StashDB) zachowują dotychczasową
kolejność.
Wyrażenie MUSI być immutable, żeby weszło do indeksu. `created_at::date` zależy od
strefy czasowej sesji (stable, nie immutable), stąd jawne `AT TIME ZONE 'UTC'`.
Musi też być IDENTYCZNE z `_effective_date_desc()` w `app/api/scenes.py`, inaczej
planner indeksu nie użyje i wróci do pełnego skanu.
Pomiar po założeniu: hqporner Index Scan bez sortowania, 0,49 s; sxyprn, xhamster
i porntrex po 0,02 s, wszystkie z dzisiejszymi scenami na górze.
`IF NOT EXISTS`, bo na produkcji założony od razu (CONCURRENTLY, bez blokowania ruchu).
Revision ID: 0033_effective_date_idx
Revises: 0032_origin_release_idx
Create Date: 2026-08-20
"""
from __future__ import annotations
from alembic import op
revision: str = "0033_effective_date_idx"
down_revision: str | None = "0032_origin_release_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_scenes_effective_date_desc ON scenes ("
" (COALESCE(release_date, (created_at AT TIME ZONE 'UTC')::date)) DESC,"
" created_at DESC,"
" id DESC"
")"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_scenes_effective_date_desc")

View file

@ -8,7 +8,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import distinct, exists, false, func, literal_column, select
from sqlalchemy import Date, distinct, exists, false, func, literal_column, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@ -42,6 +42,20 @@ router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(requ
_VALID_SORTS = {"created_at", "release_date", "title", "studio"}
def _effective_date_desc():
"""`release_date`, a gdy go brak, dzien trafienia sceny do katalogu, malejaco.
MUSI byc identyczne z wyrazeniem w `ix_scenes_effective_date_desc`, inaczej planner
indeksu nie uzyje i wroci do pelnego skanu `scenes`. `created_at::date` NIE nadaje
sie do indeksu, bo zalezy od strefy czasowej sesji (stable, nie immutable), stad
jawne AT TIME ZONE 'UTC'.
"""
return func.coalesce(
Scene.release_date,
func.cast(func.timezone("UTC", Scene.created_at), Date),
).desc()
# TTL-cache dla count'u scen-z-żywym-playback (default lista bez filtra). Full-scan
# 1.69M scen + EXISTS ~950ms; liczba zmienia się wolno i jest przybliżona (header
# paginacji), więc 10-min cache w pamięci procesu API jest akceptowalny trade-off.
@ -438,8 +452,18 @@ def list_scenes(
# więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu
# (report 304c9258). created_at desc dalej pierwszy, więc index-walk zostaje.
if sort == "release_date":
# Data premiery LUB, gdy jej nie ma, data trafienia do katalogu. Czyste
# `release_date DESC NULLS LAST` chowalo swiezosc, bo tuby daty premiery zwykle
# NIE podaja: xhamster 99,2% scen bez daty, xnxx 97,7%, porntrex 95,8%, hqporner
# 93,5%, sxyprn 85,7%. Wszystkie NULL-e ladowaly za datowanymi, wiec 82 z 91 scen
# dodanych w hqporner przez tydzien siedzialo za 3,4 tys. starszych pozycji i
# user widzial "brak nowych scen" (zgloszenie 1df50de3), mimo ze ingest dzialal.
# Sceny z prawdziwa data (kanon z TPDB/StashDB) sortuja sie jak dotad.
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
# ix_scenes_effective_date_desc (migracja 0033).
ordered = base.order_by(
Scene.release_date.desc().nullslast(), Scene.created_at.desc(), Scene.id.desc()
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "title":
ordered = base.order_by(
@ -451,7 +475,7 @@ def list_scenes(
base.outerjoin(Studio, Studio.id == Scene.studio_id)
.order_by(
Studio.name_normalized.asc().nullslast(),
Scene.release_date.desc().nullslast(),
_effective_date_desc(),
Scene.created_at.desc(),
Scene.id.desc(),
)
@ -485,7 +509,9 @@ def list_scenes(
total = (page - 1) * per_page + len(rows)
total_capped = has_more
items = _build_scenes_out_batch(session, list(rows), light=True, device_id=device_id)
items = _build_scenes_out_batch(
session, list(rows), light=True, device_id=device_id, prefer_origin=origin
)
return SceneListOut(
items=items,
@ -748,6 +774,17 @@ def _flaky_rank(url: str) -> int:
return 0 # nie-chwiejny (martwy rotting-thumb) — bez preferencji
def _is_preferred_origin(origin: str | None, prefer_origin: str | None) -> bool:
"""Czy to origin, ktory user wlasnie przeglada (`/scenes?origin=`).
Porownanie substringiem, bo parametr API jest substringowy: `hqporner` ma zlapac
`tube:hqpornercom`.
"""
if not prefer_origin or not origin:
return False
return prefer_origin.lower() in origin.lower()
def _needs_proxy(url: str) -> bool:
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
@ -771,7 +808,12 @@ def _wrap_image_proxy(url: str, referer: str) -> str:
def _build_scenes_out_batch(
session: Session, scenes: list[Scene], *, light: bool = False, device_id: str = LEGACY_DEVICE
session: Session,
scenes: list[Scene],
*,
light: bool = False,
device_id: str = LEGACY_DEVICE,
prefer_origin: str | None = None,
) -> list[SceneOut]:
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
@ -899,9 +941,16 @@ def _build_scenes_out_batch(
prev = thumb_fallback.get(sid)
if prev is None or _flaky_rank(thumb) < _flaky_rank(prev[0]):
thumb_fallback[sid] = (thumb, page_url, origin)
elif sid not in thumb_by_scene:
elif sid not in thumb_by_scene or _is_preferred_origin(origin, prefer_origin):
# Gdy user przeglada KONKRETNY serwis, jego miniaturka wygrywa.
# Wczesniej decydowala kolejnosc alfabetyczna originu, wiec na liscie
# hqporner kafelki scen wspoldzielonych z fullpornerem bralo
# `tube:fullporner` (< `tube:hqpornercom`), a czesc tych miniaturek to
# 153-bajtowa atrapa z imgs.xiaoshenke.net oddawana z kodem 200. Efekt:
# puste kafelki podpisane FULLPORNER na liscie hqporner, mimo ze ta
# sama scena miala obok zdrowa miniaturke hqporner (zgloszenie 1df50de3).
thumb_by_scene[sid] = (thumb, page_url, origin)
if sid not in anim_by_scene and anim:
if anim and (sid not in anim_by_scene or _is_preferred_origin(origin, prefer_origin)):
anim_by_scene[sid] = (anim, page_url, origin)
# Uzupełnij scenami które mają TYLKO rotting thumbnail (sxyprn-only).
for sid, val in thumb_fallback.items():

View file

@ -344,6 +344,27 @@ async def proxy_image(
except Exception as e:
log.warning("img proxy fetch failed for %s: %s", target, e)
raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e
if r.status_code in (401, 403):
# Referer moze byc z INNEGO serwisu niz host obrazka. Zdarza sie, gdy tube
# zaciaga miniaturki cudzego CDN-a: fullporner zapisuje pliki z
# fastporndelivery.hqporner.com, a my dokladalismy Referer fullporner.com,
# ktorego CDN nie akceptuje. Pomiar 2026-08-20 na tym samym pliku:
# Referer fullporner.com 403 (153 B), brak Referera 403, Referer hqporner.com
# 200 (30 kB), Referer rowny wlasnemu hostowi obrazka 200. Skutkiem byly
# puste kafelki podpisane FULLPORNER na liscie hqporner (zgloszenie 1df50de3).
# Ponawiamy raz z wlasnym originem obrazka, bo to dziala dla calej tej klasy
# przypadkow i nie zmienia zachowania tam, gdzie pierwsza proba sie udaje.
from urllib.parse import urlsplit
parts = urlsplit(str(r.url) or target)
own = f"{parts.scheme}://{parts.netloc}/"
if own != (referer or ""):
try:
r2 = await client.get(target, headers=_build_headers(own))
if r2.status_code < 400:
r = r2
except Exception as e:
log.info("img proxy retry z wlasnym originem padl %s: %s", target, e)
if r.status_code >= 400:
# Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej