diff --git a/alembic/versions/20260820_0033_effective_date_idx.py b/alembic/versions/20260820_0033_effective_date_idx.py new file mode 100644 index 0000000..47d8a5a --- /dev/null +++ b/alembic/versions/20260820_0033_effective_date_idx.py @@ -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") diff --git a/app/api/scenes.py b/app/api/scenes.py index c0beb20..784fe8e 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -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(): diff --git a/app/api/stream_proxy.py b/app/api/stream_proxy.py index 29d8d4e..ede282e 100644 --- a/app/api/stream_proxy.py +++ b/app/api/stream_proxy.py @@ -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