diff --git a/app/api/movies.py b/app/api/movies.py index 76d2933..0b7a8de 100644 --- a/app/api/movies.py +++ b/app/api/movies.py @@ -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) diff --git a/app/resolve/movie_score.py b/app/resolve/movie_score.py index bd7ad0d..334540e 100644 --- a/app/resolve/movie_score.py +++ b/app/resolve/movie_score.py @@ -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} diff --git a/app/resolve/studio_resolver.py b/app/resolve/studio_resolver.py index 90270a2..28b05d5 100644 --- a/app/resolve/studio_resolver.py +++ b/app/resolve/studio_resolver.py @@ -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, diff --git a/mobile/App.tsx b/mobile/App.tsx index a92966c..47985ec 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -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() { - { - await clearCredentials(); - setClient(null); - queryClient.clear(); - }} - /> - - - + + { + await clearCredentials(); + setClient(null); + queryClient.clear(); + }} + /> + + + + diff --git a/mobile/src/MovieActionsContext.tsx b/mobile/src/MovieActionsContext.tsx new file mode 100644 index 0000000..8401b5d --- /dev/null +++ b/mobile/src/MovieActionsContext.tsx @@ -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(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(null); + const [marked, setMarked] = React.useState([]); + 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 ( + + {children} + {keeper ? ( + + + + {marked.length === 0 + ? `Tap every duplicate of "${keeper.title}"` + : `${marked.length} marked, keeping "${keeper.title}"`} + + {merging ? ( + + ) : ( + + + {marked.length > 0 ? `Merge ${marked.length}` : 'Merge'} + + + )} + + Cancel + + + + ) : null} + + ); +} + +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 }, +}); diff --git a/mobile/src/api.ts b/mobile/src/api.ts index c58c332..278348c 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -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 { await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, { method: 'POST', diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index ad9a60d..d25b09e 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -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', diff --git a/mobile/src/components/MoviePosterCard.tsx b/mobile/src/components/MoviePosterCard.tsx index 4b9fdd7..bfda8e5 100644 --- a/mobile/src/components/MoviePosterCard.tsx +++ b/mobile/src/components/MoviePosterCard.tsx @@ -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 ( - - + actions.openActions(movie) : undefined} + delayLongPress={350} + > + {movie.poster_url ? ( {movie.rating.toFixed(1)} ) : null} - {isNew ? ( + {isKeeper || marked ? ( + + + {isKeeper ? 'KEEP' : 'DUPLICATE'} + + + ) : null} + {isNew && !isKeeper && !marked ? ( NEW @@ -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, diff --git a/mobile/src/screens/PerformerScenesScreen.tsx b/mobile/src/screens/PerformerScenesScreen.tsx index ec8f975..651d823 100644 --- a/mobile/src/screens/PerformerScenesScreen.tsx +++ b/mobile/src/screens/PerformerScenesScreen.tsx @@ -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 ( seenSince)} onPress={() => navigation.navigate('MovieDetail', { id: item.id })} /> ); diff --git a/scripts/remerge_movies_roman_studio.py b/scripts/remerge_movies_roman_studio.py new file mode 100644 index 0000000..a8c9274 --- /dev/null +++ b/scripts/remerge_movies_roman_studio.py @@ -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()