feat(scenes): similar-scenes rail on detail (shared performers/tags)
Some checks are pending
Backend tests / test (push) Waiting to run
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:
parent
f55f87410a
commit
45459b414b
4 changed files with 199 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue