Compare commits

..

No commits in common. "0def1a36191c0fa2739ed82cc60b33049932bb0a" and "ce174b671e232ab6e2566fd97b7fe2eacd520ecd" have entirely different histories.

8 changed files with 14 additions and 174 deletions

View file

@ -15,14 +15,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import and_, case, exists, func, or_, select from sqlalchemy import and_, exists, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.models.movie import Movie, MovieTag from app.models.movie import Movie, MovieTag
from app.models.movie_playback_source import MoviePlaybackSource from app.models.movie_playback_source import MoviePlaybackSource
from app.models.performer import Performer, PerformerAlias from app.models.performer import Performer
from app.models.playback_source import PlaybackSource from app.models.playback_source import PlaybackSource
from app.models.scene import ScenePerformer, SceneTag from app.models.scene import ScenePerformer, SceneTag
from app.models.studio import Studio from app.models.studio import Studio
@ -30,37 +30,6 @@ from app.models.tag import Tag
from app.models.tag_vertical_count import TagVerticalCount from app.models.tag_vertical_count import TagVerticalCount
from app.verticals import VERTICALS from app.verticals import VERTICALS
def _performer_name_patterns(q: str) -> list[str]:
qn = " ".join(q.lower().split())
pats = [f"%{qn}%"]
words = qn.split(" ")
if len(words) == 2:
pats.append(f"%{words[1]} {words[0]}%")
return pats
def _performer_name_rank(q: str):
"""0 = trafienie w nazwie, 1 = tylko w aliasie; sortujemy po tym przed liczba scen,
inaczej "anna" dawalo na gorze duze nazwiska z aliasem "Adrianna"."""
return case((or_(*[Performer.name_normalized.ilike(p) for p in _performer_name_patterns(q)]), 0), else_=1)
def _performer_name_clause(q: str):
"""Szukanie wykonawcy: podciag nazwy ALBO aliasu, a dla dwoch slow tez w odwrotnej
kolejnosci. Zgloszenie 436121fb: "Takase Rina" (japonska kolejnosc) nie znajdowalo
"Rina Takase" i wygladalo, jakby jej nie bylo. Obie kolumny maja indeksy trigramowe."""
pats = _performer_name_patterns(q)
return or_(
*[Performer.name_normalized.ilike(p) for p in pats],
exists(
select(1).where(
PerformerAlias.performer_id == Performer.id,
or_(*[PerformerAlias.alias_normalized.ilike(p) for p in pats]),
)
),
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)]) router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
@ -325,15 +294,14 @@ def list_performers(
.join(v_count, v_count.c.performer_id == Performer.id) .join(v_count, v_count.c.performer_id == Performer.id)
) )
if q: if q:
base = base.where(_performer_name_clause(q)) base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%"))
total = session.execute( total = session.execute(
select(func.count()).select_from(base.subquery()) select(func.count()).select_from(base.subquery())
).scalar_one() ).scalar_one()
rank = [_performer_name_rank(q)] if q else []
if order in ("scene_count", "popular"): if order in ("scene_count", "popular"):
ordered = base.order_by(*rank, v_count.c.c.desc(), Performer.canonical_name.asc()) ordered = base.order_by(v_count.c.c.desc(), Performer.canonical_name.asc())
else: else:
ordered = base.order_by(*rank, Performer.canonical_name.asc()) ordered = base.order_by(Performer.canonical_name.asc())
rows = session.execute( rows = session.execute(
ordered.offset((page - 1) * per_page).limit(per_page) ordered.offset((page - 1) * per_page).limit(per_page)
).all() ).all()
@ -355,19 +323,18 @@ def list_performers(
# 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019. # 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019.
base = select(Performer) base = select(Performer)
if q: if q:
base = base.where(_performer_name_clause(q)) base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%"))
total = session.execute( total = session.execute(
select(func.count()).select_from(base.subquery()) select(func.count()).select_from(base.subquery())
).scalar_one() ).scalar_one()
rank = [_performer_name_rank(q)] if q else []
if order in ("scene_count", "popular"): if order in ("scene_count", "popular"):
ordered = base.order_by( ordered = base.order_by(
*rank, Performer.scene_count.desc(), Performer.canonical_name.asc() Performer.scene_count.desc(), Performer.canonical_name.asc()
) )
else: else:
ordered = base.order_by(*rank, Performer.canonical_name.asc()) ordered = base.order_by(Performer.canonical_name.asc())
perfs_page = session.execute( perfs_page = session.execute(
ordered.offset((page - 1) * per_page).limit(per_page) ordered.offset((page - 1) * per_page).limit(per_page)

View file

@ -1,7 +1,6 @@
"""RawMovie → NormalizedMovie. Reuse normalize_studio/performer/tag z scenes.py.""" """RawMovie → NormalizedMovie. Reuse normalize_studio/performer/tag z scenes.py."""
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
@ -17,24 +16,6 @@ from app.normalize.scenes import (
from app.normalize.text import clean_description, normalize, slugify, unescape_html from app.normalize.text import clean_description, normalize, slugify, unescape_html
# Okladki z sieci pandanetwork (i*.wp.com/pandanetwork.club/.../<numer>h.jpg) to kopie okladek
# Adult Empire pod tym samym numerem produktu. Zrodlo oddaje juz 404, a pamiec podreczna wp.com
# wygasa: 2026-10-12 40 z 40 losowych okladek martwych, zgloszenie 6799253d (Tushy Raw bez
# okladek). Oryginal z Adult Empire jest pod numerem z nazwy pliku (sprawdzone wzrokowo i po
# tytulach na probce).
_PANDA_POSTER_RE = re.compile(r"pandanetwork\.club/.*/(\d{6,8})h(?:-\d)?\.jpe?g", re.IGNORECASE)
def stable_poster_url(url: str | None) -> str | None:
if not url:
return url
m = _PANDA_POSTER_RE.search(url)
if not m:
return url
pid = m.group(1)
return f"https://imgs1cdn.adultempire.com/products/{pid[-2:]}/{pid}h.jpg"
@dataclass @dataclass
class NormalizedMovie: class NormalizedMovie:
external_id: str external_id: str
@ -74,7 +55,7 @@ def normalize_movie(raw: RawMovie) -> NormalizedMovie:
director=raw.director, director=raw.director,
country=raw.country, country=raw.country,
rating=raw.rating, rating=raw.rating,
poster_url=stable_poster_url(raw.poster_url), poster_url=raw.poster_url,
backdrop_url=raw.backdrop_url, backdrop_url=raw.backdrop_url,
url=raw.url, url=raw.url,
studio=normalize_studio(raw.studio) if raw.studio else None, studio=normalize_studio(raw.studio) if raw.studio else None,

View file

@ -35,17 +35,6 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-12a',
date: 'October 2026',
items: [
'App lock: when you set or changed your PIN, the digits from the previous step stayed in, so a changed PIN could be saved as the old and new digits glued together. Fixed. If you got locked out of changing your PIN, use the new Forgot PIN? link and confirm with your fingerprint or phone screen lock.',
'Edge no longer vibrates on every swipe.',
'Huawei phones with Google services crashed every time a video opened. The Cast button is now off on Huawei, videos play.',
'Searching performers also finds the name in reverse order and their aliases, so "Takase Rina" finds Rina Takase.',
'Missing movie covers are back for most movies.',
],
},
{ {
id: '2026-10-06b', id: '2026-10-06b',
date: 'October 2026', date: 'October 2026',

View file

@ -13,32 +13,17 @@
* w sobie niczego nie subskrybuje i jest bezpieczne wszędzie. * w sobie niczego nie subskrybuje i jest bezpieczne wszędzie.
*/ */
import React from 'react'; import React from 'react';
import { Alert, Platform } from 'react-native'; import { Alert } from 'react-native';
import { CastButton, CastContext, useRemoteMediaClient } from 'react-native-google-cast'; import { CastButton, CastContext, useRemoteMediaClient } from 'react-native-google-cast';
import type { CastTarget } from '../lib/cast'; import type { CastTarget } from '../lib/cast';
import { theme } from '../theme'; import { theme } from '../theme';
/**
* Huawei z EMUI i uslugami Google (Honor 20, YAL-L21, Android 10): nakladka systemowa daje
* polprzezroczysty kolor motywu, a MediaRouteButton przy tworzeniu liczy na nim kontrast
* (androidx ColorUtils.calculateContrast) i rzuca "background can not be translucent".
* Aplikacja padala przy KAZDYM otwarciu filmu (GOON-82, 2026-10-08). Bez przycisku Cast
* film gra normalnie; na nowszych Huawei bez uslug Google Cast i tak nie dziala.
*/
function castCrashesOnThisDevice(): boolean {
if (Platform.OS !== 'android') return false;
const c = Platform.constants as { Manufacturer?: string; Brand?: string };
const maker = `${c.Manufacturer ?? ''} ${c.Brand ?? ''}`.toLowerCase();
return maker.includes('huawei');
}
/** Czy na tym urządzeniu w ogóle da się castować. Null = jeszcze nie wiadomo. */ /** Czy na tym urządzeniu w ogóle da się castować. Null = jeszcze nie wiadomo. */
export function useCastAvailable(): boolean { export function useCastAvailable(): boolean {
const [available, setAvailable] = React.useState(false); const [available, setAvailable] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
let alive = true; let alive = true;
if (castCrashesOnThisDevice()) return;
(async () => { (async () => {
try { try {
const state = await CastContext.getPlayServicesState(); const state = await CastContext.getPlayServicesState();

View file

@ -10,6 +10,7 @@
* APK, tam zakladka Edge jest ukryta (`swipeAvailable`). * APK, tam zakladka Edge jest ukryta (`swipeAvailable`).
*/ */
import { createVideoPlayer, type VideoPlayer } from 'expo-video'; import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import * as Haptics from 'expo-haptics';
import React from 'react'; import React from 'react';
import { AppState, StyleSheet, View } from 'react-native'; import { AppState, StyleSheet, View } from 'react-native';
@ -221,8 +222,7 @@ export function ClipPager({
// ignore // ignore
} }
} }
// Bez wibracji przy przewinieciu (zgloszenie 12db400a: "bardzo meczace" przy Haptics.selectionAsync().catch(() => {});
// przewijaniu jednego klipu za drugim).
return () => { return () => {
sub.remove(); sub.remove();
subStatus.remove(); subStatus.remove();

View file

@ -64,33 +64,6 @@ export async function biometricAvailable(): Promise<boolean> {
return enrolled; return enrolled;
} }
/** Czy telefon ma jakakolwiek blokade (odcisk, twarz albo PIN/wzor ekranu). */
export async function ownerAuthAvailable(): Promise<boolean> {
try {
const level = await LocalAuthentication.getEnrolledLevelAsync();
return level !== LocalAuthentication.SecurityLevel.NONE;
} catch {
return false;
}
}
/**
* Potwierdzenie, ze to wlasciciel telefonu: odcisk albo blokada ekranu Androida.
* Do resetu zapomnianego PIN-u (zgloszenie a8d85930: odblokowanie palcem dzialalo, ale
* zmiana albo wylaczenie PIN-u wymagaly starego, ktorego user nie pamietal).
*/
export async function authenticateOwner(): Promise<boolean> {
try {
const res = await LocalAuthentication.authenticateAsync({
promptMessage: 'Confirm it is you',
disableDeviceFallback: false,
});
return res.success;
} catch {
return false;
}
}
export async function authenticateBiometric(): Promise<boolean> { export async function authenticateBiometric(): Promise<boolean> {
const res = await LocalAuthentication.authenticateAsync({ const res = await LocalAuthentication.authenticateAsync({
promptMessage: 'Unlock Goon', promptMessage: 'Unlock Goon',

View file

@ -17,12 +17,10 @@ import { useClient } from '../ClientContext';
import { getDeviceId } from '../storage'; import { getDeviceId } from '../storage';
import { import {
AppLockSettings, AppLockSettings,
authenticateOwner,
biometricAvailable, biometricAvailable,
clearPin, clearPin,
DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS,
getSettings, getSettings,
ownerAuthAvailable,
setBioEnabled, setBioEnabled,
setEnabled, setEnabled,
setPin, setPin,
@ -61,7 +59,6 @@ type Stage = 'menu' | 'enter-current' | 'set-new' | 'confirm-new' | 'disable-con
export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) { export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
const [settings, setSettings] = useState<AppLockSettings | null>(null); const [settings, setSettings] = useState<AppLockSettings | null>(null);
const [bioAvailable, setBioAvailable] = useState(false); const [bioAvailable, setBioAvailable] = useState(false);
const [ownerAuth, setOwnerAuth] = useState(false);
const [stage, setStage] = useState<Stage>('menu'); const [stage, setStage] = useState<Stage>('menu');
const [newPin, setNewPin] = useState(''); const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null); const [errorText, setErrorText] = useState<string | null>(null);
@ -77,15 +74,9 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
async function refresh() { async function refresh() {
const [s, bio, dev, owner] = await Promise.all([ const [s, bio, dev] = await Promise.all([getSettings(), biometricAvailable(), getDeviceId()]);
getSettings(),
biometricAvailable(),
getDeviceId(),
ownerAuthAvailable(),
]);
setSettings(s); setSettings(s);
setBioAvailable(bio); setBioAvailable(bio);
setOwnerAuth(owner);
setDeviceId(dev); setDeviceId(dev);
} }
@ -127,27 +118,6 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
} }
}; };
// Zapomniany PIN: odcisk albo blokada ekranu telefonu zamiast starego PIN-u. Bez blokady
// na telefonie nie ma czym potwierdzic wlasciciela, wiec tylko wyjasniamy, co zrobic.
const forgotPin = (onConfirmed: () => Promise<void> | void) => ({
label: 'Forgot PIN?',
onPress: async () => {
if (!ownerAuth) {
Alert.alert(
'Forgot PIN',
'This phone has no fingerprint or screen lock, so Goon cannot check it is you. ' +
'Reinstalling the app removes the PIN. Copy your sync code first (Backup & sync below) ' +
'to keep your favourites.',
);
return;
}
if (await authenticateOwner()) {
setErrorText(null);
await onConfirmed();
}
},
});
if (!settings) { if (!settings) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
@ -156,14 +126,9 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
); );
} }
// key={stage} przy kazdym PinEntry: bez niego React zostawial ten sam komponent miedzy
// etapami i wpisane cyfry przechodzily dalej. Potwierdzenie startowalo z wpisanym juz
// PIN-em, a przy zmianie PIN-u nowe cyfry doklejaly sie do starych i zapisywal sie
// sklejony PIN (np. 11112222), ktorego user nie znal (zgloszenie a8d85930).
if (stage === 'enter-current') { if (stage === 'enter-current') {
return ( return (
<PinEntry <PinEntry
key={stage}
title="Enter current PIN" title="Enter current PIN"
error={errorText} error={errorText}
onCancel={() => { onCancel={() => {
@ -179,7 +144,6 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
setErrorText(null); setErrorText(null);
setStage('set-new'); setStage('set-new');
}} }}
footerAction={forgotPin(() => setStage('set-new'))}
/> />
); );
} }
@ -187,7 +151,6 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
if (stage === 'set-new') { if (stage === 'set-new') {
return ( return (
<PinEntry <PinEntry
key={stage}
title="Enter new PIN (4-8 digits)" title="Enter new PIN (4-8 digits)"
error={errorText} error={errorText}
onCancel={() => { onCancel={() => {
@ -211,7 +174,6 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
if (stage === 'confirm-new') { if (stage === 'confirm-new') {
return ( return (
<PinEntry <PinEntry
key={stage}
title="Confirm PIN" title="Confirm PIN"
error={errorText} error={errorText}
onCancel={() => { onCancel={() => {
@ -239,7 +201,6 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
if (stage === 'disable-confirm') { if (stage === 'disable-confirm') {
return ( return (
<PinEntry <PinEntry
key={stage}
title="Enter PIN to disable lock" title="Enter PIN to disable lock"
error={errorText} error={errorText}
onCancel={() => { onCancel={() => {
@ -259,13 +220,6 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
setStage('menu'); setStage('menu');
await refresh(); await refresh();
}} }}
footerAction={forgotPin(async () => {
await setEnabled(false);
await setBioEnabled(false);
await clearPin();
setStage('menu');
await refresh();
})}
/> />
); );
} }

View file

@ -8,13 +8,11 @@ interface Props {
error?: string | null; error?: string | null;
onSubmit: (pin: string) => Promise<void> | void; onSubmit: (pin: string) => Promise<void> | void;
onCancel: () => void; onCancel: () => void;
/** Maly link pod przyciskami, np. "Forgot PIN?". */
footerAction?: { label: string; onPress: () => void };
} }
const KEYS: (string | null)[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', null, '0', '⌫']; const KEYS: (string | null)[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', null, '0', '⌫'];
export function PinEntry({ title, error, onSubmit, onCancel, footerAction }: Props) { export function PinEntry({ title, error, onSubmit, onCancel }: Props) {
const [pin, setPin] = useState(''); const [pin, setPin] = useState('');
function press(k: string) { function press(k: string) {
@ -69,11 +67,6 @@ export function PinEntry({ title, error, onSubmit, onCancel, footerAction }: Pro
<Text style={styles.primaryText}>Next</Text> <Text style={styles.primaryText}>Next</Text>
</Pressable> </Pressable>
</View> </View>
{footerAction ? (
<Pressable onPress={footerAction.onPress} hitSlop={10} style={styles.footerAction}>
<Text style={styles.footerActionText}>{footerAction.label}</Text>
</Pressable>
) : null}
</View> </View>
); );
} }
@ -82,8 +75,6 @@ const KEY_SIZE = 72;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg, alignItems: 'center', paddingTop: 60 }, root: { flex: 1, backgroundColor: theme.bg, alignItems: 'center', paddingTop: 60 },
footerAction: { marginTop: 22, padding: 6 },
footerActionText: { color: theme.muted, fontSize: 14, textDecorationLine: 'underline' },
title: { color: theme.fg, fontSize: 18, fontWeight: '700' }, title: { color: theme.fg, fontSize: 18, fontWeight: '700' },
error: { color: theme.bad, fontSize: 13, marginTop: 8 }, error: { color: theme.bad, fontSize: 13, marginTop: 8 },
dots: { flexDirection: 'row', gap: 14, marginTop: 14, minHeight: 16 }, dots: { flexDirection: 'row', gap: 14, marginTop: 14, minHeight: 16 },