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:
goon-foss 2026-10-02 11:30:50 +02:00
parent 27309d24f2
commit 92f83b93fe
3 changed files with 78 additions and 31 deletions

View file

@ -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:
"""Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści)."""
counts = _new_counts(session, device_id, kind=kind)
for gid, n in _new_movie_counts(session, device_id, kind=kind).items():
counts[gid] = counts.get(gid, 0) + n
return counts
"""gid -> (nowe sceny, nowe filmy). Apka od 2026-10 pokazuje je osobno (zgłoszenie
5a724cc8), starsze wersje dostają sumę w `new_count`."""
scenes = _new_counts(session, device_id, kind=kind)
movies = _new_movie_counts(session, device_id, kind=kind)
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
@ -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
for gid, at in _counts_seen.get(key, {}).items():
if at >= started:
counts[gid] = 0
counts[gid] = _NO_NEW
_counts_seen.pop(key, None)
_counts_cache[key] = (time.monotonic(), counts)
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()
hit = _counts_cache.get(key)
if hit is not None and hit[1].get(gid):
hit[1][gid] = 0
hit[1][gid] = _NO_NEW
class FavoriteOut(BaseModel):
@ -300,7 +308,9 @@ class FavoriteOut(BaseModel):
canonical_name: str
slug: str | None
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
created_at: datetime
@ -334,19 +344,22 @@ def list_favorites(
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
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] = []
new_total = 0
for fav, perf in rows:
nc = new_counts.get(perf.id, 0)
new_total += nc
ns, nm = new_counts.get(perf.id, _NO_NEW)
new_total += ns + nm
items.append(
FavoriteOut(
performer_id=perf.id,
canonical_name=perf.canonical_name,
slug=perf.slug,
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,
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.
movie_count: int = 0
new_count: int
new_scene_count: int = 0
new_movie_count: int = 0
last_seen_at: datetime
created_at: datetime
@ -499,12 +514,13 @@ def list_favorite_studios(
).all()
}
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] = []
new_total = 0
for fav, st in rows:
nc = new_counts.get(st.id, 0)
new_total += nc
ns, nm = new_counts.get(st.id, _NO_NEW)
new_total += ns + nm
items.append(
FavoriteStudioOut(
studio_id=st.id,
@ -513,7 +529,9 @@ def list_favorite_studios(
network=st.network,
scene_count=scene_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,
created_at=fav.created_at,
)

View file

@ -3,6 +3,7 @@
// (zeruje licznik). Klucz: przekazujemy `seenSince` (last_seen_at SPRZED markSeen)
// żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych
// scenach.
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import { formatCount } from '../lib/formatCount';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
@ -85,7 +86,7 @@ export function FavoritesScreen() {
? {
...prev,
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(
0,
@ -103,7 +104,7 @@ export function FavoritesScreen() {
? {
...prev,
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(
0,
@ -337,6 +338,35 @@ function initials(name: string): string {
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({
fav,
onPress,
@ -364,13 +394,7 @@ function PerformerRow({
{formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
</Text>
</View>
{fav.new_count > 0 ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
</View>
) : (
<Text style={styles.chevron}>›</Text>
)}
<NewBadges fav={fav} />
</Pressable>
);
}
@ -456,13 +480,7 @@ function StudioRow({
{fav.network ? ` · ${fav.network}` : ''}
</Text>
</View>
{fav.new_count > 0 ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
</View>
) : (
<Text style={styles.chevron}>›</Text>
)}
<NewBadges fav={fav} />
</Pressable>
);
}
@ -521,7 +539,11 @@ const styles = StyleSheet.create({
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
rowMuted: { color: theme.muted, fontSize: 13 },
newBadges: { alignItems: 'flex-end', gap: 4 },
newBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
backgroundColor: theme.accent,
paddingHorizontal: 10,
paddingVertical: 4,

View file

@ -344,6 +344,9 @@ export interface FavoriteOut {
slug: string | null;
scene_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;
created_at: string;
}
@ -369,6 +372,8 @@ export interface FavoriteStudioOut {
* setki filmów), a lista pokazywała wtedy samo „0 scenes". */
movie_count?: number;
new_count: number;
new_scene_count?: number;
new_movie_count?: number;
last_seen_at: string;
created_at: string;
}
@ -486,7 +491,9 @@ export type ClipEventKind =
| 'unlike'
| 'not_interested'
| 'autoswipe'
| 'open';
| 'open'
/** Odtwarzacz nie dal rady (np. 403 po skasowaniu klipu) albo stoi bez obrazu. */
| 'error';
export interface ClipEventIn {
clip_id: string;