feat(scenes): similar-scenes rail on detail (shared performers/tags)
Some checks are pending
Backend tests / test (push) Waiting to run

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.
This commit is contained in:
goon-foss 2026-07-21 15:26:55 +02:00
parent f55f87410a
commit 45459b414b
4 changed files with 199 additions and 0 deletions

View file

@ -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
(JAVJAV, nie-JAVnie-JAV), z pominięciem blacklist/stubów/samej siebie.
Wydajność: performerzy 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)

View file

@ -339,6 +339,10 @@ export class GoonClient {
return this.request(`/scenes/${id}`);
}
async getSimilarScenes(id: string, limit = 12): Promise<SceneListOut> {
return this.request(`/scenes/${id}/similar?limit=${limit}`);
}
async resolvePlayback(sceneId: string, playbackId: string): Promise<ResolveOut> {
return this.request<ResolveOut>(`/scenes/${sceneId}/playback/${playbackId}/resolve`, {
method: 'POST',

View file

@ -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',

View file

@ -386,11 +386,66 @@ export function SceneDetailScreen() {
<Text style={styles.description}>{data.description}</Text>
</Section>
) : null}
<SimilarRail sceneId={data.id} />
</View>
</ScrollView>
);
}
// 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<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
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 (
<Section title="Similar scenes">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.simRailContent}
>
{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 (
<Pressable
key={sc.id}
style={styles.simCard}
onPress={() => nav.push('SceneDetail', { id: sc.id })}
>
<View style={styles.simThumbWrap}>
<Image
source={thumb ? { uri: thumb } : undefined}
style={StyleSheet.absoluteFill}
contentFit="cover"
transition={150}
/>
{mins !== null ? (
<View style={styles.simDurBadge}>
<Text style={styles.simDurText}>{mins}m</Text>
</View>
) : null}
</View>
<Text style={styles.simTitle} numberOfLines={2}>
{sc.title}
</Text>
</Pressable>
);
})}
</ScrollView>
</Section>
);
}
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,