From 45459b414b4bb6243f97595a723977a0cf933ce9 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Tue, 21 Jul 2026 15:26:55 +0200 Subject: [PATCH] feat(scenes): similar-scenes rail on detail (shared performers/tags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend GET /scenes/{id}/similar: ranking po WSPÓLNYCH performerach (waga 3) i tagach (waga 1). Performerzy selektywni → mała pula kandydatów (cap 1500); tagi liczone tylko w tej puli. Fill z tagów (bounded 400, HAVING>=2 wspólne) tylko gdy performerów mało/brak (anonimowe tube sceny) — inaczej szeroki tag skanowałby setki tysięcy scen. Filtr widoczności jak lista: grywalne + brama JAV + blacklist + nie-stub + nie-self. Ranking/sort w Pythonie nad ograniczonymi zbiorami. Zmierzone: 210-944ms wg liczby performerów seeda. Mobile: SimilarRail (poziomy) na dole SceneDetail, lazy przez react-query, znika gdy brak dopasowań, tap → nav.push kolejnego SceneDetail. --- app/api/scenes.py | 113 +++++++++++++++++++++++ mobile/src/api.ts | 4 + mobile/src/changelog.ts | 7 ++ mobile/src/screens/SceneDetailScreen.tsx | 75 +++++++++++++++ 4 files changed, 199 insertions(+) diff --git a/app/api/scenes.py b/app/api/scenes.py index 91315ea..580c5bb 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -499,6 +499,119 @@ def get_scene( return _build_scene_out(session, scene, device_id=device_id) +@router.get("/{scene_id}/similar", response_model=SceneListOut) +def similar_scenes( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], + limit: int = Query(default=12, ge=1, le=30), +) -> SceneListOut: + """Sceny podobne do danej — ranking po WSPÓLNYCH performerach (mocny sygnał) i + tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV + (JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie. + + Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap + _CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded + _TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów + (anonimowe tube sceny) — inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy + wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id.""" + scene = session.get(Scene, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail="scene not found") + + _empty = SceneListOut( + items=[], total=0, page=1, per_page=limit, has_more=False, total_capped=False + ) + + perf_ids = session.execute( + select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id) + ).scalars().all() + tag_ids = session.execute( + select(SceneTag.tag_id).where(SceneTag.scene_id == scene_id) + ).scalars().all() + if not perf_ids and not tag_ids: + return _empty + + _PERF_W, _TAG_W = 3, 1 + _CAND_CAP = 1500 # pula kandydatów z performerów (bezpiecznik na płodnego performera) + _TAG_FILL_CAP = 400 # pula fill z tagów gdy brak performerów + + perf_shared: dict[uuid.UUID, int] = {} + if perf_ids: + rows = session.execute( + select(ScenePerformer.scene_id, func.count().label("c")) + .where( + ScenePerformer.performer_id.in_(perf_ids), + ScenePerformer.scene_id != scene_id, + ) + .group_by(ScenePerformer.scene_id) + .order_by(func.count().desc()) + .limit(_CAND_CAP) + ).all() + perf_shared = {r[0]: r[1] for r in rows} + + cand_ids: set[uuid.UUID] = set(perf_shared) + tag_shared: dict[uuid.UUID, int] = {} + if tag_ids and cand_ids: + rows = session.execute( + select(SceneTag.scene_id, func.count(distinct(SceneTag.tag_id))) + .where(SceneTag.scene_id.in_(cand_ids), SceneTag.tag_id.in_(tag_ids)) + .group_by(SceneTag.scene_id) + ).all() + tag_shared = {r[0]: r[1] for r in rows} + # Fill z tagów gdy performerów mało/brak (anonimowe tube sceny) — inaczej pomijamy, + # bo szeroki tag skanuje setki tysięcy scen. + if tag_ids and len(cand_ids) < limit * 4: + rows = session.execute( + select(SceneTag.scene_id, func.count(distinct(SceneTag.tag_id)).label("c")) + .where(SceneTag.tag_id.in_(tag_ids), SceneTag.scene_id != scene_id) + .group_by(SceneTag.scene_id) + .having(func.count(distinct(SceneTag.tag_id)) >= 2) + .order_by(func.count(distinct(SceneTag.tag_id)).desc()) + .limit(_TAG_FILL_CAP) + ).all() + for sid, c in rows: + tag_shared.setdefault(sid, c) + cand_ids.add(sid) + + if not cand_ids: + return _empty + + # Filtr widoczności (ta sama definicja co lista): grywalne + zgodne z bramą JAV + + # blacklist + nie-stub + nie-self. + target_is_jav = bool( + session.execute( + select(_jav_source_exists()).select_from(Scene).where(Scene.id == scene_id) + ).scalar() + ) + filt = select(Scene.id).where( + Scene.id.in_(cand_ids), + Scene.id != scene_id, + live_playback_exists(), + stub_exclusion_clause(), + _jav_source_exists() if target_is_jav else ~_jav_source_exists(), + ) + for _bl in blacklist_clauses(session, device_id): + filt = filt.where(_bl) + valid_ids = set(session.execute(filt).scalars().all()) + if not valid_ids: + return _empty + + def _score(sid: uuid.UUID) -> int: + return _PERF_W * perf_shared.get(sid, 0) + _TAG_W * tag_shared.get(sid, 0) + + top_ids = sorted(valid_ids, key=_score, reverse=True)[: limit * 2] + scenes = session.execute(select(Scene).where(Scene.id.in_(top_ids))).scalars().all() + scenes.sort(key=lambda sc: (_score(sc.id), sc.created_at), reverse=True) + scenes = scenes[:limit] + + items = _build_scenes_out_batch(session, list(scenes), light=True, device_id=device_id) + return SceneListOut( + items=items, total=len(items), page=1, per_page=limit, + has_more=False, total_capped=False, + ) + + _SXYPRN_POST_RE = re.compile(r"sxyprn\.com/post/([0-9a-f]{6,40})", re.IGNORECASE) diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 5fcabc8..de336ba 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -339,6 +339,10 @@ export class GoonClient { return this.request(`/scenes/${id}`); } + async getSimilarScenes(id: string, limit = 12): Promise { + return this.request(`/scenes/${id}/similar?limit=${limit}`); + } + async resolvePlayback(sceneId: string, playbackId: string): Promise { return this.request(`/scenes/${sceneId}/playback/${playbackId}/resolve`, { method: 'POST', diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 07822c3..433a5ed 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-21', + date: 'July 2026', + items: [ + 'Similar scenes: a scene\'s detail page now shows a row of related scenes, matched by shared performers and tags. Tap one to jump straight to it.', + ], + }, { id: '2026-07-20b', date: 'July 2026', diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index 6d36580..162bf95 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -386,11 +386,66 @@ export function SceneDetailScreen() { {data.description} ) : null} + + ); } +// Rail "podobne sceny" — ranking backendu po wspólnych performerach/tagach +// (GET /scenes/{id}/similar). Lazy: dociąga się po wyrenderowaniu detalu, znika +// gdy brak dopasowań. Tap → push nowego SceneDetail (stackuje się, back wraca). +function SimilarRail({ sceneId }: { sceneId: string }) { + const client = useClient(); + const nav = useNavigation>(); + const { data } = useQuery({ + queryKey: ['similar', sceneId], + queryFn: () => client.getSimilarScenes(sceneId, 12), + staleTime: 5 * 60_000, + }); + const items = data?.items ?? []; + if (items.length === 0) return null; + return ( +
+ + {items.map((sc) => { + const thumb = sc.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url; + const mins = sc.duration_sec ? Math.floor(sc.duration_sec / 60) : null; + return ( + nav.push('SceneDetail', { id: sc.id })} + > + + + {mins !== null ? ( + + {mins}m + + ) : null} + + + {sc.title} + + + ); + })} + +
+ ); +} + function Hero({ data, onThumbState, @@ -948,6 +1003,26 @@ const styles = StyleSheet.create({ fontSize: 13, }, tagHint: { color: theme.mutedDim, fontSize: 11, marginTop: 6 }, + simRailContent: { gap: 12, paddingRight: 8, paddingTop: 4 }, + simCard: { width: 160 }, + simThumbWrap: { + width: 160, + height: 90, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.06)', + }, + simDurBadge: { + position: 'absolute', + right: 5, + bottom: 5, + backgroundColor: 'rgba(0,0,0,0.7)', + borderRadius: 4, + paddingHorizontal: 5, + paddingVertical: 1, + }, + simDurText: { color: '#fff', fontSize: 11, fontWeight: '600' }, + simTitle: { color: theme.fg, fontSize: 12, marginTop: 6, lineHeight: 16 }, refreshThumbBtn: { alignSelf: 'flex-start', paddingVertical: 6,