feat(movies): NEW przy filmach ulubionych, scalanie duplikatow przytrzymaniem
Some checks are pending
Backend tests / test (push) Waiting to run

Filmy performerki z ulubionych dodane od ostatniej wizyty maja odznake NEW
i ida na gore. Przytrzymanie filmu otwiera scalanie jak przy scenach
(POST /movies/{keep}/merge/{drop}). Ocena dopasowania liczy cyfry rzymskie
jak arabskie i uznaje za zgodne studia o tej samej nazwie bez spacji;
resolver studiow nie zaklada juz blizniaka "EvilAngel" obok "Evil Angel".
Skrypt remerge_movies_roman_studio.py scala istniejace takie pary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-16 09:56:26 +02:00
parent 5e203e7e98
commit f167c491c8
10 changed files with 486 additions and 21 deletions

View file

@ -6,6 +6,7 @@ from collections import defaultdict
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import exists, func, select from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -384,3 +385,32 @@ def _movies_to_out(
) )
) )
return out return out
class MovieMergeOut(BaseModel):
keep_id: uuid.UUID
dropped_id: uuid.UUID
@router.post("/{keep_id}/merge/{drop_id}", response_model=MovieMergeOut)
def merge_duplicate_movie(
keep_id: uuid.UUID,
drop_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> MovieMergeOut:
"""Scal `drop_id` w `keep_id` z apki (przytrzymanie filmu, "merge into this one"),
jak przy scenach. Przenosi linki, obsade, tagi, rozdzialy i dane uzytkownikow
(movie_merge), kasuje `drop`. Zgloszenie f8c9486e (2026-10-16): te same filmy z dwoch
zrodel, ktorych automat nie zlaczyl ("Hardcore Gangbangs 8" i "... VIII")."""
from app.resolve.movie_merge import MovieMergeError, merge_movies
if keep_id == drop_id:
raise HTTPException(status_code=400, detail="keep_id == drop_id")
if session.get(Movie, keep_id) is None or session.get(Movie, drop_id) is None:
raise HTTPException(status_code=404, detail="movie not found")
try:
merge_movies(session, keep_id=keep_id, drop_id=drop_id, resolved_by="user_long_press_duplicate")
except MovieMergeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
session.commit()
return MovieMergeOut(keep_id=keep_id, dropped_id=drop_id)

View file

@ -16,6 +16,7 @@ Threshold (lower niż scenes, title dla movies ma niższy false-positive rate):
""" """
from __future__ import annotations from __future__ import annotations
import re
import uuid import uuid
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
@ -24,6 +25,7 @@ from rapidfuzz import fuzz
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.movie import Movie, MoviePerformer from app.models.movie import Movie, MoviePerformer
from app.models.studio import Studio
@dataclass @dataclass
@ -58,6 +60,31 @@ AUTO_THRESHOLD = 0.85
REVIEW_THRESHOLD = 0.65 REVIEW_THRESHOLD = 0.65
_ROMAN_RE = re.compile(r"^(x{0,3})(ix|iv|v?i{0,3})$")
_ROMAN_VALUES = {"i": 1, "v": 5, "x": 10}
def _roman_value(token: str) -> int | None:
"""Wartosc cyfry rzymskiej II..XXXIX; pojedyncze litery (i, v, x) to zwykle slowa,
a "xx"/"xxx" w tych tytulach to slowo ("Star Wars XXX"), nie liczba."""
if len(token) < 2 or not _ROMAN_RE.match(token) or set(token) == {"x"}:
return None
total, prev = 0, 0
for ch in reversed(token):
v = _ROMAN_VALUES[ch]
total = total - v if v < prev else total + v
prev = max(prev, v)
return total if 2 <= total <= 39 else None
def _roman_to_arabic(title_normalized: str) -> str:
out = []
for t in title_normalized.split():
v = _roman_value(t)
out.append(str(v) if v is not None else t)
return " ".join(out)
def _series_number(title_normalized: str) -> int | None: def _series_number(title_normalized: str) -> int | None:
"""Ostatnia samodzielna liczba w tytule, z pominieciem lat (1900-2099). """Ostatnia samodzielna liczba w tytule, z pominieciem lat (1900-2099).
@ -221,6 +248,14 @@ def _slowa(title_normalized: str) -> list[str]:
return [t for t in out if t not in _SEO_WYPELNIACZE] return [t for t in out if t not in _SEO_WYPELNIACZE]
def _same_studio_name(session: Session, a_id: uuid.UUID, b_id: uuid.UUID) -> bool:
a, b = session.get(Studio, a_id), session.get(Studio, b_id)
if a is None or b is None or not a.name_normalized or not b.name_normalized:
return False
ka, kb = a.name_normalized.replace(" ", ""), b.name_normalized.replace(" ", "")
return len(ka) >= 4 and ka == kb
def score_movie_candidate( def score_movie_candidate(
session: Session, session: Session,
*, *,
@ -233,11 +268,12 @@ def score_movie_candidate(
"""Liczy score dopasowania incoming → candidate. Wszystko w [0,1].""" """Liczy score dopasowania incoming → candidate. Wszystko w [0,1]."""
breakdown = MovieScoreBreakdown(reasons={}) breakdown = MovieScoreBreakdown(reasons={})
# Title (always) # Title (always). Cyfry rzymskie jako arabskie: "hardcore gangbangs viii" i "... 8" to
# ten sam numer serii (zgloszenie f8c9486e, xtapes vs inne zrodla).
series_reject = False series_reject = False
if candidate.title_normalized and norm_title_normalized: if candidate.title_normalized and norm_title_normalized:
breakdown.title, series_reject, powody = _title_signal( breakdown.title, series_reject, powody = _title_signal(
candidate.title_normalized, norm_title_normalized _roman_to_arabic(candidate.title_normalized), _roman_to_arabic(norm_title_normalized)
) )
breakdown.reasons.update(powody) breakdown.reasons.update(powody)
@ -253,9 +289,12 @@ def score_movie_candidate(
else: else:
breakdown.year = 0.0 breakdown.year = 0.0
# Studio match, binary # Studio match, binary. Rozne wiersze o tej samej nazwie bez spacji ("EvilAngel" z xtapes
# i "Evil Angel" z TPDB) to to samo studio; w bazie jest ok. 2 tys. takich par.
if candidate.studio_id and norm_studio_id: if candidate.studio_id and norm_studio_id:
breakdown.studio_match = candidate.studio_id == norm_studio_id breakdown.studio_match = candidate.studio_id == norm_studio_id or _same_studio_name(
session, candidate.studio_id, norm_studio_id
)
# Cast Jaccard, gdy obie strony mają performerów # Cast Jaccard, gdy obie strony mają performerów
norm_perf_set = {str(pid) for pid in norm_performer_ids if pid is not None} norm_perf_set = {str(pid) for pid in norm_performer_ids if pid is not None}

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import logging import logging
import uuid import uuid
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.studio import Studio, StudioAlias, StudioExternalRef from app.models.studio import Studio, StudioAlias, StudioExternalRef
@ -21,6 +21,24 @@ from app.normalize.text import slugify
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _compact_name_twin(session: Session, name_normalized: str) -> Studio | None:
"""Jedno studio o tej samej nazwie bez spacji ("evilangel" = "evil angel").
xtapes podaje studia sklejone ("[EvilAngel]", "[TeamSkeet]"), a reszta zrodel ze
spacja; osobne studio psulo dopasowanie filmow (studio "rozne" obnizalo ocene, np.
"Hardcore Gangbangs 8" i "... VIII", zgloszenie f8c9486e). Tylko przy jednym trafieniu.
"""
compact = (name_normalized or "").replace(" ", "")
if len(compact) < 4:
return None
rows = session.execute(
select(Studio)
.where(func.replace(Studio.name_normalized, " ", "") == compact)
.limit(2)
).scalars().all()
return rows[0] if len(rows) == 1 else None
def resolve_studio( def resolve_studio(
session: Session, session: Session,
*, *,
@ -43,6 +61,8 @@ def resolve_studio(
studio = session.execute( studio = session.execute(
select(Studio).where(Studio.name_normalized == norm.name_normalized) select(Studio).where(Studio.name_normalized == norm.name_normalized)
).scalar_one_or_none() ).scalar_one_or_none()
if studio is None:
studio = _compact_name_twin(session, norm.name_normalized)
if studio is None: if studio is None:
studio = Studio( studio = Studio(
name=norm.name, name=norm.name,

View file

@ -27,6 +27,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GoonClient } from './src/api'; import { GoonClient } from './src/api';
import { ClientProvider } from './src/ClientContext'; import { ClientProvider } from './src/ClientContext';
import { PreferencesProvider } from './src/PreferencesContext'; import { PreferencesProvider } from './src/PreferencesContext';
import { MovieActionsProvider } from './src/MovieActionsContext';
import { SceneActionsProvider } from './src/SceneActionsContext'; import { SceneActionsProvider } from './src/SceneActionsContext';
import { OnboardingModal } from './src/components/OnboardingModal'; import { OnboardingModal } from './src/components/OnboardingModal';
import { SurveyModal } from './src/components/SurveyModal'; import { SurveyModal } from './src/components/SurveyModal';
@ -505,6 +506,7 @@ export default function App() {
<ClientProvider client={client}> <ClientProvider client={client}>
<PreferencesProvider> <PreferencesProvider>
<SceneActionsProvider> <SceneActionsProvider>
<MovieActionsProvider>
<AppNavigator <AppNavigator
client={client} client={client}
appVersion={APP_VERSION} appVersion={APP_VERSION}
@ -517,6 +519,7 @@ export default function App() {
<WhatsNewModal /> <WhatsNewModal />
<SurveyModal /> <SurveyModal />
<OnboardingModal /> <OnboardingModal />
</MovieActionsProvider>
</SceneActionsProvider> </SceneActionsProvider>
</PreferencesProvider> </PreferencesProvider>
</ClientProvider> </ClientProvider>

View file

@ -0,0 +1,168 @@
/**
* MovieActions: scalanie duplikatow filmow przytrzymaniem, jak przy scenach
* (SceneActionsContext). Zgloszenie f8c9486e (2026-10-16): te same filmy z dwoch zrodel,
* ktorych automat nie zlaczyl, np. "Hardcore Gangbangs 8" i "Hardcore Gangbangs VIII".
*
* Przytrzymany film ZOSTAJE, kolejne tapniecia zaznaczaja jego duplikaty, przycisk
* w bannerze scala wszystkie naraz (`POST /movies/{keep}/merge/{drop}` w petli).
* Kontekst, bo MoviePosterCard jest wspolny dla kilku ekranow.
*/
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import { ActivityIndicator, Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useClient } from './ClientContext';
import { theme } from './theme';
import type { MovieOut } from './types';
interface MovieActionsCtx {
keeper: MovieOut | null;
isSelecting: boolean;
isMarked: (movieId: string) => boolean;
openActions: (movie: MovieOut) => void;
toggleDuplicate: (movie: MovieOut) => void;
}
const Ctx = React.createContext<MovieActionsCtx | null>(null);
/** Bez providera (np. w testach) karta dziala jak zwykly przycisk. */
export function useMovieActions(): MovieActionsCtx | null {
return React.useContext(Ctx);
}
const MOVIE_LIST_KEYS = ['movies', 'performer-movies', 'studio-movies', 'studio-movies-pages', 'favorites-movies'];
export function MovieActionsProvider({ children }: { children: React.ReactNode }) {
const client = useClient();
const queryClient = useQueryClient();
const [keeper, setKeeper] = React.useState<MovieOut | null>(null);
const [marked, setMarked] = React.useState<MovieOut[]>([]);
const [merging, setMerging] = React.useState(false);
const reset = React.useCallback(() => {
setKeeper(null);
setMarked([]);
}, []);
const openActions = React.useCallback(
(movie: MovieOut) => {
if (keeper) return;
Alert.alert(movie.title, 'Movie actions', [
{ text: 'Merge duplicates into this', onPress: () => setKeeper(movie) },
{ text: 'Cancel', style: 'cancel' },
]);
},
[keeper],
);
const toggleDuplicate = React.useCallback(
(movie: MovieOut) => {
if (!keeper || merging || movie.id === keeper.id) return;
setMarked((prev) =>
prev.some((m) => m.id === movie.id) ? prev.filter((m) => m.id !== movie.id) : [...prev, movie],
);
},
[keeper, merging],
);
const isMarked = React.useCallback((movieId: string) => marked.some((m) => m.id === movieId), [marked]);
const runMerge = React.useCallback(() => {
if (!keeper || marked.length === 0 || merging) return;
const preview = marked.slice(0, 3).map((m) => `- ${m.title}`).join('\n');
const more = marked.length > 3 ? `\n- and ${marked.length - 3} more` : '';
Alert.alert(
marked.length === 1 ? 'Merge duplicate?' : `Merge ${marked.length} duplicates?`,
`Keeping:\n${keeper.title}\n\nMerging in and removing:\n${preview}${more}`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Merge',
onPress: async () => {
setMerging(true);
const failed: string[] = [];
for (const dup of marked) {
try {
await client.mergeDuplicateMovie(keeper.id, dup.id);
} catch (e: any) {
failed.push(`${dup.title}: ${e?.message || 'unknown error'}`);
}
}
setMerging(false);
for (const k of MOVIE_LIST_KEYS) queryClient.invalidateQueries({ queryKey: [k] });
queryClient.invalidateQueries({ queryKey: ['movie', keeper.id] });
reset();
if (failed.length) {
Alert.alert(
`Merged ${marked.length - failed.length} of ${marked.length}`,
failed.slice(0, 5).join('\n\n'),
);
}
},
},
],
);
}, [keeper, marked, merging, client, reset, queryClient]);
const value = React.useMemo(
() => ({ keeper, isSelecting: !!keeper, isMarked, openActions, toggleDuplicate }),
[keeper, isMarked, openActions, toggleDuplicate],
);
return (
<Ctx.Provider value={value}>
{children}
{keeper ? (
<View style={styles.banner} pointerEvents="box-none">
<View style={styles.bannerInner}>
<Text style={styles.bannerText} numberOfLines={2}>
{marked.length === 0
? `Tap every duplicate of "${keeper.title}"`
: `${marked.length} marked, keeping "${keeper.title}"`}
</Text>
{merging ? (
<ActivityIndicator color={theme.fg} />
) : (
<Pressable
onPress={runMerge}
disabled={marked.length === 0}
style={[styles.bannerMerge, marked.length === 0 && styles.bannerMergeOff]}
hitSlop={8}
>
<Text style={styles.bannerMergeText}>
{marked.length > 0 ? `Merge ${marked.length}` : 'Merge'}
</Text>
</Pressable>
)}
<Pressable onPress={reset} style={styles.bannerCancel} hitSlop={8} disabled={merging}>
<Text style={styles.bannerCancelText}>Cancel</Text>
</Pressable>
</View>
</View>
) : null}
</Ctx.Provider>
);
}
const styles = StyleSheet.create({
banner: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 10 },
bannerInner: {
backgroundColor: theme.accent,
borderRadius: 12,
padding: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
bannerText: { color: theme.fg, fontSize: 13, fontWeight: '600', flex: 1 },
bannerMerge: { backgroundColor: theme.fg, borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
bannerMergeOff: { opacity: 0.4 },
bannerMergeText: { color: theme.accent, fontWeight: '800', fontSize: 13 },
bannerCancel: {
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 8,
paddingVertical: 6,
paddingHorizontal: 12,
},
bannerCancelText: { color: theme.fg, fontWeight: '700', fontSize: 13 },
});

View file

@ -314,6 +314,10 @@ export class GoonClient {
return this.request(`/scenes/${keepId}/merge/${dropId}`, { method: 'POST' }); return this.request(`/scenes/${keepId}/merge/${dropId}`, { method: 'POST' });
} }
async mergeDuplicateMovie(keepId: string, dropId: string): Promise<{ keep_id: string; dropped_id: string }> {
return this.request(`/movies/${keepId}/merge/${dropId}`, { method: 'POST' });
}
async markMoviePlaybackDead(movieId: string, playbackId: string): Promise<void> { async markMoviePlaybackDead(movieId: string, playbackId: string): Promise<void> {
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, { await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
method: 'POST', method: 'POST',

View file

@ -35,6 +35,15 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-16b',
date: 'October 2026',
items: [
'Movies of a favorite performer added since your last visit are marked NEW and listed first.',
'Merge duplicate movies like scenes: long-press the one to keep, tap its copies, then Merge.',
'Movies numbered differently ("8" and "VIII") or from the same studio spelled differently are now merged automatically.',
],
},
{ {
id: '2026-10-16a', id: '2026-10-16a',
date: 'October 2026', date: 'October 2026',

View file

@ -3,6 +3,7 @@
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useMovieActions } from '../MovieActionsContext';
import { fonts, theme } from '../theme'; import { fonts, theme } from '../theme';
import type { MovieOut } from '../types'; import type { MovieOut } from '../types';
@ -16,6 +17,19 @@ export function MoviePosterCard({
onPress: () => void; onPress: () => void;
}) { }) {
const studio = movie.studio?.name; const studio = movie.studio?.name;
// Przytrzymanie: scalanie duplikatow (MovieActionsContext). W trybie wyboru tap
// zaznacza i odznacza zamiast otwierac film; karta zachowywana nie reaguje.
const actions = useMovieActions();
const isKeeper = actions?.keeper?.id === movie.id;
const marked = actions?.isMarked(movie.id) ?? false;
const selecting = !!actions?.isSelecting;
const handlePress = () => {
if (selecting) {
if (!isKeeper) actions?.toggleDuplicate(movie);
return;
}
onPress();
};
// Watched indicator (parytet ze ScenesScreen): finished=true → dim posteru + // Watched indicator (parytet ze ScenesScreen): finished=true → dim posteru +
// ✓ badge w prawym górnym. Pośredni progress bez dim'a (user może wrócić). // ✓ badge w prawym górnym. Pośredni progress bez dim'a (user może wrócić).
// bug-report b207ff17 2026-05-26. // bug-report b207ff17 2026-05-26.
@ -25,8 +39,20 @@ export function MoviePosterCard({
? Math.min(100, Math.round((movie.position_sec / movie.duration_sec) * 100)) ? Math.min(100, Math.round((movie.position_sec / movie.duration_sec) * 100))
: 0; : 0;
return ( return (
<Pressable style={styles.card} onPress={onPress}> <Pressable
<View style={styles.posterWrap}> style={styles.card}
onPress={handlePress}
onLongPress={actions ? () => actions.openActions(movie) : undefined}
delayLongPress={350}
>
<View
style={[
styles.posterWrap,
selecting && !isKeeper && !marked && styles.posterSelectable,
marked && styles.posterMarked,
isKeeper && styles.posterKeeper,
]}
>
{movie.poster_url ? ( {movie.poster_url ? (
<Image <Image
source={{ uri: movie.poster_url }} source={{ uri: movie.poster_url }}
@ -43,7 +69,14 @@ export function MoviePosterCard({
<Text style={styles.ratingText}>{movie.rating.toFixed(1)}</Text> <Text style={styles.ratingText}>{movie.rating.toFixed(1)}</Text>
</View> </View>
) : null} ) : null}
{isNew ? ( {isKeeper || marked ? (
<View style={[styles.dupBadge, isKeeper && styles.keepBadge]}>
<Text style={[styles.dupBadgeText, isKeeper && styles.keepBadgeText]}>
{isKeeper ? 'KEEP' : 'DUPLICATE'}
</Text>
</View>
) : null}
{isNew && !isKeeper && !marked ? (
<View style={styles.newBadge}> <View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text> <Text style={styles.newBadgeText}>NEW</Text>
</View> </View>
@ -103,6 +136,21 @@ const styles = StyleSheet.create({
}, },
newBadgeText: { color: theme.fg, fontSize: 9, fontFamily: fonts.mono, fontWeight: '700', letterSpacing: 0.6 }, newBadgeText: { color: theme.fg, fontSize: 9, fontFamily: fonts.mono, fontWeight: '700', letterSpacing: 0.6 },
posterDimmed: { opacity: 0.45 }, posterDimmed: { opacity: 0.45 },
posterSelectable: { borderWidth: 2, borderColor: theme.accentText, opacity: 0.85 },
posterMarked: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 },
posterKeeper: { borderWidth: 3, borderColor: theme.accent, opacity: 1 },
dupBadge: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: theme.accent,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
keepBadge: { backgroundColor: theme.fg },
keepBadgeText: { color: theme.accent },
watchedBadge: { watchedBadge: {
position: 'absolute', position: 'absolute',
bottom: 8, bottom: 8,

View file

@ -223,7 +223,14 @@ export function PerformerScenesScreen() {
[scenesQuery.data?.items, seenSince], [scenesQuery.data?.items, seenSince],
); );
const movies = moviesQuery.data?.items ?? []; // Filmy jak sceny: NEW (dodane od ostatniej wizyty w ulubionych) na gore i z odznaka
// (zgloszenie f8c9486e: "widac nowe filmy, ale nie widac ktore to sa").
const movies = React.useMemo(() => {
const items = moviesQuery.data?.items ?? [];
if (!seenSince) return items;
const isNew = (m: (typeof items)[number]) => !!(m.created_at && m.created_at > seenSince);
return [...items.filter(isNew), ...items.filter((m) => !isNew(m))];
}, [moviesQuery.data?.items, seenSince]);
const scenesTotal = scenesQuery.data?.total ?? 0; const scenesTotal = scenesQuery.data?.total ?? 0;
const scenesSuffix = scenesQuery.data?.total_capped ? '+' : ''; const scenesSuffix = scenesQuery.data?.total_capped ? '+' : '';
const moviesTotal = moviesQuery.data?.total ?? 0; const moviesTotal = moviesQuery.data?.total ?? 0;
@ -340,6 +347,7 @@ export function PerformerScenesScreen() {
return ( return (
<MoviePosterCard <MoviePosterCard
movie={item} movie={item}
isNew={!!(seenSince && item.created_at && item.created_at > seenSince)}
onPress={() => navigation.navigate('MovieDetail', { id: item.id })} onPress={() => navigation.navigate('MovieDetail', { id: item.id })}
/> />
); );

View file

@ -0,0 +1,136 @@
"""Jednorazowy dedup filmow po poprawkach oceny z 2026-10-16 (zgloszenie f8c9486e).
Ocena (movie_score) liczy teraz cyfry rzymskie jak arabskie ("Hardcore Gangbangs VIII"
= "... 8") i uznaje za zgodne studia o tej samej nazwie bez spacji ("EvilAngel" z xtapes
i "Evil Angel"). Istniejace pary, ktore ingest wtedy rozdzielil, zostaja rozdzielone,
wiec ten skrypt przechodzi tylko po parach, na ktore poprawka ma wplyw, i scala te z
ocena >= AUTO_THRESHOLD. Pary odrzucone przez czlowieka (merge_candidates.rejected)
pomija. Zostaje starszy film (jego tytul i id), mlodszy jest wlewany.
python3 /srv/scripts/remerge_movies_roman_studio.py # dry-run
python3 /srv/scripts/remerge_movies_roman_studio.py --apply
"""
from __future__ import annotations
import argparse
import uuid
from sqlalchemy import select, text
from app.db import session_scope
from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus
from app.models.movie import Movie, MoviePerformer
from app.resolve.movie_match import find_movie_candidates
from app.resolve.movie_merge import MovieMergeError, merge_movies
from app.resolve.movie_score import AUTO_THRESHOLD, _roman_to_arabic, score_movie_candidate
def _affected_ids(session) -> list[uuid.UUID]:
"""Filmy z cyfra rzymska w tytule albo ze studiem, ktore ma blizniaka bez spacji."""
rows = session.execute(text("""
WITH twins AS (
SELECT a.id FROM studios a JOIN studios b
ON a.id <> b.id
AND replace(a.name_normalized, ' ', '') = replace(b.name_normalized, ' ', '')
AND length(replace(a.name_normalized, ' ', '')) >= 4
)
SELECT m.id FROM movies m
WHERE m.title_normalized ~ '(^| )(x{0,3})(ix|iv|v?i{0,3})( |$)'
OR m.studio_id IN (SELECT id FROM twins)
ORDER BY m.created_at DESC
""")).scalars().all()
return list(rows)
def _rejected_pairs(session) -> set[frozenset]:
rows = session.execute(
select(MergeCandidate.left_id, MergeCandidate.right_id).where(
MergeCandidate.kind == MergeKind.movie,
MergeCandidate.status == MergeStatus.rejected,
)
).all()
return {frozenset((a, b)) for a, b in rows}
def _keep_rank(session, movie: Movie) -> tuple:
"""Zostaje film z rokiem, potem z liczniejsza obsada, potem starszy. Sam wiek dawal
np. "Hard Core Gangbangs 8" bez roku zamiast "Hardcore Gangbangs VIII" z TPDB."""
cast = session.execute(
text("SELECT count(*) FROM movie_performers WHERE movie_id = :m"), {"m": movie.id}
).scalar_one()
return (movie.release_year is not None, cast, -movie.created_at.timestamp())
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args()
with session_scope() as s:
ids = _affected_ids(s)
rejected = _rejected_pairs(s)
if args.limit:
ids = ids[: args.limit]
print(f"filmy do sprawdzenia: {len(ids)}", flush=True)
gone: set[uuid.UUID] = set()
merged = 0
for n, mid in enumerate(ids, 1):
if n % 1000 == 0:
print(f"... {n}/{len(ids)}, par: {merged}", flush=True)
if mid in gone:
continue
with session_scope() as s:
m = s.get(Movie, mid)
if m is None or not m.title_normalized:
continue
perfs = [
r.performer_id
for r in s.execute(
MoviePerformer.__table__.select().where(MoviePerformer.movie_id == m.id)
).fetchall()
]
best, best_b = None, None
for c in find_movie_candidates(
s, title_normalized=m.title_normalized, studio_id=m.studio_id, release_year=m.release_year
):
if c.id == m.id or c.id in gone or frozenset((c.id, m.id)) in rejected:
continue
roman = _roman_to_arabic(c.title_normalized or "") != (c.title_normalized or "") or (
_roman_to_arabic(m.title_normalized) != m.title_normalized
)
twin_studio = bool(c.studio_id and m.studio_id and c.studio_id != m.studio_id)
if not (roman or twin_studio):
continue # ta para wyglada dla oceny tak samo jak przy ingescie
b = score_movie_candidate(
s,
candidate=c,
norm_title_normalized=m.title_normalized,
norm_release_year=m.release_year,
norm_studio_id=m.studio_id,
norm_performer_ids=perfs,
)
if b.composite >= AUTO_THRESHOLD and (best_b is None or b.composite > best_b.composite):
best, best_b = c, b
if best is None:
continue
keep, drop = sorted((best, m), key=lambda x: _keep_rank(s, x), reverse=True)
print(
f"{best_b.composite:.2f} ZOSTAJE {keep.title!r} ({keep.release_year})"
f" <= {drop.title!r} ({drop.release_year})",
flush=True,
)
if args.apply:
try:
merge_movies(s, keep_id=keep.id, drop_id=drop.id, resolved_by="remerge_roman_studio_20261016")
except MovieMergeError as e:
print(" blad:", e, flush=True)
continue
gone.add(drop.id)
merged += 1
print(f"{'scalono' if args.apply else 'do scalenia'}: {merged}", flush=True)
if __name__ == "__main__":
main()