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()