fix(performers): "Search more scenes" odpowiada od razu, wyszukiwanie mieli w tle

Zgloszenie 23c64a28: "Search more scenes button not working". Przycisk DZIALAL, tylko
wygladal na zepsuty: endpoint byl synchroniczny i przy performerce z 871 scenami search
po ~25 tubach trwal dluzej, niz telefon utrzymuje polaczenie. W logu zostaly 4 starty
performer_driven bez ANI JEDNEJ domknietej odpowiedzi HTTP: kazdy tap konczyl sie
"Refresh failed", uzytkownik klikal znowu i szukania mielily sie rownolegle, a wyniki
i tak dopisywaly sie w tle, tylko bez zadnego sygnalu dla czlowieka.

Endpoint startuje teraz watek i odpowiada od razu (`status="started"`). Rate-guard 60s
bez zmian, `last_searched_at` idzie w gore PRZED startem watku, wiec chroni tez przed
zdublowaniem samego startu. Watek ma wlasna obsluge bledow, bo nie ma juz requestu,
ktoremu moglby oddac wyjatek.

Mobile: przy `status="started"` komunikat "Searching... new finds appear within a
minute or two" i odswiezenie listy po 60 s. Stare pola odpowiedzi zostaly, wiec klient
sprzed OTA pokaze "0 new scenes", co jest znosne przez kilkuminutowe okno do publikacji.

Zweryfikowane na produkcji: odpowiedz w 0,03 s, drugi strzal 429, watek tla skonczyl
po 30 s i zalogowal wynik.
This commit is contained in:
goon-foss 2026-08-26 14:12:25 +02:00
parent 360af9fabc
commit ccba96376c
4 changed files with 70 additions and 21 deletions

View file

@ -9,6 +9,7 @@ Zwraca też scene_count żeby UI pokazywał "(123)" przy każdym tagu/performerz
"""
from __future__ import annotations
import logging
import uuid
from typing import Annotated
@ -28,6 +29,8 @@ from app.models.studio import Studio
from app.models.tag import Tag
from app.verticals import VERTICALS
log = logging.getLogger(__name__)
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
@ -440,6 +443,10 @@ class PerformerRefreshOut(BaseModel):
counters: dict[str, dict[str, int]]
new_scenes: int
last_searched_at: str | None
# "started" = wyszukiwanie ruszyło w tle i sceny dopiszą się za chwilę. Pole doszło
# przy przejściu na async (zgłoszenie 23c64a28); klient sprzed tej zmiany go nie
# czyta i pokaże "0 new scenes", co jest znośne przez okno do publikacji OTA.
status: str = "done"
class PerformerRescrapeOut(BaseModel):
@ -704,11 +711,18 @@ def refresh_performer(
performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> PerformerRefreshOut:
"""On-demand search across all tubes dla pojedynczego performera. Synchronous,
blokujemy aż search skończy. Mobile pokazuje spinner.
"""On-demand search across all tubes dla pojedynczego performera. Startuje w TLE
i odpowiada od razu (`status="started"`), a sceny dopisują się w miarę mielenia.
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
detail). Continuous worker w tle też robi swoje, więc cache jest częsty.
Dlaczego nie synchronicznie, jak do 2026-08-26: search po ~25 tubach trwa dłużej,
niż klient jest gotów czekać, i przy rozbudowanych performerach telefon zrywał
połączenie. W logu zostawały starty `performer_driven` bez ANI JEDNEJ domkniętej
odpowiedzi (Cassie Del Isla, 871 scen, 4 zerwane requesty), użytkownik widział
"Refresh failed", klikał znowu i mieliliśmy to samo kilka razy równolegle
(zgłoszenie 23c64a28 "Search more scenes button not working").
Rate-guard bez zmian: refresh < 60s temu → 429. `last_searched_at` stawiamy PRZED
startem wątku, więc guard chroni też przed dublowaniem samego startu.
"""
from datetime import UTC as _UTC, datetime as _dt, timedelta as _td
@ -724,27 +738,37 @@ def refresh_performer(
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
)
# Lazy import, performer_driven ma ciężki connector tree
from app.scheduler.performer_driven import run_performer_driven
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).
# Akceptowalne dla self-hosted single-user. W razie potrzeby dorobić task queue.
counters_obj = run_performer_driven(
performer_ids=[performer_id],
top_n=0,
per_performer_limit=200,
)
# Update last_searched_at + counter (tak samo jak continuous worker)
perf.last_searched_at = _dt.now(_UTC)
perf.search_run_count = (perf.search_run_count or 0) + 1
session.commit()
name = perf.canonical_name
searched_at = perf.last_searched_at.isoformat()
def _run_in_background() -> None:
# Własny import i własna obsługa błędów: wątek nie ma requestu, któremu mógłby
# oddać wyjątek, a `run_performer_driven` otwiera sesje DB samodzielnie.
try:
from app.scheduler.performer_driven import run_performer_driven
counters_obj = run_performer_driven(
performer_ids=[performer_id],
top_n=0,
per_performer_limit=200,
)
new_total = sum(s.get("new", 0) for s in counters_obj.per_source.values())
log.info("refresh-performer %s (%s): tlo skonczone, new=%d", performer_id, name, new_total)
except Exception:
log.exception("refresh-performer %s (%s): tlo padlo", performer_id, name)
import threading
threading.Thread(target=_run_in_background, name=f"perf-refresh-{performer_id}", daemon=True).start()
new_total = sum(s.get("new", 0) for s in counters_obj.per_source.values())
return PerformerRefreshOut(
performer_id=performer_id,
canonical_name=perf.canonical_name,
counters=counters_obj.per_source,
new_scenes=new_total,
last_searched_at=perf.last_searched_at.isoformat() if perf.last_searched_at else None,
canonical_name=name,
counters={},
new_scenes=0,
last_searched_at=searched_at,
status="started",
)

View file

@ -268,6 +268,8 @@ export class GoonClient {
counters: Record<string, Record<string, number>>;
new_scenes: number;
last_searched_at: string | null;
/** "started" = backend mieli w tle i sceny dopiszą się za chwilę (od 2026-08-26). */
status?: string;
}> {
return this.request(`/performers/${performerId}/refresh?pages=${pages}`, {
method: 'POST',

View file

@ -29,6 +29,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-26a',
date: 'August 2026',
items: [
'"Search more scenes" on a performer now answers right away and keeps working in the background. It used to look broken on performers with lots of scenes: the search took longer than the phone keeps a connection open, so you saw "Refresh failed" even though the search actually ran. New finds show up within a minute or two.',
'Movie hosts that quietly moved to a new address (myvidplay, doply) play again. The app was being handed the old address and its pop-up blocker stopped the jump to the new one, which looked like a black screen.',
],
},
{
id: '2026-08-24b',
date: 'August 2026',

View file

@ -72,6 +72,21 @@ export function PerformerScenesScreen() {
const refreshMutation = useMutation({
mutationFn: () => client.refreshPerformer(id),
onSuccess: (data) => {
// Od 2026-08-26 backend startuje wyszukiwanie w tle i odpowiada od razu.
// Poprzedni tryb (czekanie na wynik) trwał dłużej, niż telefon utrzymuje
// połączenie: przy rozbudowanych performerach każdy tap kończył się "Refresh
// failed", mimo że sceny i tak dopisywały się w tle (zgłoszenie 23c64a28).
// Listę odświeżamy z opóźnieniem, żeby pierwsze znaleziska były już zapisane.
if (data.status === 'started') {
Alert.alert(
'Searching…',
'Checking all sites for new scenes in the background. New finds appear within a minute or two; pull down to refresh.',
);
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] });
}, 60_000);
return;
}
queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] });
Alert.alert(
'Refresh complete',