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:
parent
f8b1e801ef
commit
ffb80c7b60
3 changed files with 86 additions and 70 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q) qs.set('q', params.q);
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
: ' '}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 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). */}
|
||||
<View style={styles.actionRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
|
|
@ -307,24 +265,27 @@ export function PerformerScenesScreen() {
|
|||
{refreshMutation.isPending ? (
|
||||
<ActivityIndicator color={theme.accent} size="small" />
|
||||
) : (
|
||||
<Text style={styles.actionBtnTextPrimary}>↻ Search</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>
|
||||
<Text style={styles.actionBtnTextPrimary}>↻ Search more scenes</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</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>
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue