feat(favorites): ulubione z nowosciami na gorze, osobno nowe sceny i filmy
Zgloszenie 5a724cc8: aktorki i studia z "+N" grupowane u gory listy, a znaczek rozbity na sceny i filmy (new_scene_count, new_movie_count). new_count zostaje jako suma dla starszych wersji apki, ktore dostaja tez nowa kolejnosc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
27309d24f2
commit
92f83b93fe
3 changed files with 78 additions and 31 deletions
|
|
@ -200,11 +200,19 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
"""Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści)."""
|
"""gid -> (nowe sceny, nowe filmy). Apka od 2026-10 pokazuje je osobno (zgłoszenie
|
||||||
counts = _new_counts(session, device_id, kind=kind)
|
5a724cc8), starsze wersje dostają sumę w `new_count`."""
|
||||||
for gid, n in _new_movie_counts(session, device_id, kind=kind).items():
|
scenes = _new_counts(session, device_id, kind=kind)
|
||||||
counts[gid] = counts.get(gid, 0) + n
|
movies = _new_movie_counts(session, device_id, kind=kind)
|
||||||
return counts
|
return {gid: (scenes.get(gid, 0), movies.get(gid, 0)) for gid in scenes.keys() | movies.keys()}
|
||||||
|
|
||||||
|
|
||||||
|
_NO_NEW = (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _favorites_order(new: dict, gid, name: str) -> tuple:
|
||||||
|
"""Najpierw ulubione z nowościami, w obu grupach alfabetycznie (zgłoszenie 5a724cc8)."""
|
||||||
|
return (sum(new.get(gid, _NO_NEW)) == 0, (name or "").lower())
|
||||||
|
|
||||||
|
|
||||||
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
|
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
|
||||||
|
|
@ -234,7 +242,7 @@ def _store_counts(key: tuple[str, str], started: float, counts: dict) -> None:
|
||||||
# mark_seen w trakcie liczenia: wynik policzono ze starym last_seen_at
|
# mark_seen w trakcie liczenia: wynik policzono ze starym last_seen_at
|
||||||
for gid, at in _counts_seen.get(key, {}).items():
|
for gid, at in _counts_seen.get(key, {}).items():
|
||||||
if at >= started:
|
if at >= started:
|
||||||
counts[gid] = 0
|
counts[gid] = _NO_NEW
|
||||||
_counts_seen.pop(key, None)
|
_counts_seen.pop(key, None)
|
||||||
_counts_cache[key] = (time.monotonic(), counts)
|
_counts_cache[key] = (time.monotonic(), counts)
|
||||||
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
|
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
|
||||||
|
|
@ -292,7 +300,7 @@ def _mark_counts_seen(device_id: str, kind: str, gid: uuid.UUID) -> None:
|
||||||
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
|
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
|
||||||
hit = _counts_cache.get(key)
|
hit = _counts_cache.get(key)
|
||||||
if hit is not None and hit[1].get(gid):
|
if hit is not None and hit[1].get(gid):
|
||||||
hit[1][gid] = 0
|
hit[1][gid] = _NO_NEW
|
||||||
|
|
||||||
|
|
||||||
class FavoriteOut(BaseModel):
|
class FavoriteOut(BaseModel):
|
||||||
|
|
@ -300,7 +308,9 @@ class FavoriteOut(BaseModel):
|
||||||
canonical_name: str
|
canonical_name: str
|
||||||
slug: str | None
|
slug: str | None
|
||||||
scene_count: int
|
scene_count: int
|
||||||
new_count: int # sceny od last_seen_at
|
new_count: int # sceny + filmy od last_seen_at (dla starszych wersji apki)
|
||||||
|
new_scene_count: int = 0
|
||||||
|
new_movie_count: int = 0
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
@ -334,19 +344,22 @@ def list_favorites(
|
||||||
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||||
new_counts = _cached_new_counts(device_id, kind="performer") if counts else {}
|
new_counts = _cached_new_counts(device_id, kind="performer") if counts else {}
|
||||||
|
rows = sorted(rows, key=lambda r: _favorites_order(new_counts, r[1].id, r[1].canonical_name))
|
||||||
|
|
||||||
items: list[FavoriteOut] = []
|
items: list[FavoriteOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
for fav, perf in rows:
|
for fav, perf in rows:
|
||||||
nc = new_counts.get(perf.id, 0)
|
ns, nm = new_counts.get(perf.id, _NO_NEW)
|
||||||
new_total += nc
|
new_total += ns + nm
|
||||||
items.append(
|
items.append(
|
||||||
FavoriteOut(
|
FavoriteOut(
|
||||||
performer_id=perf.id,
|
performer_id=perf.id,
|
||||||
canonical_name=perf.canonical_name,
|
canonical_name=perf.canonical_name,
|
||||||
slug=perf.slug,
|
slug=perf.slug,
|
||||||
scene_count=scene_counts.get(perf.id, 0),
|
scene_count=scene_counts.get(perf.id, 0),
|
||||||
new_count=nc,
|
new_count=ns + nm,
|
||||||
|
new_scene_count=ns,
|
||||||
|
new_movie_count=nm,
|
||||||
last_seen_at=fav.last_seen_at,
|
last_seen_at=fav.last_seen_at,
|
||||||
created_at=fav.created_at,
|
created_at=fav.created_at,
|
||||||
)
|
)
|
||||||
|
|
@ -427,6 +440,8 @@ class FavoriteStudioOut(BaseModel):
|
||||||
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
|
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
|
||||||
movie_count: int = 0
|
movie_count: int = 0
|
||||||
new_count: int
|
new_count: int
|
||||||
|
new_scene_count: int = 0
|
||||||
|
new_movie_count: int = 0
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
@ -499,12 +514,13 @@ def list_favorite_studios(
|
||||||
).all()
|
).all()
|
||||||
}
|
}
|
||||||
new_counts = _cached_new_counts(device_id, kind="studio")
|
new_counts = _cached_new_counts(device_id, kind="studio")
|
||||||
|
rows = sorted(rows, key=lambda r: _favorites_order(new_counts, r[1].id, r[1].name))
|
||||||
|
|
||||||
items: list[FavoriteStudioOut] = []
|
items: list[FavoriteStudioOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
for fav, st in rows:
|
for fav, st in rows:
|
||||||
nc = new_counts.get(st.id, 0)
|
ns, nm = new_counts.get(st.id, _NO_NEW)
|
||||||
new_total += nc
|
new_total += ns + nm
|
||||||
items.append(
|
items.append(
|
||||||
FavoriteStudioOut(
|
FavoriteStudioOut(
|
||||||
studio_id=st.id,
|
studio_id=st.id,
|
||||||
|
|
@ -513,7 +529,9 @@ def list_favorite_studios(
|
||||||
network=st.network,
|
network=st.network,
|
||||||
scene_count=scene_counts.get(st.id, 0),
|
scene_count=scene_counts.get(st.id, 0),
|
||||||
movie_count=movie_counts.get(st.id, 0),
|
movie_count=movie_counts.get(st.id, 0),
|
||||||
new_count=nc,
|
new_count=ns + nm,
|
||||||
|
new_scene_count=ns,
|
||||||
|
new_movie_count=nm,
|
||||||
last_seen_at=fav.last_seen_at,
|
last_seen_at=fav.last_seen_at,
|
||||||
created_at=fav.created_at,
|
created_at=fav.created_at,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// (zeruje licznik). Klucz: przekazujemy `seenSince` (last_seen_at SPRZED markSeen)
|
// (zeruje licznik). Klucz: przekazujemy `seenSince` (last_seen_at SPRZED markSeen)
|
||||||
// żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych
|
// żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych
|
||||||
// scenach.
|
// scenach.
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { formatCount } from '../lib/formatCount';
|
import { formatCount } from '../lib/formatCount';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
|
@ -85,7 +86,7 @@ export function FavoritesScreen() {
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
items: prev.items.map((f) =>
|
items: prev.items.map((f) =>
|
||||||
f.performer_id === id ? { ...f, new_count: 0 } : f,
|
f.performer_id === id ? { ...f, new_count: 0, new_scene_count: 0, new_movie_count: 0 } : f,
|
||||||
),
|
),
|
||||||
new_total: Math.max(
|
new_total: Math.max(
|
||||||
0,
|
0,
|
||||||
|
|
@ -103,7 +104,7 @@ export function FavoritesScreen() {
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
items: prev.items.map((f) =>
|
items: prev.items.map((f) =>
|
||||||
f.studio_id === id ? { ...f, new_count: 0 } : f,
|
f.studio_id === id ? { ...f, new_count: 0, new_scene_count: 0, new_movie_count: 0 } : f,
|
||||||
),
|
),
|
||||||
new_total: Math.max(
|
new_total: Math.max(
|
||||||
0,
|
0,
|
||||||
|
|
@ -337,6 +338,35 @@ function initials(name: string): string {
|
||||||
return (first + last).toUpperCase() || '?';
|
return (first + last).toUpperCase() || '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// „+N" osobno dla scen i filmow (zgloszenie 5a724cc8). Starszy backend nie podaje
|
||||||
|
// rozbicia, wtedy jeden znaczek jak dawniej. Kolejnosc listy (nowosci na gorze) robi backend.
|
||||||
|
function NewBadges({ fav }: { fav: FavoriteOut | FavoriteStudioOut }) {
|
||||||
|
if (fav.new_count <= 0) return <Text style={styles.chevron}>›</Text>;
|
||||||
|
if (fav.new_scene_count == null || fav.new_movie_count == null) {
|
||||||
|
return (
|
||||||
|
<View style={styles.newBadge}>
|
||||||
|
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={styles.newBadges}>
|
||||||
|
{fav.new_scene_count > 0 ? (
|
||||||
|
<View style={styles.newBadge} accessibilityLabel={`${fav.new_scene_count} new scenes`}>
|
||||||
|
<MaterialCommunityIcons name="play-circle-outline" size={13} color={theme.fg} />
|
||||||
|
<Text style={styles.newBadgeText}>+{fav.new_scene_count}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{fav.new_movie_count > 0 ? (
|
||||||
|
<View style={styles.newBadge} accessibilityLabel={`${fav.new_movie_count} new movies`}>
|
||||||
|
<MaterialCommunityIcons name="movie-open-outline" size={13} color={theme.fg} />
|
||||||
|
<Text style={styles.newBadgeText}>+{fav.new_movie_count}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PerformerRow({
|
function PerformerRow({
|
||||||
fav,
|
fav,
|
||||||
onPress,
|
onPress,
|
||||||
|
|
@ -364,13 +394,7 @@ function PerformerRow({
|
||||||
{formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
|
{formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{fav.new_count > 0 ? (
|
<NewBadges fav={fav} />
|
||||||
<View style={styles.newBadge}>
|
|
||||||
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.chevron}>›</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -456,13 +480,7 @@ function StudioRow({
|
||||||
{fav.network ? ` · ${fav.network}` : ''}
|
{fav.network ? ` · ${fav.network}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{fav.new_count > 0 ? (
|
<NewBadges fav={fav} />
|
||||||
<View style={styles.newBadge}>
|
|
||||||
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.chevron}>›</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -521,7 +539,11 @@ const styles = StyleSheet.create({
|
||||||
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
|
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
|
||||||
rowMuted: { color: theme.muted, fontSize: 13 },
|
rowMuted: { color: theme.muted, fontSize: 13 },
|
||||||
|
|
||||||
|
newBadges: { alignItems: 'flex-end', gap: 4 },
|
||||||
newBadge: {
|
newBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
backgroundColor: theme.accent,
|
backgroundColor: theme.accent,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,9 @@ export interface FavoriteOut {
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
scene_count: number;
|
scene_count: number;
|
||||||
new_count: number;
|
new_count: number;
|
||||||
|
/** Rozbicie new_count na sceny i filmy (backend od 2026-10). Brak = starszy backend. */
|
||||||
|
new_scene_count?: number;
|
||||||
|
new_movie_count?: number;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +372,8 @@ export interface FavoriteStudioOut {
|
||||||
* setki filmów), a lista pokazywała wtedy samo „0 scenes". */
|
* setki filmów), a lista pokazywała wtedy samo „0 scenes". */
|
||||||
movie_count?: number;
|
movie_count?: number;
|
||||||
new_count: number;
|
new_count: number;
|
||||||
|
new_scene_count?: number;
|
||||||
|
new_movie_count?: number;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -486,7 +491,9 @@ export type ClipEventKind =
|
||||||
| 'unlike'
|
| 'unlike'
|
||||||
| 'not_interested'
|
| 'not_interested'
|
||||||
| 'autoswipe'
|
| 'autoswipe'
|
||||||
| 'open';
|
| 'open'
|
||||||
|
/** Odtwarzacz nie dal rady (np. 403 po skasowaniu klipu) albo stoi bez obrazu. */
|
||||||
|
| 'error';
|
||||||
|
|
||||||
export interface ClipEventIn {
|
export interface ClipEventIn {
|
||||||
clip_id: string;
|
clip_id: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue