diff --git a/app/api/taxonomies.py b/app/api/taxonomies.py index d1bb7ab..3c5fcf4 100644 --- a/app/api/taxonomies.py +++ b/app/api/taxonomies.py @@ -22,7 +22,8 @@ from app.db import get_session from app.models.movie import Movie, MovieTag from app.models.movie_playback_source import MoviePlaybackSource from app.models.performer import Performer -from app.models.scene import ScenePerformer +from app.models.playback_source import PlaybackSource +from app.models.scene import ScenePerformer, SceneTag from app.models.studio import Studio from app.models.tag import Tag @@ -352,6 +353,38 @@ _TAG_PRIORITY = [ ] +@router.get("/performers/{performer_id}/tags", response_model=TagListOut) +def performer_top_tags( + performer_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], + limit: int = Query(default=20, ge=1, le=50), +) -> TagListOut: + """Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips, + bug-report 1a4bf258 — zastępują dev-only przycisk Re-scrape). Liczymy tylko po + scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index + pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request.""" + live = exists().where( + and_( + PlaybackSource.scene_id == ScenePerformer.scene_id, + PlaybackSource.dead_at.is_(None), + ) + ) + rows = session.execute( + select(Tag.id, Tag.name, Tag.slug, func.count().label("c")) + .select_from(ScenePerformer) + .join(SceneTag, SceneTag.scene_id == ScenePerformer.scene_id) + .join(Tag, Tag.id == SceneTag.tag_id) + .where(ScenePerformer.performer_id == performer_id, live) + .group_by(Tag.id, Tag.name, Tag.slug) + .order_by(func.count().desc(), Tag.name.asc()) + .limit(limit) + ).all() + items = [ + TagCount(id=r.id, name=r.name, slug=r.slug, scene_count=int(r.c)) for r in rows + ] + return TagListOut(items=items, total=len(items), page=1, per_page=limit) + + @router.post("/performers/{performer_id}/rescrape", response_model=PerformerRescrapeOut) def rescrape_performer_scenes( performer_id: uuid.UUID, diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 8265322..a3740d9 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -195,6 +195,11 @@ export class GoonClient { return this.request(`/tags?${qs.toString()}`); } + // Top tagi/kategorie performera (PerformerScenes chips). Agregat ze scen z żywym playbackiem. + async performerTags(performerId: string, limit = 20): Promise { + return this.request(`/performers/${performerId}/tags?limit=${limit}`); + } + async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number } = {}): Promise { const qs = new URLSearchParams(); if (params.q) qs.set('q', params.q); diff --git a/mobile/src/screens/PerformerScenesScreen.tsx b/mobile/src/screens/PerformerScenesScreen.tsx index 80245b5..4a07a5e 100644 --- a/mobile/src/screens/PerformerScenesScreen.tsx +++ b/mobile/src/screens/PerformerScenesScreen.tsx @@ -81,57 +81,14 @@ export function PerformerScenesScreen() { }, }); - // Re-scrapuje thumbnails + tagi z tube pages dla wszystkich scen tego performera. - // Bug-report 2026-05-16 (6fcaa5f4): xhamster scenes często mają puste thumbnails - // (KVS player nie zwraca og:image dla wszystkich) i ubogie tagi. Per-scene enrich - // jest on-demand z SceneDetail, ten button robi bulk dla całej listy. - // - // Auto-loop dla performerów z >50 scen: backend ma cap 50 scen / 55s (nginx 60s - // timeout protection). Pojedyncze wywołanie zostawia resztę nieobsłużoną — user - // musiałby klikać Rescrape wiele razy. Bug-report `e1fc4f92` 2026-05-17 "Rescrape - // miniaturek nie pobrał wszystkich". Auto-loop dopóki backend zwraca `capped=true` - // — idempotent (scena z thumb się skipuje na backendzie), więc kolejne iteracje - // mielą tylko brakujące. Hard limit 10 iteracji jako safety net (max ~500 scen). - const rescrapeMutation = useMutation({ - mutationFn: async () => { - let scenes_processed = 0; - let scenes_total = 0; - let thumbs_added = 0; - let tags_added = 0; - let iterations = 0; - let last; - do { - last = await client.rescrapePerformer(id); - scenes_processed += last.scenes_processed; - thumbs_added += last.thumbs_added; - tags_added += last.tags_added; - scenes_total = last.scenes_total; // ostatni response ma aktualny total - iterations += 1; - } while (last.capped && iterations < 10); - return { - scenes_processed, - scenes_total, - thumbs_added, - tags_added, - iterations, - capped: last.capped, - cap_reason: last.cap_reason, - }; - }, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] }); - queryClient.invalidateQueries({ queryKey: ['performer-movies', id] }); - queryClient.invalidateQueries({ queryKey: ['scenes'] }); - const iterNote = data.iterations > 1 ? ` (${data.iterations} batches)` : ''; - const capNote = data.capped ? ` · still capped — retry to continue` : ''; - Alert.alert( - 'Rescrape complete', - `${data.scenes_processed} scenes · +${data.thumbs_added} thumbs · +${data.tags_added} tags${iterNote}${capNote}`, - ); - }, - onError: (e: any) => { - Alert.alert('Rescrape failed', e?.message || 'unknown error'); - }, + // Top tagi/kategorie performera (chipsy w headerze, bug-report 1a4bf258 — zastąpiły + // dev-only przycisk Re-scrape). Backend agreguje scene_tags po scenach z żywym + // playbackiem. Tap → TagScenes. Rescrape (bulk enrich miniaturek/tagów) został + // przeniesiony do flow per-scene (SceneDetail) — na liście performera był devowy szum. + const tagsQuery = useQuery({ + queryKey: ['performer-tags', id], + queryFn: () => client.performerTags(id, 18), + staleTime: 5 * 60_000, }); const onHide = () => { @@ -292,9 +249,10 @@ export function PerformerScenesScreen() { : ' '} - {/* Bug-report 2026-05-17 (f3f019d0): "elementy obsługowe znowu - zaczynają zajmować za dużo ekranu". Buttons side-by-side - w jednym row, kompaktowe paddingi, krótszy text. */} + {/* Bug-report 2026-05-17 (f3f019d0): "elementy obsługowe zajmują za + dużo ekranu" + 1a4bf258: "Re-scrape mógłby zniknąć, za to tagi/ + kategorie by mogły". Re-scrape (dev-only bulk enrich) usunięty; + Search zostaje, pod nim chipsy top-tagów performera (tap → TagScenes). */} ) : ( - ↻ Search - )} - - rescrapeMutation.mutate()} - disabled={rescrapeMutation.isPending} - > - {rescrapeMutation.isPending ? ( - - ) : ( - ⟳ Re-scrape + ↻ Search more scenes )} + {tab === 'scenes' && !!tagsQuery.data?.items.length && ( + + {tagsQuery.data.items.map((t) => ( + + navigation.push('TagScenes', { slug: t.slug, name: t.name }) + } + > + + {t.name} + + + ))} + + )} } ListEmptyComponent={ @@ -455,6 +416,23 @@ const styles = StyleSheet.create({ }, actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 }, actionBtnTextPrimary: { color: theme.accent, fontWeight: '700', fontSize: 12 }, + tagRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginHorizontal: 4, + marginBottom: 10, + }, + tagChip: { + backgroundColor: theme.card, + borderColor: theme.border, + borderWidth: 1, + borderRadius: 14, + paddingVertical: 4, + paddingHorizontal: 10, + maxWidth: 160, + }, + tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' }, tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, tabBtn: { flex: 1,