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 typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import exists, func, select
|
from sqlalchemy import exists, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -384,3 +385,32 @@ def _movies_to_out(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class MovieMergeOut(BaseModel):
|
||||||
|
keep_id: uuid.UUID
|
||||||
|
dropped_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{keep_id}/merge/{drop_id}", response_model=MovieMergeOut)
|
||||||
|
def merge_duplicate_movie(
|
||||||
|
keep_id: uuid.UUID,
|
||||||
|
drop_id: uuid.UUID,
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
) -> MovieMergeOut:
|
||||||
|
"""Scal `drop_id` w `keep_id` z apki (przytrzymanie filmu, "merge into this one"),
|
||||||
|
jak przy scenach. Przenosi linki, obsade, tagi, rozdzialy i dane uzytkownikow
|
||||||
|
(movie_merge), kasuje `drop`. Zgloszenie f8c9486e (2026-10-16): te same filmy z dwoch
|
||||||
|
zrodel, ktorych automat nie zlaczyl ("Hardcore Gangbangs 8" i "... VIII")."""
|
||||||
|
from app.resolve.movie_merge import MovieMergeError, merge_movies
|
||||||
|
|
||||||
|
if keep_id == drop_id:
|
||||||
|
raise HTTPException(status_code=400, detail="keep_id == drop_id")
|
||||||
|
if session.get(Movie, keep_id) is None or session.get(Movie, drop_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="movie not found")
|
||||||
|
try:
|
||||||
|
merge_movies(session, keep_id=keep_id, drop_id=drop_id, resolved_by="user_long_press_duplicate")
|
||||||
|
except MovieMergeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
session.commit()
|
||||||
|
return MovieMergeOut(keep_id=keep_id, dropped_id=drop_id)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ Threshold (lower niż scenes, title dla movies ma niższy false-positive rate):
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -24,6 +25,7 @@ from rapidfuzz import fuzz
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.movie import Movie, MoviePerformer
|
from app.models.movie import Movie, MoviePerformer
|
||||||
|
from app.models.studio import Studio
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -58,6 +60,31 @@ AUTO_THRESHOLD = 0.85
|
||||||
REVIEW_THRESHOLD = 0.65
|
REVIEW_THRESHOLD = 0.65
|
||||||
|
|
||||||
|
|
||||||
|
_ROMAN_RE = re.compile(r"^(x{0,3})(ix|iv|v?i{0,3})$")
|
||||||
|
_ROMAN_VALUES = {"i": 1, "v": 5, "x": 10}
|
||||||
|
|
||||||
|
|
||||||
|
def _roman_value(token: str) -> int | None:
|
||||||
|
"""Wartosc cyfry rzymskiej II..XXXIX; pojedyncze litery (i, v, x) to zwykle slowa,
|
||||||
|
a "xx"/"xxx" w tych tytulach to slowo ("Star Wars XXX"), nie liczba."""
|
||||||
|
if len(token) < 2 or not _ROMAN_RE.match(token) or set(token) == {"x"}:
|
||||||
|
return None
|
||||||
|
total, prev = 0, 0
|
||||||
|
for ch in reversed(token):
|
||||||
|
v = _ROMAN_VALUES[ch]
|
||||||
|
total = total - v if v < prev else total + v
|
||||||
|
prev = max(prev, v)
|
||||||
|
return total if 2 <= total <= 39 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _roman_to_arabic(title_normalized: str) -> str:
|
||||||
|
out = []
|
||||||
|
for t in title_normalized.split():
|
||||||
|
v = _roman_value(t)
|
||||||
|
out.append(str(v) if v is not None else t)
|
||||||
|
return " ".join(out)
|
||||||
|
|
||||||
|
|
||||||
def _series_number(title_normalized: str) -> int | None:
|
def _series_number(title_normalized: str) -> int | None:
|
||||||
"""Ostatnia samodzielna liczba w tytule, z pominieciem lat (1900-2099).
|
"""Ostatnia samodzielna liczba w tytule, z pominieciem lat (1900-2099).
|
||||||
|
|
||||||
|
|
@ -221,6 +248,14 @@ def _slowa(title_normalized: str) -> list[str]:
|
||||||
return [t for t in out if t not in _SEO_WYPELNIACZE]
|
return [t for t in out if t not in _SEO_WYPELNIACZE]
|
||||||
|
|
||||||
|
|
||||||
|
def _same_studio_name(session: Session, a_id: uuid.UUID, b_id: uuid.UUID) -> bool:
|
||||||
|
a, b = session.get(Studio, a_id), session.get(Studio, b_id)
|
||||||
|
if a is None or b is None or not a.name_normalized or not b.name_normalized:
|
||||||
|
return False
|
||||||
|
ka, kb = a.name_normalized.replace(" ", ""), b.name_normalized.replace(" ", "")
|
||||||
|
return len(ka) >= 4 and ka == kb
|
||||||
|
|
||||||
|
|
||||||
def score_movie_candidate(
|
def score_movie_candidate(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -233,11 +268,12 @@ def score_movie_candidate(
|
||||||
"""Liczy score dopasowania incoming → candidate. Wszystko w [0,1]."""
|
"""Liczy score dopasowania incoming → candidate. Wszystko w [0,1]."""
|
||||||
breakdown = MovieScoreBreakdown(reasons={})
|
breakdown = MovieScoreBreakdown(reasons={})
|
||||||
|
|
||||||
# Title (always)
|
# Title (always). Cyfry rzymskie jako arabskie: "hardcore gangbangs viii" i "... 8" to
|
||||||
|
# ten sam numer serii (zgloszenie f8c9486e, xtapes vs inne zrodla).
|
||||||
series_reject = False
|
series_reject = False
|
||||||
if candidate.title_normalized and norm_title_normalized:
|
if candidate.title_normalized and norm_title_normalized:
|
||||||
breakdown.title, series_reject, powody = _title_signal(
|
breakdown.title, series_reject, powody = _title_signal(
|
||||||
candidate.title_normalized, norm_title_normalized
|
_roman_to_arabic(candidate.title_normalized), _roman_to_arabic(norm_title_normalized)
|
||||||
)
|
)
|
||||||
breakdown.reasons.update(powody)
|
breakdown.reasons.update(powody)
|
||||||
|
|
||||||
|
|
@ -253,9 +289,12 @@ def score_movie_candidate(
|
||||||
else:
|
else:
|
||||||
breakdown.year = 0.0
|
breakdown.year = 0.0
|
||||||
|
|
||||||
# Studio match, binary
|
# Studio match, binary. Rozne wiersze o tej samej nazwie bez spacji ("EvilAngel" z xtapes
|
||||||
|
# i "Evil Angel" z TPDB) to to samo studio; w bazie jest ok. 2 tys. takich par.
|
||||||
if candidate.studio_id and norm_studio_id:
|
if candidate.studio_id and norm_studio_id:
|
||||||
breakdown.studio_match = candidate.studio_id == norm_studio_id
|
breakdown.studio_match = candidate.studio_id == norm_studio_id or _same_studio_name(
|
||||||
|
session, candidate.studio_id, norm_studio_id
|
||||||
|
)
|
||||||
|
|
||||||
# Cast Jaccard, gdy obie strony mają performerów
|
# Cast Jaccard, gdy obie strony mają performerów
|
||||||
norm_perf_set = {str(pid) for pid in norm_performer_ids if pid is not None}
|
norm_perf_set = {str(pid) for pid in norm_performer_ids if pid is not None}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.studio import Studio, StudioAlias, StudioExternalRef
|
from app.models.studio import Studio, StudioAlias, StudioExternalRef
|
||||||
|
|
@ -21,6 +21,24 @@ from app.normalize.text import slugify
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_name_twin(session: Session, name_normalized: str) -> Studio | None:
|
||||||
|
"""Jedno studio o tej samej nazwie bez spacji ("evilangel" = "evil angel").
|
||||||
|
|
||||||
|
xtapes podaje studia sklejone ("[EvilAngel]", "[TeamSkeet]"), a reszta zrodel ze
|
||||||
|
spacja; osobne studio psulo dopasowanie filmow (studio "rozne" obnizalo ocene, np.
|
||||||
|
"Hardcore Gangbangs 8" i "... VIII", zgloszenie f8c9486e). Tylko przy jednym trafieniu.
|
||||||
|
"""
|
||||||
|
compact = (name_normalized or "").replace(" ", "")
|
||||||
|
if len(compact) < 4:
|
||||||
|
return None
|
||||||
|
rows = session.execute(
|
||||||
|
select(Studio)
|
||||||
|
.where(func.replace(Studio.name_normalized, " ", "") == compact)
|
||||||
|
.limit(2)
|
||||||
|
).scalars().all()
|
||||||
|
return rows[0] if len(rows) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def resolve_studio(
|
def resolve_studio(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -43,6 +61,8 @@ def resolve_studio(
|
||||||
studio = session.execute(
|
studio = session.execute(
|
||||||
select(Studio).where(Studio.name_normalized == norm.name_normalized)
|
select(Studio).where(Studio.name_normalized == norm.name_normalized)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
if studio is None:
|
||||||
|
studio = _compact_name_twin(session, norm.name_normalized)
|
||||||
if studio is None:
|
if studio is None:
|
||||||
studio = Studio(
|
studio = Studio(
|
||||||
name=norm.name,
|
name=norm.name,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import { GoonClient } from './src/api';
|
import { GoonClient } from './src/api';
|
||||||
import { ClientProvider } from './src/ClientContext';
|
import { ClientProvider } from './src/ClientContext';
|
||||||
import { PreferencesProvider } from './src/PreferencesContext';
|
import { PreferencesProvider } from './src/PreferencesContext';
|
||||||
|
import { MovieActionsProvider } from './src/MovieActionsContext';
|
||||||
import { SceneActionsProvider } from './src/SceneActionsContext';
|
import { SceneActionsProvider } from './src/SceneActionsContext';
|
||||||
import { OnboardingModal } from './src/components/OnboardingModal';
|
import { OnboardingModal } from './src/components/OnboardingModal';
|
||||||
import { SurveyModal } from './src/components/SurveyModal';
|
import { SurveyModal } from './src/components/SurveyModal';
|
||||||
|
|
@ -505,18 +506,20 @@ export default function App() {
|
||||||
<ClientProvider client={client}>
|
<ClientProvider client={client}>
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<SceneActionsProvider>
|
<SceneActionsProvider>
|
||||||
<AppNavigator
|
<MovieActionsProvider>
|
||||||
client={client}
|
<AppNavigator
|
||||||
appVersion={APP_VERSION}
|
client={client}
|
||||||
onLogout={async () => {
|
appVersion={APP_VERSION}
|
||||||
await clearCredentials();
|
onLogout={async () => {
|
||||||
setClient(null);
|
await clearCredentials();
|
||||||
queryClient.clear();
|
setClient(null);
|
||||||
}}
|
queryClient.clear();
|
||||||
/>
|
}}
|
||||||
<WhatsNewModal />
|
/>
|
||||||
<SurveyModal />
|
<WhatsNewModal />
|
||||||
<OnboardingModal />
|
<SurveyModal />
|
||||||
|
<OnboardingModal />
|
||||||
|
</MovieActionsProvider>
|
||||||
</SceneActionsProvider>
|
</SceneActionsProvider>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
</ClientProvider>
|
</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' });
|
return this.request(`/scenes/${keepId}/merge/${dropId}`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async mergeDuplicateMovie(keepId: string, dropId: string): Promise<{ keep_id: string; dropped_id: string }> {
|
||||||
|
return this.request(`/movies/${keepId}/merge/${dropId}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
async markMoviePlaybackDead(movieId: string, playbackId: string): Promise<void> {
|
async markMoviePlaybackDead(movieId: string, playbackId: string): Promise<void> {
|
||||||
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
|
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,15 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-10-16b',
|
||||||
|
date: 'October 2026',
|
||||||
|
items: [
|
||||||
|
'Movies of a favorite performer added since your last visit are marked NEW and listed first.',
|
||||||
|
'Merge duplicate movies like scenes: long-press the one to keep, tap its copies, then Merge.',
|
||||||
|
'Movies numbered differently ("8" and "VIII") or from the same studio spelled differently are now merged automatically.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-10-16a',
|
id: '2026-10-16a',
|
||||||
date: 'October 2026',
|
date: 'October 2026',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useMovieActions } from '../MovieActionsContext';
|
||||||
import { fonts, theme } from '../theme';
|
import { fonts, theme } from '../theme';
|
||||||
import type { MovieOut } from '../types';
|
import type { MovieOut } from '../types';
|
||||||
|
|
||||||
|
|
@ -16,6 +17,19 @@ export function MoviePosterCard({
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
const studio = movie.studio?.name;
|
const studio = movie.studio?.name;
|
||||||
|
// Przytrzymanie: scalanie duplikatow (MovieActionsContext). W trybie wyboru tap
|
||||||
|
// zaznacza i odznacza zamiast otwierac film; karta zachowywana nie reaguje.
|
||||||
|
const actions = useMovieActions();
|
||||||
|
const isKeeper = actions?.keeper?.id === movie.id;
|
||||||
|
const marked = actions?.isMarked(movie.id) ?? false;
|
||||||
|
const selecting = !!actions?.isSelecting;
|
||||||
|
const handlePress = () => {
|
||||||
|
if (selecting) {
|
||||||
|
if (!isKeeper) actions?.toggleDuplicate(movie);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPress();
|
||||||
|
};
|
||||||
// Watched indicator (parytet ze ScenesScreen): finished=true → dim posteru +
|
// Watched indicator (parytet ze ScenesScreen): finished=true → dim posteru +
|
||||||
// ✓ badge w prawym górnym. Pośredni progress bez dim'a (user może wrócić).
|
// ✓ badge w prawym górnym. Pośredni progress bez dim'a (user może wrócić).
|
||||||
// bug-report b207ff17 2026-05-26.
|
// bug-report b207ff17 2026-05-26.
|
||||||
|
|
@ -25,8 +39,20 @@ export function MoviePosterCard({
|
||||||
? Math.min(100, Math.round((movie.position_sec / movie.duration_sec) * 100))
|
? Math.min(100, Math.round((movie.position_sec / movie.duration_sec) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
return (
|
return (
|
||||||
<Pressable style={styles.card} onPress={onPress}>
|
<Pressable
|
||||||
<View style={styles.posterWrap}>
|
style={styles.card}
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={actions ? () => actions.openActions(movie) : undefined}
|
||||||
|
delayLongPress={350}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.posterWrap,
|
||||||
|
selecting && !isKeeper && !marked && styles.posterSelectable,
|
||||||
|
marked && styles.posterMarked,
|
||||||
|
isKeeper && styles.posterKeeper,
|
||||||
|
]}
|
||||||
|
>
|
||||||
{movie.poster_url ? (
|
{movie.poster_url ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: movie.poster_url }}
|
source={{ uri: movie.poster_url }}
|
||||||
|
|
@ -43,7 +69,14 @@ export function MoviePosterCard({
|
||||||
<Text style={styles.ratingText}>{movie.rating.toFixed(1)}</Text>
|
<Text style={styles.ratingText}>{movie.rating.toFixed(1)}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{isNew ? (
|
{isKeeper || marked ? (
|
||||||
|
<View style={[styles.dupBadge, isKeeper && styles.keepBadge]}>
|
||||||
|
<Text style={[styles.dupBadgeText, isKeeper && styles.keepBadgeText]}>
|
||||||
|
{isKeeper ? 'KEEP' : 'DUPLICATE'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{isNew && !isKeeper && !marked ? (
|
||||||
<View style={styles.newBadge}>
|
<View style={styles.newBadge}>
|
||||||
<Text style={styles.newBadgeText}>NEW</Text>
|
<Text style={styles.newBadgeText}>NEW</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -103,6 +136,21 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
newBadgeText: { color: theme.fg, fontSize: 9, fontFamily: fonts.mono, fontWeight: '700', letterSpacing: 0.6 },
|
newBadgeText: { color: theme.fg, fontSize: 9, fontFamily: fonts.mono, fontWeight: '700', letterSpacing: 0.6 },
|
||||||
posterDimmed: { opacity: 0.45 },
|
posterDimmed: { opacity: 0.45 },
|
||||||
|
posterSelectable: { borderWidth: 2, borderColor: theme.accentText, opacity: 0.85 },
|
||||||
|
posterMarked: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 },
|
||||||
|
posterKeeper: { borderWidth: 3, borderColor: theme.accent, opacity: 1 },
|
||||||
|
dupBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
backgroundColor: theme.accent,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
|
||||||
|
keepBadge: { backgroundColor: theme.fg },
|
||||||
|
keepBadgeText: { color: theme.accent },
|
||||||
watchedBadge: {
|
watchedBadge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,14 @@ export function PerformerScenesScreen() {
|
||||||
[scenesQuery.data?.items, seenSince],
|
[scenesQuery.data?.items, seenSince],
|
||||||
);
|
);
|
||||||
|
|
||||||
const movies = moviesQuery.data?.items ?? [];
|
// Filmy jak sceny: NEW (dodane od ostatniej wizyty w ulubionych) na gore i z odznaka
|
||||||
|
// (zgloszenie f8c9486e: "widac nowe filmy, ale nie widac ktore to sa").
|
||||||
|
const movies = React.useMemo(() => {
|
||||||
|
const items = moviesQuery.data?.items ?? [];
|
||||||
|
if (!seenSince) return items;
|
||||||
|
const isNew = (m: (typeof items)[number]) => !!(m.created_at && m.created_at > seenSince);
|
||||||
|
return [...items.filter(isNew), ...items.filter((m) => !isNew(m))];
|
||||||
|
}, [moviesQuery.data?.items, seenSince]);
|
||||||
const scenesTotal = scenesQuery.data?.total ?? 0;
|
const scenesTotal = scenesQuery.data?.total ?? 0;
|
||||||
const scenesSuffix = scenesQuery.data?.total_capped ? '+' : '';
|
const scenesSuffix = scenesQuery.data?.total_capped ? '+' : '';
|
||||||
const moviesTotal = moviesQuery.data?.total ?? 0;
|
const moviesTotal = moviesQuery.data?.total ?? 0;
|
||||||
|
|
@ -340,6 +347,7 @@ export function PerformerScenesScreen() {
|
||||||
return (
|
return (
|
||||||
<MoviePosterCard
|
<MoviePosterCard
|
||||||
movie={item}
|
movie={item}
|
||||||
|
isNew={!!(seenSince && item.created_at && item.created_at > seenSince)}
|
||||||
onPress={() => navigation.navigate('MovieDetail', { id: item.id })}
|
onPress={() => navigation.navigate('MovieDetail', { id: item.id })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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