feat(performer): replace dev Re-scrape button with top-tag chips

bug-report 1a4bf258: "Re-scrape mógłby zniknąć, za to tagi/kategorie by mogły".
Re-scrape was a dev-only bulk thumbnail/tag enrich — noise on the performer page
(per-scene enrich already happens on SceneDetail). Removed it; kept Search.

New GET /performers/{id}/tags aggregates scene_tags across the performer's
live-playback scenes (top N). PerformerScenes renders them as chips → tap navigates
to TagScenes. Search button widened to full row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-08 11:56:26 +02:00
parent f8b1e801ef
commit ffb80c7b60
3 changed files with 86 additions and 70 deletions

View file

@ -22,7 +22,8 @@ from app.db import get_session
from app.models.movie import Movie, MovieTag from app.models.movie import Movie, MovieTag
from app.models.movie_playback_source import MoviePlaybackSource from app.models.movie_playback_source import MoviePlaybackSource
from app.models.performer import Performer 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.studio import Studio
from app.models.tag import Tag 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) @router.post("/performers/{performer_id}/rescrape", response_model=PerformerRescrapeOut)
def rescrape_performer_scenes( def rescrape_performer_scenes(
performer_id: uuid.UUID, performer_id: uuid.UUID,

View file

@ -195,6 +195,11 @@ export class GoonClient {
return this.request(`/tags?${qs.toString()}`); 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<TagListOut> {
return this.request(`/performers/${performerId}/tags?limit=${limit}`);
}
async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number } = {}): Promise<PerformerListOut> { async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number } = {}): Promise<PerformerListOut> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q); if (params.q) qs.set('q', params.q);

View file

@ -81,57 +81,14 @@ export function PerformerScenesScreen() {
}, },
}); });
// Re-scrapuje thumbnails + tagi z tube pages dla wszystkich scen tego performera. // Top tagi/kategorie performera (chipsy w headerze, bug-report 1a4bf258 — zastąpiły
// Bug-report 2026-05-16 (6fcaa5f4): xhamster scenes często mają puste thumbnails // dev-only przycisk Re-scrape). Backend agreguje scene_tags po scenach z żywym
// (KVS player nie zwraca og:image dla wszystkich) i ubogie tagi. Per-scene enrich // playbackiem. Tap → TagScenes. Rescrape (bulk enrich miniaturek/tagów) został
// jest on-demand z SceneDetail, ten button robi bulk dla całej listy. // przeniesiony do flow per-scene (SceneDetail) — na liście performera był devowy szum.
// const tagsQuery = useQuery({
// Auto-loop dla performerów z >50 scen: backend ma cap 50 scen / 55s (nginx 60s queryKey: ['performer-tags', id],
// timeout protection). Pojedyncze wywołanie zostawia resztę nieobsłużoną — user queryFn: () => client.performerTags(id, 18),
// musiałby klikać Rescrape wiele razy. Bug-report `e1fc4f92` 2026-05-17 "Rescrape staleTime: 5 * 60_000,
// 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');
},
}); });
const onHide = () => { const onHide = () => {
@ -292,9 +249,10 @@ export function PerformerScenesScreen() {
: ' '} : ' '}
</Text> </Text>
</View> </View>
{/* Bug-report 2026-05-17 (f3f019d0): "elementy obsługowe znowu {/* Bug-report 2026-05-17 (f3f019d0): "elementy obsługowe zajmują za
zaczynają zajmować za dużo ekranu". Buttons side-by-side dużo ekranu" + 1a4bf258: "Re-scrape mógłby zniknąć, za to tagi/
w jednym row, kompaktowe paddingi, krótszy text. */} kategorie by mogły". Re-scrape (dev-only bulk enrich) usunięty;
Search zostaje, pod nim chipsy top-tagów performera (tap TagScenes). */}
<View style={styles.actionRow}> <View style={styles.actionRow}>
<Pressable <Pressable
style={[ style={[
@ -307,24 +265,27 @@ export function PerformerScenesScreen() {
{refreshMutation.isPending ? ( {refreshMutation.isPending ? (
<ActivityIndicator color={theme.accent} size="small" /> <ActivityIndicator color={theme.accent} size="small" />
) : ( ) : (
<Text style={styles.actionBtnTextPrimary}> Search</Text> <Text style={styles.actionBtnTextPrimary}> Search more scenes</Text>
)}
</Pressable>
<Pressable
style={[
styles.actionBtn,
rescrapeMutation.isPending && styles.refreshBtnLoading,
]}
onPress={() => rescrapeMutation.mutate()}
disabled={rescrapeMutation.isPending}
>
{rescrapeMutation.isPending ? (
<ActivityIndicator color={theme.fg} size="small" />
) : (
<Text style={styles.actionBtnText}> Re-scrape</Text>
)} )}
</Pressable> </Pressable>
</View> </View>
{tab === 'scenes' && !!tagsQuery.data?.items.length && (
<View style={styles.tagRow}>
{tagsQuery.data.items.map((t) => (
<Pressable
key={t.id}
style={styles.tagChip}
onPress={() =>
navigation.push('TagScenes', { slug: t.slug, name: t.name })
}
>
<Text style={styles.tagChipText} numberOfLines={1}>
{t.name}
</Text>
</Pressable>
))}
</View>
)}
</View> </View>
} }
ListEmptyComponent={ ListEmptyComponent={
@ -455,6 +416,23 @@ const styles = StyleSheet.create({
}, },
actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 }, actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 },
actionBtnTextPrimary: { color: theme.accent, fontWeight: '700', 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 }, tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
tabBtn: { tabBtn: {
flex: 1, flex: 1,