Compare commits
3 commits
ce174b671e
...
0def1a3619
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0def1a3619 | ||
|
|
f6081669a0 | ||
|
|
cd34b296e2 |
8 changed files with 174 additions and 14 deletions
|
|
@ -15,14 +15,14 @@ from typing import Annotated
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import and_, exists, func, select
|
||||
from sqlalchemy import and_, case, exists, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_api_key
|
||||
from app.db import get_session
|
||||
from app.models.movie import Movie, MovieTag
|
||||
from app.models.movie_playback_source import MoviePlaybackSource
|
||||
from app.models.performer import Performer
|
||||
from app.models.performer import Performer, PerformerAlias
|
||||
from app.models.playback_source import PlaybackSource
|
||||
from app.models.scene import ScenePerformer, SceneTag
|
||||
from app.models.studio import Studio
|
||||
|
|
@ -30,6 +30,37 @@ from app.models.tag import Tag
|
|||
from app.models.tag_vertical_count import TagVerticalCount
|
||||
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__)
|
||||
|
||||
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
|
||||
|
|
@ -294,14 +325,15 @@ def list_performers(
|
|||
.join(v_count, v_count.c.performer_id == Performer.id)
|
||||
)
|
||||
if q:
|
||||
base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%"))
|
||||
base = base.where(_performer_name_clause(q))
|
||||
total = session.execute(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
).scalar_one()
|
||||
rank = [_performer_name_rank(q)] if q else []
|
||||
if order in ("scene_count", "popular"):
|
||||
ordered = base.order_by(v_count.c.c.desc(), Performer.canonical_name.asc())
|
||||
ordered = base.order_by(*rank, v_count.c.c.desc(), Performer.canonical_name.asc())
|
||||
else:
|
||||
ordered = base.order_by(Performer.canonical_name.asc())
|
||||
ordered = base.order_by(*rank, Performer.canonical_name.asc())
|
||||
rows = session.execute(
|
||||
ordered.offset((page - 1) * per_page).limit(per_page)
|
||||
).all()
|
||||
|
|
@ -323,18 +355,19 @@ def list_performers(
|
|||
# 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019.
|
||||
base = select(Performer)
|
||||
if q:
|
||||
base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%"))
|
||||
base = base.where(_performer_name_clause(q))
|
||||
|
||||
total = session.execute(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
).scalar_one()
|
||||
|
||||
rank = [_performer_name_rank(q)] if q else []
|
||||
if order in ("scene_count", "popular"):
|
||||
ordered = base.order_by(
|
||||
Performer.scene_count.desc(), Performer.canonical_name.asc()
|
||||
*rank, Performer.scene_count.desc(), Performer.canonical_name.asc()
|
||||
)
|
||||
else:
|
||||
ordered = base.order_by(Performer.canonical_name.asc())
|
||||
ordered = base.order_by(*rank, Performer.canonical_name.asc())
|
||||
|
||||
perfs_page = session.execute(
|
||||
ordered.offset((page - 1) * per_page).limit(per_page)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""RawMovie → NormalizedMovie. Reuse normalize_studio/performer/tag z scenes.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
|
|
@ -16,6 +17,24 @@ from app.normalize.scenes import (
|
|||
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
|
||||
class NormalizedMovie:
|
||||
external_id: str
|
||||
|
|
@ -55,7 +74,7 @@ def normalize_movie(raw: RawMovie) -> NormalizedMovie:
|
|||
director=raw.director,
|
||||
country=raw.country,
|
||||
rating=raw.rating,
|
||||
poster_url=raw.poster_url,
|
||||
poster_url=stable_poster_url(raw.poster_url),
|
||||
backdrop_url=raw.backdrop_url,
|
||||
url=raw.url,
|
||||
studio=normalize_studio(raw.studio) if raw.studio else None,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,17 @@ export type 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',
|
||||
date: 'October 2026',
|
||||
|
|
|
|||
|
|
@ -13,17 +13,32 @@
|
|||
* w sobie niczego nie subskrybuje i jest bezpieczne wszędzie.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import { CastButton, CastContext, useRemoteMediaClient } from 'react-native-google-cast';
|
||||
|
||||
import type { CastTarget } from '../lib/cast';
|
||||
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. */
|
||||
export function useCastAvailable(): boolean {
|
||||
const [available, setAvailable] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
if (castCrashesOnThisDevice()) return;
|
||||
(async () => {
|
||||
try {
|
||||
const state = await CastContext.getPlayServicesState();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
* APK, tam zakladka Edge jest ukryta (`swipeAvailable`).
|
||||
*/
|
||||
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import React from 'react';
|
||||
import { AppState, StyleSheet, View } from 'react-native';
|
||||
|
||||
|
|
@ -222,7 +221,8 @@ export function ClipPager({
|
|||
// ignore
|
||||
}
|
||||
}
|
||||
Haptics.selectionAsync().catch(() => {});
|
||||
// Bez wibracji przy przewinieciu (zgloszenie 12db400a: "bardzo meczace" przy
|
||||
// przewijaniu jednego klipu za drugim).
|
||||
return () => {
|
||||
sub.remove();
|
||||
subStatus.remove();
|
||||
|
|
|
|||
|
|
@ -64,6 +64,33 @@ export async function biometricAvailable(): Promise<boolean> {
|
|||
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> {
|
||||
const res = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'Unlock Goon',
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ import { useClient } from '../ClientContext';
|
|||
import { getDeviceId } from '../storage';
|
||||
import {
|
||||
AppLockSettings,
|
||||
authenticateOwner,
|
||||
biometricAvailable,
|
||||
clearPin,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
getSettings,
|
||||
ownerAuthAvailable,
|
||||
setBioEnabled,
|
||||
setEnabled,
|
||||
setPin,
|
||||
|
|
@ -59,6 +61,7 @@ type Stage = 'menu' | 'enter-current' | 'set-new' | 'confirm-new' | 'disable-con
|
|||
export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
|
||||
const [settings, setSettings] = useState<AppLockSettings | null>(null);
|
||||
const [bioAvailable, setBioAvailable] = useState(false);
|
||||
const [ownerAuth, setOwnerAuth] = useState(false);
|
||||
const [stage, setStage] = useState<Stage>('menu');
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
|
|
@ -74,9 +77,15 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
|
|||
const [importing, setImporting] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
const [s, bio, dev] = await Promise.all([getSettings(), biometricAvailable(), getDeviceId()]);
|
||||
const [s, bio, dev, owner] = await Promise.all([
|
||||
getSettings(),
|
||||
biometricAvailable(),
|
||||
getDeviceId(),
|
||||
ownerAuthAvailable(),
|
||||
]);
|
||||
setSettings(s);
|
||||
setBioAvailable(bio);
|
||||
setOwnerAuth(owner);
|
||||
setDeviceId(dev);
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +127,27 @@ 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) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
|
|
@ -126,9 +156,14 @@ 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') {
|
||||
return (
|
||||
<PinEntry
|
||||
key={stage}
|
||||
title="Enter current PIN"
|
||||
error={errorText}
|
||||
onCancel={() => {
|
||||
|
|
@ -144,6 +179,7 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
|
|||
setErrorText(null);
|
||||
setStage('set-new');
|
||||
}}
|
||||
footerAction={forgotPin(() => setStage('set-new'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -151,6 +187,7 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
|
|||
if (stage === 'set-new') {
|
||||
return (
|
||||
<PinEntry
|
||||
key={stage}
|
||||
title="Enter new PIN (4-8 digits)"
|
||||
error={errorText}
|
||||
onCancel={() => {
|
||||
|
|
@ -174,6 +211,7 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
|
|||
if (stage === 'confirm-new') {
|
||||
return (
|
||||
<PinEntry
|
||||
key={stage}
|
||||
title="Confirm PIN"
|
||||
error={errorText}
|
||||
onCancel={() => {
|
||||
|
|
@ -201,6 +239,7 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
|
|||
if (stage === 'disable-confirm') {
|
||||
return (
|
||||
<PinEntry
|
||||
key={stage}
|
||||
title="Enter PIN to disable lock"
|
||||
error={errorText}
|
||||
onCancel={() => {
|
||||
|
|
@ -220,6 +259,13 @@ export function AppLockSettingsScreen({ onLogout }: { onLogout?: () => void }) {
|
|||
setStage('menu');
|
||||
await refresh();
|
||||
}}
|
||||
footerAction={forgotPin(async () => {
|
||||
await setEnabled(false);
|
||||
await setBioEnabled(false);
|
||||
await clearPin();
|
||||
setStage('menu');
|
||||
await refresh();
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ interface Props {
|
|||
error?: string | null;
|
||||
onSubmit: (pin: string) => Promise<void> | 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', '⌫'];
|
||||
|
||||
export function PinEntry({ title, error, onSubmit, onCancel }: Props) {
|
||||
export function PinEntry({ title, error, onSubmit, onCancel, footerAction }: Props) {
|
||||
const [pin, setPin] = useState('');
|
||||
|
||||
function press(k: string) {
|
||||
|
|
@ -67,6 +69,11 @@ export function PinEntry({ title, error, onSubmit, onCancel }: Props) {
|
|||
<Text style={styles.primaryText}>Next</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{footerAction ? (
|
||||
<Pressable onPress={footerAction.onPress} hitSlop={10} style={styles.footerAction}>
|
||||
<Text style={styles.footerActionText}>{footerAction.label}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -75,6 +82,8 @@ const KEY_SIZE = 72;
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
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' },
|
||||
error: { color: theme.bad, fontSize: 13, marginTop: 8 },
|
||||
dots: { flexDirection: 'row', gap: 14, marginTop: 14, minHeight: 16 },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue