feat(movies): NEW przy filmach ulubionych, scalanie duplikatow przytrzymaniem
Some checks are pending
Backend tests / test (push) Waiting to run
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:
parent
5e203e7e98
commit
f167c491c8
10 changed files with 486 additions and 21 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,6 +506,7 @@ export default function App() {
|
|||
<ClientProvider client={client}>
|
||||
<PreferencesProvider>
|
||||
<SceneActionsProvider>
|
||||
<MovieActionsProvider>
|
||||
<AppNavigator
|
||||
client={client}
|
||||
appVersion={APP_VERSION}
|
||||
|
|
@ -517,6 +519,7 @@ export default function App() {
|
|||
<WhatsNewModal />
|
||||
<SurveyModal />
|
||||
<OnboardingModal />
|
||||
</MovieActionsProvider>
|
||||
</SceneActionsProvider>
|
||||
</PreferencesProvider>
|
||||
</ClientProvider>
|
||||
|
|
|
|||
168
mobile/src/MovieActionsContext.tsx
Normal file
168
mobile/src/MovieActionsContext.tsx
Normal 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 },
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
136
scripts/remerge_movies_roman_studio.py
Normal file
136
scripts/remerge_movies_roman_studio.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue