Goon — self-hosted aggregator for adult-content scene metadata. Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites. Cross-source deduplication via perceptual hash + Levenshtein distance. FastAPI backend + APScheduler worker + React Native (Expo) mobile client. FOSS, ad-free, donation-funded. See README for details.
136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
// Renderuje sub-scores merge candidate jako horizontal bars zamiast JSON dumpa.
|
|
// Wagi: fp 0.20 / title 0.15 / performers 0.35 / duration 0.25 / date 0.05 (aggregator)
|
|
// Wartości w `reasons` to top-level keys (fp, title, performers, duration, date) jako float [0..1].
|
|
import React from 'react';
|
|
import { StyleSheet, Text, View } from 'react-native';
|
|
import { theme } from '../theme';
|
|
|
|
const SUBSCORES: { key: string; label: string }[] = [
|
|
{ key: 'fp', label: 'fingerprint' },
|
|
{ key: 'title', label: 'title' },
|
|
{ key: 'performers', label: 'performers' },
|
|
{ key: 'duration', label: 'duration' },
|
|
{ key: 'date', label: 'date' },
|
|
];
|
|
|
|
function barColor(v: number): string {
|
|
if (v >= 0.85) return theme.good;
|
|
if (v >= 0.5) return theme.warn;
|
|
if (v > 0) return theme.bad;
|
|
return theme.border;
|
|
}
|
|
|
|
export function SubScoreBars({ reasons }: { reasons: Record<string, unknown> }) {
|
|
// reasons.path = "composite" / "cross_source" / "fp_phash" / etc.
|
|
// reasons.{fp,title,performers,duration,date} are floats or null.
|
|
// reasons.reasons.aggregator_weights or reasons.reasons.weights — for context label.
|
|
const path = (reasons.path as string) ?? 'unknown';
|
|
const studioMatch = reasons.studio_match as boolean | null | undefined;
|
|
const inner = (reasons.reasons as Record<string, unknown>) ?? {};
|
|
const flags: string[] = [];
|
|
if (inner.aggregator_low_performer_cap) flags.push('cap: low perf');
|
|
if (inner.duration_perf_strong_match_bump) flags.push('bumped (dur+perf)');
|
|
if (inner.studio_ignored_aggregator) flags.push('studio ignored');
|
|
if (inner.studio_mismatch_overridden_by_fp) flags.push('fp overrode studio');
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>path</Text>
|
|
<Text style={styles.metaValue}>{path}</Text>
|
|
</View>
|
|
{studioMatch !== null && studioMatch !== undefined ? (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>studio match</Text>
|
|
<Text style={[styles.metaValue, { color: studioMatch ? theme.good : theme.bad }]}>
|
|
{studioMatch ? 'yes' : 'no'}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{SUBSCORES.map(({ key, label }) => {
|
|
const v = reasons[key] as number | null | undefined;
|
|
const present = typeof v === 'number';
|
|
return (
|
|
<View key={key} style={styles.barRow}>
|
|
<Text style={styles.barLabel}>{label}</Text>
|
|
<View style={styles.barTrack}>
|
|
{present ? (
|
|
<View
|
|
style={[
|
|
styles.barFill,
|
|
{
|
|
width: `${Math.max(2, Math.round((v as number) * 100))}%`,
|
|
backgroundColor: barColor(v as number),
|
|
},
|
|
]}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
<Text style={[styles.barValue, !present && styles.barValueMuted]}>
|
|
{present ? (v as number).toFixed(2) : '—'}
|
|
</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
|
|
{flags.length > 0 ? (
|
|
<View style={styles.flagsRow}>
|
|
{flags.map((f) => (
|
|
<View key={f} style={styles.flagPill}>
|
|
<Text style={styles.flagText}>{f}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 8,
|
|
padding: 12,
|
|
marginBottom: 12,
|
|
gap: 6,
|
|
},
|
|
metaRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
metaLabel: { color: theme.muted, fontSize: 12, textTransform: 'uppercase' },
|
|
metaValue: { color: theme.fg, fontSize: 13, fontWeight: '600' },
|
|
barRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
barLabel: { color: theme.muted, fontSize: 12, width: 80 },
|
|
barTrack: {
|
|
flex: 1,
|
|
height: 8,
|
|
backgroundColor: theme.bg,
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
},
|
|
barFill: {
|
|
height: '100%',
|
|
borderRadius: 4,
|
|
},
|
|
barValue: { color: theme.fg, fontSize: 12, fontFamily: 'Courier', width: 40, textAlign: 'right' },
|
|
barValueMuted: { color: theme.muted },
|
|
flagsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 },
|
|
flagPill: {
|
|
backgroundColor: theme.bg,
|
|
borderColor: theme.warn,
|
|
borderWidth: 1,
|
|
borderRadius: 10,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
},
|
|
flagText: { color: theme.warn, fontSize: 11 },
|
|
});
|