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 fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
@ -384,3 +385,32 @@ def _movies_to_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
import re
import uuid
from collections.abc import Iterable
from dataclasses import dataclass
@ -24,6 +25,7 @@ from rapidfuzz import fuzz
from sqlalchemy.orm import Session
from app.models.movie import Movie, MoviePerformer
from app.models.studio import Studio
@dataclass
@ -58,6 +60,31 @@ AUTO_THRESHOLD = 0.85
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:
"""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]
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(
session: Session,
*,
@ -233,11 +268,12 @@ def score_movie_candidate(
"""Liczy score dopasowania incoming → candidate. Wszystko w [0,1]."""
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
if candidate.title_normalized and norm_title_normalized:
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)
@ -253,9 +289,12 @@ def score_movie_candidate(
else:
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:
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
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 uuid
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.studio import Studio, StudioAlias, StudioExternalRef
@ -21,6 +21,24 @@ from app.normalize.text import slugify
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(
session: Session,
*,
@ -43,6 +61,8 @@ def resolve_studio(
studio = session.execute(
select(Studio).where(Studio.name_normalized == norm.name_normalized)
).scalar_one_or_none()
if studio is None:
studio = _compact_name_twin(session, norm.name_normalized)
if studio is None:
studio = Studio(
name=norm.name,

View file

@ -27,6 +27,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GoonClient } from './src/api';
import { ClientProvider } from './src/ClientContext';
import { PreferencesProvider } from './src/PreferencesContext';
import { MovieActionsProvider } from './src/MovieActionsContext';
import { SceneActionsProvider } from './src/SceneActionsContext';
import { OnboardingModal } from './src/components/OnboardingModal';
import { SurveyModal } from './src/components/SurveyModal';
@ -505,18 +506,20 @@ export default function App() {
<ClientProvider client={client}>
<PreferencesProvider>
<SceneActionsProvider>
<AppNavigator
client={client}
appVersion={APP_VERSION}
onLogout={async () => {
await clearCredentials();
setClient(null);
queryClient.clear();
}}
/>
<WhatsNewModal />
<SurveyModal />
<OnboardingModal />
<MovieActionsProvider>
<AppNavigator
client={client}
appVersion={APP_VERSION}
onLogout={async () => {
await clearCredentials();
setClient(null);
queryClient.clear();
}}
/>
<WhatsNewModal />
<SurveyModal />
<OnboardingModal />
</MovieActionsProvider>
</SceneActionsProvider>
</PreferencesProvider>
</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' });
}
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> {
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
method: 'POST',

View file

@ -35,6 +35,15 @@ export type 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',
date: 'October 2026',

View file

@ -3,6 +3,7 @@
import { Image } from 'expo-image';
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useMovieActions } from '../MovieActionsContext';
import { fonts, theme } from '../theme';
import type { MovieOut } from '../types';
@ -16,6 +17,19 @@ export function MoviePosterCard({
onPress: () => void;
}) {
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 +
// ✓ badge w prawym górnym. Pośredni progress bez dim'a (user może wrócić).
// 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))
: 0;
return (
<Pressable style={styles.card} onPress={onPress}>
<View style={styles.posterWrap}>
<Pressable
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 ? (
<Image
source={{ uri: movie.poster_url }}
@ -43,7 +69,14 @@ export function MoviePosterCard({
<Text style={styles.ratingText}>{movie.rating.toFixed(1)}</Text>
</View>
) : 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}>
<Text style={styles.newBadgeText}>NEW</Text>
</View>
@ -103,6 +136,21 @@ const styles = StyleSheet.create({
},
newBadgeText: { color: theme.fg, fontSize: 9, fontFamily: fonts.mono, fontWeight: '700', letterSpacing: 0.6 },
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: {
position: 'absolute',
bottom: 8,

View file

@ -223,7 +223,14 @@ export function PerformerScenesScreen() {
[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 scenesSuffix = scenesQuery.data?.total_capped ? '+' : '';
const moviesTotal = moviesQuery.data?.total ?? 0;
@ -340,6 +347,7 @@ export function PerformerScenesScreen() {
return (
<MoviePosterCard
movie={item}
isNew={!!(seenSince && item.created_at && item.created_at > seenSince)}
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()