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 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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue