From cb0b843f488cb354ed5b7f9dea32a8c440653367 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Thu, 2 Jul 2026 09:41:37 +0200 Subject: [PATCH] refactor(review): dedup favorites/list visibility + SQL-aggregate count, share mobile isNew Follow-up cleanup from the ultra-review (behaviour-preserving, verified equivalent): - scenes.py: extract live_playback_exists / blacklist_clauses / stub_exclusion_clause; list_scenes and favorites now share ONE definition of "visible scene" (was a hand-kept copy in favorites -> the count-vs-list drift class). Verified identical: helper vs old inline both count 1,853,327 scenes. - favorites.py: replace the two copy-pasted count blocks with one _new_counts(kind=...) that counts in SQL (count(*) FILTER over the windowed subquery) instead of streaming up to N*200 rows to Python; joins the favorite table for per-row last_seen. Deployed _new_counts verified == hand SQL (studios 364). Dropped now-unused imports. - mobile: extract lib/newScenes.ts (isNewScene / sortNewFirst); SceneTile + Performer/ StudioScenes use it (was triplicated, already drifted once in the deleted FavoriteSceneRow). - SceneDetail: fold tube:pornxpph into the phoneResolver map (was a verbatim-duplicated block). - deep_crawl: persist the cursor after every completed page (was once at run end), so a mid-page hard-kill past the soft budget can't lose progress (GOON-V hardening). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/favorites.py | 208 ++++++------------- app/api/scenes.py | 154 +++++++------- app/scheduler/deep_crawl.py | 31 ++- mobile/src/components/SceneTile.tsx | 5 +- mobile/src/lib/newScenes.ts | 26 +++ mobile/src/screens/PerformerScenesScreen.tsx | 20 +- mobile/src/screens/SceneDetailScreen.tsx | 35 +--- mobile/src/screens/StudioScenesScreen.tsx | 20 +- 8 files changed, 211 insertions(+), 288 deletions(-) create mode 100644 mobile/src/lib/newScenes.ts diff --git a/app/api/favorites.py b/app/api/favorites.py index 953a143..b55098d 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -38,8 +38,7 @@ from app.models.favorite_performer import FavoritePerformer from app.models.favorite_studio import FavoriteStudio from app.models.movie import Movie from app.models.performer import Performer -from app.models.playback_source import PlaybackSource -from app.models.scene import Scene, ScenePerformer, SceneTag +from app.models.scene import Scene, ScenePerformer from app.models.studio import Studio router = APIRouter( @@ -57,79 +56,76 @@ router = APIRouter( _FAVORITES_PAGE_CAP = 200 # == per_page w PerformerScenesScreen/StudioScenesScreen -def _visible_scene_clauses(session: Session, device_id: str, *, apply_stub: bool) -> list: - """Klauzule WHERE = filtry widoczności listy scen: żywy playback_source + - blacklisty device + (opcjonalnie) odsianie stub-scen. Aplikowane na zapytanie - z Scene w FROM.""" - from sqlalchemy import exists +def _new_counts(session: Session, device_id: str, *, kind: str) -> dict: + """Policz per-favorite "+N nowych" = sceny created_at > last_seen_at, ale TYLKO wśród + tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy + playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem + release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca + {group_id: n} — bez streamowania N*200 wierszy do Pythona. - clauses = [ - exists( - select(1).where( - PlaybackSource.scene_id == Scene.id, - PlaybackSource.dead_at.is_(None), + kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers. + kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują + odsiewu stub (mogą nie mieć performera); performerki nie (mają). + """ + from sqlalchemy import and_, func + + from app.api.scenes import blacklist_clauses, live_playback_exists, stub_exclusion_clause + + clauses = [live_playback_exists(), *blacklist_clauses(session, device_id)] + + if kind == "performer": + gid = ScenePerformer.performer_id + last_seen = FavoritePerformer.last_seen_at + base = ( + select( + gid.label("gid"), + Scene.created_at.label("created_at"), + Scene.backfill.label("backfill"), + last_seen.label("last_seen"), ) + .select_from(FavoritePerformer) + .join(ScenePerformer, ScenePerformer.performer_id == FavoritePerformer.performer_id) + .join(Scene, Scene.id == ScenePerformer.scene_id) + .where(FavoritePerformer.device_id == device_id) ) - ] - from app.api.scenes import _blacklists_empty - - if not _blacklists_empty(session, device_id): - from app.models.blacklist import ( - BlacklistedPerformer, - BlacklistedStudio, - BlacklistedTag, - ) - - clauses.append( - ~exists( - select(1) - .select_from(ScenePerformer) - .join( - BlacklistedPerformer, - (BlacklistedPerformer.performer_id == ScenePerformer.performer_id) - & (BlacklistedPerformer.device_id == device_id), - ) - .where(ScenePerformer.scene_id == Scene.id) + partition = ScenePerformer.performer_id + else: + clauses.append(stub_exclusion_clause()) + gid = Scene.studio_id + last_seen = FavoriteStudio.last_seen_at + base = ( + select( + gid.label("gid"), + Scene.created_at.label("created_at"), + Scene.backfill.label("backfill"), + last_seen.label("last_seen"), ) + .select_from(FavoriteStudio) + .join(Scene, Scene.studio_id == FavoriteStudio.studio_id) + .where(FavoriteStudio.device_id == device_id) ) - clauses.append( - ~Scene.studio_id.in_( - select(BlacklistedStudio.studio_id).where( - BlacklistedStudio.device_id == device_id + partition = Scene.studio_id + + rn = func.row_number().over( + partition_by=partition, + order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), + ).label("rn") + inner = base.add_columns(rn).where(*clauses).subquery() + rows = session.execute( + select( + inner.c.gid, + func.count() + .filter( + and_( + inner.c.rn <= _FAVORITES_PAGE_CAP, + inner.c.backfill.is_(False), + inner.c.created_at > inner.c.last_seen, ) ) - ) - clauses.append( - ~exists( - select(1) - .select_from(SceneTag) - .join( - BlacklistedTag, - (BlacklistedTag.tag_id == SceneTag.tag_id) - & (BlacklistedTag.device_id == device_id), - ) - .where(SceneTag.scene_id == Scene.id) - ) - ) - if apply_stub: - # Stub = tube-only scena bez release_date AND bez canonical (TPDB/StashDB) AND - # bez performera. Dla widoku performerki nigdy nie zachodzi (ma performera), więc - # apply_stub=False tam; dla studiów tak. Lustro scenes.py:348-367. - from app.models.scene import SceneExternalRef - from app.models.source import Source, SourceKind - - canonical_exists = exists( - select(1) - .select_from(SceneExternalRef) - .join(Source, Source.id == SceneExternalRef.source_id) - .where(SceneExternalRef.scene_id == Scene.id) - .where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb])) - ) - has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id)) - clauses.append( - Scene.release_date.is_not(None) | canonical_exists | has_performer - ) - return clauses + .label("n"), + ).group_by(inner.c.gid) + ).all() + return {gid_val: int(n) for gid_val, n in rows} class FavoriteOut(BaseModel): @@ -162,48 +158,11 @@ def list_favorites( if not rows: return FavoriteListOut(items=[], total=0, new_total=0) - perf_ids = [perf.id for _, perf in rows] - last_seen_by_perf = {fav.performer_id: fav.last_seen_at for fav, _ in rows} - # scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez # _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym # 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_count per performer = sceny created_at > last_seen_at, ale liczone TYLKO wśród - # tych, które user faktycznie zobaczy na liście: te same filtry (żywy playback + - # blacklist) i to samo okno (top-_FAVORITES_PAGE_CAP pod release_date desc) co - # PerformerScenes. apply_stub=False, widok performerki i tak ma performera (nie-stub). - new_counts: dict = {} - if perf_ids: - from sqlalchemy import func - - clauses = _visible_scene_clauses(session, device_id, apply_stub=False) - rn = func.row_number().over( - partition_by=ScenePerformer.performer_id, - order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), - ).label("rn") - inner = ( - select( - ScenePerformer.performer_id.label("gid"), - Scene.created_at.label("created_at"), - Scene.backfill.label("backfill"), - rn, - ) - .join(Scene, Scene.id == ScenePerformer.scene_id) - .where(ScenePerformer.performer_id.in_(perf_ids)) - .where(*clauses) - .subquery() - ) - for gid, created_at, backfill in session.execute( - select(inner.c.gid, inner.c.created_at, inner.c.backfill).where( - inner.c.rn <= _FAVORITES_PAGE_CAP - ) - ): - ls = last_seen_by_perf.get(gid) - # backfill (masowy import katalogu) NIE liczy się jako nowość, patrz Scene.backfill. - if not backfill and created_at is not None and ls is not None and created_at > ls: - new_counts[gid] = new_counts.get(gid, 0) + 1 + new_counts = _new_counts(session, device_id, kind="performer") items: list[FavoriteOut] = [] new_total = 0 @@ -316,44 +275,9 @@ def list_favorite_studios( if not rows: return FavoriteStudioListOut(items=[], total=0, new_total=0) - studio_ids = [st.id for _, st in rows] - last_seen_by_studio = {fav.studio_id: fav.last_seen_at for fav, _ in rows} - # scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019). scene_counts: dict = {st.id: st.scene_count for _, st in rows} - - # new_count per studio, jak dla performerów (patrz list_favorites): te same filtry - # + okno co lista StudioScenes. apply_stub=True: studio-scena bez performera/release/ - # canonical to stub który lista odsiewa, więc licznik też musi. - new_counts: dict = {} - if studio_ids: - from sqlalchemy import func - - clauses = _visible_scene_clauses(session, device_id, apply_stub=True) - rn = func.row_number().over( - partition_by=Scene.studio_id, - order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), - ).label("rn") - inner = ( - select( - Scene.studio_id.label("gid"), - Scene.created_at.label("created_at"), - Scene.backfill.label("backfill"), - rn, - ) - .where(Scene.studio_id.in_(studio_ids)) - .where(*clauses) - .subquery() - ) - for gid, created_at, backfill in session.execute( - select(inner.c.gid, inner.c.created_at, inner.c.backfill).where( - inner.c.rn <= _FAVORITES_PAGE_CAP - ) - ): - ls = last_seen_by_studio.get(gid) - # backfill (masowy import katalogu) NIE liczy się jako nowość, patrz Scene.backfill. - if not backfill and created_at is not None and ls is not None and created_at > ls: - new_counts[gid] = new_counts.get(gid, 0) + 1 + new_counts = _new_counts(session, device_id, kind="studio") items: list[FavoriteStudioOut] = [] new_total = 0 diff --git a/app/api/scenes.py b/app/api/scenes.py index 844c956..c6215f6 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -108,6 +108,73 @@ def _split_csv(raw: str | None) -> list[str]: return [s.strip() for s in raw.split(",") if s.strip()] +# ---- Współdzielone klauzule widoczności sceny ---------------------------------- +# Definicja "co user widzi na liście" żyje TU i jest reużywana przez list_scenes ORAZ +# licznik "+N nowych" w app/api/favorites.py. Wcześniej favorites miał ręczną kopię tych +# klauzul (dryf: zmiana filtra listy nie trafiała do licznika → "+6 a nic nowego"). + + +def live_playback_exists(): + """EXISTS: scena ma choć jeden żywy playback_source.""" + return exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + + +def blacklist_clauses(session: Session, device_id: str) -> list: + """NOT-EXISTS klauzule blacklist device (performer/studio/tag). [] gdy wszystkie puste.""" + if _blacklists_empty(session, device_id): + return [] + from app.models.blacklist import ( + BlacklistedPerformer, + BlacklistedStudio, + BlacklistedTag, + ) + + return [ + ~exists( + select(1) + .select_from(ScenePerformer) + .join( + BlacklistedPerformer, + (BlacklistedPerformer.performer_id == ScenePerformer.performer_id) + & (BlacklistedPerformer.device_id == device_id), + ) + .where(ScenePerformer.scene_id == Scene.id) + ), + ~Scene.studio_id.in_( + select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id) + ), + ~exists( + select(1) + .select_from(SceneTag) + .join( + BlacklistedTag, + (BlacklistedTag.tag_id == SceneTag.tag_id) + & (BlacklistedTag.device_id == device_id), + ) + .where(SceneTag.scene_id == Scene.id) + ), + ] + + +def stub_exclusion_clause(): + """Odsiew stub-scen: tube-only bez release_date AND bez canonical (TPDB/StashDB) AND + bez performera. NOT stub gdy ma release_date OR canonical OR performera.""" + canonical_exists = exists( + select(1) + .select_from(SceneExternalRef) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(SceneExternalRef.scene_id == Scene.id) + .where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb])) + ) + has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id)) + return Scene.release_date.is_not(None) | canonical_exists | has_performer + + @router.get("", response_model=SceneListOut) def list_scenes( session: Annotated[Session, Depends(get_session)], @@ -239,24 +306,9 @@ def list_scenes( ) if has_playback is True: - # Tylko sceny z choć jednym ŻYWYM playback_source. - base = base.where( - exists( - select(1).where( - PlaybackSource.scene_id == Scene.id, - PlaybackSource.dead_at.is_(None), - ) - ) - ) + base = base.where(live_playback_exists()) elif has_playback is False: - base = base.where( - ~exists( - select(1).where( - PlaybackSource.scene_id == Scene.id, - PlaybackSource.dead_at.is_(None), - ) - ) - ) + base = base.where(~live_playback_exists()) if origin: # Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'. @@ -270,46 +322,10 @@ def list_scenes( ) ) - # Blacklisty — globalne wykluczenia. Jeśli scena ma JAKIEGOKOLWIEK blacklisted - # performera, jest na blacklisted studio, lub ma JAKIKOLWIEK blacklisted tag → out. - # Pomijamy gdy wszystkie 3 blacklisty puste (typowy stan single-user) — te NOT EXISTS - # ewaluują się per-row na ~176k scen przy mega-tagu i kosztowały ~3.4s za nic. - if not _blacklists_empty(session, device_id): - from app.models.blacklist import ( - BlacklistedPerformer, - BlacklistedStudio, - BlacklistedTag, - ) - base = base.where( - ~exists( - select(1) - .select_from(ScenePerformer) - .join( - BlacklistedPerformer, - (BlacklistedPerformer.performer_id == ScenePerformer.performer_id) - & (BlacklistedPerformer.device_id == device_id), - ) - .where(ScenePerformer.scene_id == Scene.id) - ) - ) - base = base.where( - ~Scene.studio_id.in_( - select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id) - ) - ) - base = base.where( - ~exists( - select(1) - .select_from(SceneTag) - .join( - BlacklistedTag, - (BlacklistedTag.tag_id == SceneTag.tag_id) - & (BlacklistedTag.device_id == device_id), - ) - .where(SceneTag.scene_id == Scene.id) - ) - ) - + # Blacklisty device (performer/studio/tag) — globalne wykluczenia, współdzielone z + # licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu). + for _bl_clause in blacklist_clauses(session, device_id): + base = base.where(_bl_clause) if min_duration_sec is not None: base = base.where(Scene.duration_sec >= min_duration_sec) @@ -346,25 +362,11 @@ def list_scenes( ) if not include_stubs: - # Stub scene heuristic: tube-only scena BEZ release_date AND BEZ canonical - # (TPDB/StashDB) ref AND BEZ żadnego ScenePerformer linka. ScenePerformer - # dodaje continuous worker (search-by-name → wymusza link), więc per-performer - # search-result NIGDY nie jest stub. To filtruje tylko anonymous tube-only - # sceny z newUrl/categories ingestu które nie zostały zsyntowane z performerem. - canonical_exists = exists( - select(1) - .select_from(SceneExternalRef) - .join(Source, Source.id == SceneExternalRef.source_id) - .where(SceneExternalRef.scene_id == Scene.id) - .where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb])) - ) - has_performer = exists( - select(1).where(ScenePerformer.scene_id == Scene.id) - ) - # NOT stub gdy: ma canonical_ref OR ma release_date OR ma performera - base = base.where( - Scene.release_date.is_not(None) | canonical_exists | has_performer - ) + # Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena + # bez release_date AND bez canonical (TPDB/StashDB) AND bez performera. Continuous + # worker dodaje ScenePerformer (search-by-name), więc per-performer wynik nie jest + # stubem. Filtruje anonymous tube-only sceny z newUrl/categories ingestu. + base = base.where(stub_exclusion_clause()) _is_pure_default = ( not include_stubs and not q and not studio_slug_list and not tag_slug_list diff --git a/app/scheduler/deep_crawl.py b/app/scheduler/deep_crawl.py index 4a6ce52..75ea47b 100644 --- a/app/scheduler/deep_crawl.py +++ b/app/scheduler/deep_crawl.py @@ -140,6 +140,20 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None exhausted = False budget_hit = False + st = state.setdefault(sitetag, {}) + + def _persist() -> None: + # Zapisz kursor po KAŻDEJ skończonej stronie. Bez tego stan leciał raz na końcu + # runu, więc hard-timeout ubijający run mid-page (mimo miękkiego budżetu, gdy + # pojedyncza strona przez wolne proxy przekroczy margines) gubił cały postęp + # (orphan thread, GOON-V). Per-page persist = najgorszy przypadek to powtórka + # jednej strony. Zapis jest atomowy (tmp+replace) i tani względem fetcha strony. + st["last_page"] = last_done + st["exhausted"] = exhausted + st["swept_once"] = swept_once or exhausted + st["updated_at"] = int(time.time()) + _save_state(state) + if cap is not None and start > cap: # kursor osiągnął per-tube cap → traktuj jak koniec katalogu (reset re-sweepuje od 1) exhausted = True @@ -167,8 +181,9 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None except Exception: counters["errors"] += 1 last_done = page - # Miękki budżet: stop po skończonej stronie (kursor=last_done zapisany niżej), - # zanim hard-timeout ubije run mid-page (orphan thread, kursor zgubiony, GOON-V). + _persist() # kursor po każdej stronie — mid-page hard-kill nie gubi postępu + # Miękki budżet: stop po skończonej stronie (kursor już zapisany), zanim + # hard-timeout ubije run mid-page (orphan thread, kursor zgubiony, GOON-V). if time.time() - t0 > _RUN_BUDGET_SEC: budget_hit = True log.warning( @@ -181,14 +196,10 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None log.info("deep-crawl %s: reached page cap %d (exhausted)", sitetag, cap) exhausted = True - st = state.setdefault(sitetag, {}) - st["last_page"] = last_done - st["exhausted"] = exhausted - # Gdy tube dobił do końca katalogu (empty page albo cap), zapamiętaj to na stałe - - # kolejne przejścia (po resecie kursora) to re-sweep, gdzie nowe sceny są genuine. - st["swept_once"] = swept_once or exhausted - st["updated_at"] = int(time.time()) - _save_state(state) + # Zapis terminalny: utrwala finalne flagi (exhausted z empty-page/cap, swept_once). + # swept_once=True gdy tube dobił do końca katalogu — kolejne przejścia (po resecie + # kursora) to re-sweep, gdzie nowe sceny są genuine, nie backfill. + _persist() log.info( "deep-crawl %s pages %d-%d: %s exhausted=%s budget_hit=%s (%.0fs)", diff --git a/mobile/src/components/SceneTile.tsx b/mobile/src/components/SceneTile.tsx index f81ad6e..3e23251 100644 --- a/mobile/src/components/SceneTile.tsx +++ b/mobile/src/components/SceneTile.tsx @@ -26,6 +26,7 @@ import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useSceneActions } from '../SceneActionsContext'; +import { isNewScene } from '../lib/newScenes'; import type { RootStackParamList } from '../navigation'; import { fonts, theme } from '../theme'; import type { SceneOut } from '../types'; @@ -80,9 +81,7 @@ function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: }; const dim = scene.finished === true; - // NEW = dodane od ostatniej wizyty, ale NIE backfill (masowy import starego katalogu - - // tube podaje datę importu jako release_date, więc udawałby świeżość). Spójne z licznikiem +N. - const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince && !scene.backfill); + const isNew = isNewScene(scene, seenSince); const dur = scene.duration_sec; const durLabel = dur && dur > 0 diff --git a/mobile/src/lib/newScenes.ts b/mobile/src/lib/newScenes.ts new file mode 100644 index 0000000..dde1a88 --- /dev/null +++ b/mobile/src/lib/newScenes.ts @@ -0,0 +1,26 @@ +// Jedna definicja "co jest NOWĄ sceną" dla favorite-driven widoków (PerformerScenes, +// StudioScenes, SceneTile). Wcześniej reguła była skopiowana w 3-4 miejscach i dryfowała +// (martwy FavoriteSceneRow miał starą wersję bez backfillu). Trzymaj tu. +import type { SceneOut } from '../types'; + +/** + * NEW = dodane od ostatniej wizyty (created_at > seenSince), ale NIE backfill (masowy + * import starego katalogu, tube podaje datę importu jako release_date, więc udawałby + * świeżość). Spójne z licznikiem "+N" w backendzie (app/api/favorites.py _new_counts). + */ +export function isNewScene(scene: SceneOut, seenSince: string | undefined): boolean { + return !!(seenSince && scene.created_at && scene.created_at > seenSince && !scene.backfill); +} + +/** NEW-first: nowe sceny na górę (zachowując kolejność), reszta bez zmian. Bez seenSince + * zwraca listę jak jest. */ +export function sortNewFirst(items: SceneOut[], seenSince: string | undefined): SceneOut[] { + if (!seenSince) return items; + const newOnes: SceneOut[] = []; + const rest: SceneOut[] = []; + for (const s of items) { + if (isNewScene(s, seenSince)) newOnes.push(s); + else rest.push(s); + } + return [...newOnes, ...rest]; +} diff --git a/mobile/src/screens/PerformerScenesScreen.tsx b/mobile/src/screens/PerformerScenesScreen.tsx index 2ed9e9f..f23db4f 100644 --- a/mobile/src/screens/PerformerScenesScreen.tsx +++ b/mobile/src/screens/PerformerScenesScreen.tsx @@ -21,6 +21,7 @@ import { import { useClient } from '../ClientContext'; import { MoviePosterCard } from '../components/MoviePosterCard'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; +import { sortNewFirst } from '../lib/newScenes'; import { usePreferences } from '../PreferencesContext'; import { ErrorBoundary } from '../ErrorBoundary'; import type { RootStackParamList } from '../navigation'; @@ -170,21 +171,10 @@ export function PerformerScenesScreen() { // Sortowanie: NEW (created_at > seenSince) na górze; reszta po release_date desc // jak zwracane z backendu. Bez `seenSince` (entry spoza Favorites) — kolejność nie zmieniana. - const sortedScenes = React.useMemo(() => { - const items = scenesQuery.data?.items ?? []; - if (!seenSince) return items; - const newOnes: SceneOut[] = []; - const rest: SceneOut[] = []; - for (const s of items) { - // NEW-first pomija backfill (masowy import katalogu), spójne z badge + licznikiem +N. - if (s.created_at && s.created_at > seenSince && !s.backfill) { - newOnes.push(s); - } else { - rest.push(s); - } - } - return [...newOnes, ...rest]; - }, [scenesQuery.data?.items, seenSince]); + const sortedScenes = React.useMemo( + () => sortNewFirst(scenesQuery.data?.items ?? [], seenSince), + [scenesQuery.data?.items, seenSince], + ); const movies = moviesQuery.data?.items ?? []; const scenesTotal = scenesQuery.data?.total ?? 0; diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index d8f7a6d..d0dd186 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -599,32 +599,11 @@ function PlaybackButton({ return; } - // pornxp.ph: CDN token IP-bound (backend 403 cross-IP) → backend oddaje WebView - // fallback który czarno-ekranił (bug-report 2026-06-07, fd06cd86). Telefon sam - // pobiera stronę (phone-IP-bound mp4) → natywne multi-quality, zero WebView/reklam. - if (source.origin === 'tube:pornxpph') { - setResolving(true); - try { - const links = await resolvePornxpPage(source.page_url); - if (links.length > 0) { - markStarted(); - const pick = pickAuto(links); - if (pick) await openAsVideo(pick, source.page_url); - else setQualityLinks(links); - return; - } - // pusto → spadnij na backend resolve (WebView) poniżej - } catch { - // ignore → backend fallback - } finally { - setResolving(false); - } - } - - // sxyprn / eporner: CDN token IP-bound do tego KTO POBRAŁ STRONĘ (audit 2026-06-11). - // Backend resolvuje z VPS → telefon dostaje URL bound do IP VPS → direct daje 403 / - // 10B placeholder → fallback na proxy → CAŁE wideo przez Hetzner. Telefon sam pobiera - // stronę (phone IP) → token bound do telefonu → gra direct, zero VPS. [] → backend niżej. + // Phone-side resolve dla IP-bound tubów: CDN token jest bound do IP które POBRAŁO + // stronę (audit 2026-06-11). Backend resolvuje z VPS → telefon dostaje URL bound do IP + // VPS → direct 403 / 10B placeholder. Telefon sam pobiera stronę (phone IP) → token + // bound do telefonu → gra direct, zero VPS. pornxp.ph analogicznie (backend WebView + // fallback czarno-ekranił, fd06cd86). Pusto → spadnij na backend resolve niżej. const phoneResolver = source.origin === 'tube:sxyprncom' ? resolveSxyprnPage @@ -632,7 +611,9 @@ function PlaybackButton({ ? resolveEpornerPage : source.origin === 'tube:fpoxxx' ? resolveFpoxxxPage - : null; + : source.origin === 'tube:pornxpph' + ? resolvePornxpPage + : null; if (phoneResolver) { setResolving(true); try { diff --git a/mobile/src/screens/StudioScenesScreen.tsx b/mobile/src/screens/StudioScenesScreen.tsx index 8870139..20481e9 100644 --- a/mobile/src/screens/StudioScenesScreen.tsx +++ b/mobile/src/screens/StudioScenesScreen.tsx @@ -16,6 +16,7 @@ import { View, } from 'react-native'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; +import { sortNewFirst } from '../lib/newScenes'; import { useClient } from '../ClientContext'; import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; @@ -115,21 +116,10 @@ export function StudioScenesScreen() { }), }); - const sortedItems = React.useMemo(() => { - const items = data?.items ?? []; - if (!seenSince) return items; - const newOnes: SceneOut[] = []; - const rest: SceneOut[] = []; - for (const s of items) { - // NEW-first pomija backfill (masowy import katalogu), spójne z badge + licznikiem +N. - if (s.created_at && s.created_at > seenSince && !s.backfill) { - newOnes.push(s); - } else { - rest.push(s); - } - } - return [...newOnes, ...rest]; - }, [data?.items, seenSince]); + const sortedItems = React.useMemo( + () => sortNewFirst(data?.items ?? [], seenSince), + [data?.items, seenSince], + ); return (