diff --git a/alembic/versions/20260702_0026_scene_backfill_flag.py b/alembic/versions/20260702_0026_scene_backfill_flag.py index 5613761..8d953aa 100644 --- a/alembic/versions/20260702_0026_scene_backfill_flag.py +++ b/alembic/versions/20260702_0026_scene_backfill_flag.py @@ -6,13 +6,16 @@ Create Date: 2026-07-02 Tube'y podają datę importu jako release_date, więc masowy backfill starego katalogu (deep-crawl głębokie strony) udawał świeżość i zawyżał licznik "+N nowych" w ulubionych. -`scenes.backfill` oznacza takie sceny — pozostają widoczne, ale nie liczą się jako nowe. -Ustawiane przy tworzeniu (deep_crawl page > próg); świeży latest-crawl + delta TPDB/ -StashDB → False. Patrz app/scheduler/deep_crawl.py, app/resolve/scene_resolver.py. +`scenes.backfill` oznacza takie sceny (pozostają widoczne, ale nie liczą się jako nowe). +Dodatkowo indeks na scene_performers(performer_id) pod zapytanie licznika ulubionych +(PK to (scene_id, performer_id), więc filtr po samym performer_id był full scanem). + +Idempotentne (IF NOT EXISTS): prod dostał kolumnę/indeksy ręcznym ALTER-em zanim ta +migracja powstała, a deploy nie odpala alembic; guard chroni przed DuplicateColumn gdyby +`alembic upgrade` puszczono na prodzie albo na dumpie z prod. """ from collections.abc import Sequence -import sqlalchemy as sa from alembic import op revision: str = "0026_scene_backfill_flag" @@ -22,18 +25,17 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: - op.add_column( - "scenes", - sa.Column( - "backfill", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), + op.execute( + "ALTER TABLE scenes ADD COLUMN IF NOT EXISTS backfill boolean NOT NULL DEFAULT false" + ) + op.execute("CREATE INDEX IF NOT EXISTS ix_scenes_backfill ON scenes (backfill)") + op.execute( + "CREATE INDEX IF NOT EXISTS ix_scene_performers_performer_id " + "ON scene_performers (performer_id)" ) - op.create_index("ix_scenes_backfill", "scenes", ["backfill"]) def downgrade() -> None: - op.drop_index("ix_scenes_backfill", table_name="scenes") - op.drop_column("scenes", "backfill") + op.execute("DROP INDEX IF EXISTS ix_scene_performers_performer_id") + op.execute("DROP INDEX IF EXISTS ix_scenes_backfill") + op.execute("ALTER TABLE scenes DROP COLUMN IF EXISTS backfill") diff --git a/app/api/favorites.py b/app/api/favorites.py index 4fa03bd..953a143 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -47,10 +47,10 @@ router = APIRouter( ) # Licznik "+N nowych" MUSI liczyć to samo, co user zobaczy po wejściu na listę scen -# performerki/studia — inaczej pokazuje +6 a lista ma 0 (report: „+6 a nic nowego"). +# performerki/studia, inaczej pokazuje +6 a lista ma 0 (report: „+6 a nic nowego"). # Rozjazdy które to powodowały: (1) licznik nie nakładał blacklist device (gay-filter/ # ukryte tagi) którą lista nakłada, (2) nie ograniczał do pierwszej strony listy -# (per_page=200, sort release_date desc) — nowa scena o starej dacie wydania wpadała +# (per_page=200, sort release_date desc), nowa scena o starej dacie wydania wpadała # poza top-200 albo w ogóle poza to co widać, (3) dla studiów nie odsiewał stub-scen. # Poniższe replikuje filtry i okno listy (app/api/scenes.py list_scenes, domyślne paramy # mobile: has_playback=true, include_stubs=false). Trzymane ręcznie w zgodzie z tamtym. @@ -173,7 +173,7 @@ def list_favorites( # 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). + # PerformerScenes. apply_stub=False, widok performerki i tak ma performera (nie-stub). new_counts: dict = {} if perf_ids: from sqlalchemy import func @@ -201,7 +201,7 @@ def list_favorites( ) ): ls = last_seen_by_perf.get(gid) - # backfill (masowy import katalogu) NIE liczy się jako nowość — patrz Scene.backfill. + # 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 @@ -322,7 +322,7 @@ def list_favorite_studios( # 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 + # 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 = {} @@ -351,7 +351,7 @@ def list_favorite_studios( ) ): ls = last_seen_by_studio.get(gid) - # backfill (masowy import katalogu) NIE liczy się jako nowość — patrz Scene.backfill. + # 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 diff --git a/app/api/schemas.py b/app/api/schemas.py index e8da3ab..08879ef 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -71,7 +71,7 @@ class SceneOut(BaseModel): # `created_at > last_seen_at` (favorite) → badge. created_at: datetime | None = None # True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej - # NEW badge nawet gdy created_at > last_seen — to stara treść, nie świeży release. + # NEW badge nawet gdy created_at > last_seen, to stara treść, nie świeży release. backfill: bool = False # Watched indicator (z `scene_play_progress`): mobile dim'uje kafelek gdy # `finished=True`, pokazuje progress bar gdy `position_sec > 0`. diff --git a/app/resolve/scene_merge.py b/app/resolve/scene_merge.py index a1fc72b..167f21a 100644 --- a/app/resolve/scene_merge.py +++ b/app/resolve/scene_merge.py @@ -227,6 +227,11 @@ def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None: # scena zachowuje datę pierwszego pojawienia, nie datę re-ingestu. if drop.created_at and keep.created_at and drop.created_at < keep.created_at: keep.created_at = drop.created_at + # backfill: scalona scena jest backfillem TYLKO gdy OBIE były backfillem. Jeśli + # którakolwiek była świeża (backfill=False), treść jest realnie nowa, więc zdjęcie + # flagi przywraca jej status "nowa" (review 5: fresh scena scalona w martwy dup nie + # może na stałe stracić NEW przez to że keep był backfillem). + keep.backfill = bool(keep.backfill and drop.backfill) def _close_pending_candidates( diff --git a/app/resolve/scene_resolver.py b/app/resolve/scene_resolver.py index ac11a0d..388308c 100644 --- a/app/resolve/scene_resolver.py +++ b/app/resolve/scene_resolver.py @@ -73,7 +73,7 @@ def resolve_scene( ) -> SceneResolveResult: """backfill=True → nowo utworzona scena to masowy import katalogu (deep-crawl głębokie strony), NIE świeży release. Flag ląduje na Scene.backfill i wyklucza scenę z "nowych" - (licznik +N w ulubionych). Dotyczy tylko TWORZENIA — match do istniejącej sceny nie + (licznik +N w ulubionych). Dotyczy tylko TWORZENIA, match do istniejącej sceny nie zmienia jej flagi (istniejąca mogła być świeża).""" studio = resolve_studio(session, norm=norm.studio, source_id=source_id) if norm.studio else None studio_id = studio.id if studio else None diff --git a/app/scheduler/deep_crawl.py b/app/scheduler/deep_crawl.py index 15bbcf6..4a6ce52 100644 --- a/app/scheduler/deep_crawl.py +++ b/app/scheduler/deep_crawl.py @@ -43,7 +43,7 @@ _PAGE_CAP: dict[str, int] = { # przez wolne proxy) potrafią przekroczyć hard-timeout 3600s z _job_deep_crawl → run # ubijany w locie, kursor NIE zapisany (orphan thread), tube zero postępu + alert # GOON-V. Budżet < hard-timeout: przerywamy PO skończonej stronie, zapisujemy kursor, -# wracamy czysto — następny run kontynuuje. Margines 600s na dokończenie strony w toku. +# wracamy czysto, następny run kontynuuje. Margines 600s na dokończenie strony w toku. _RUN_BUDGET_SEC = 3000 # Strony <= tego progu = "latest" (świeże posty tube'a) → nowe sceny stąd to genuine @@ -121,6 +121,11 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None scraper = scrapers[sitetag]() cap = _PAGE_CAP.get(sitetag) # mega-tube depth cap (None = crawl do końca katalogu) start = int(state.get(sitetag, {}).get("last_page", 0)) + 1 + # swept_once = tube był już RAZ przecrawlowany do końca. Wtedy jesteśmy w re-sweepie + # (reset kursora), a każda NOWA scena to realny przyrost katalogu, NIE backfill, + # niezależnie od numeru strony (review 23). Backfill tagujemy tylko na PIERWSZYM + # przejściu, gdy strony > progu to nurkowanie w stary katalog. + swept_once = bool(state.get(sitetag, {}).get("swept_once", False)) end = start + pages_per_run - 1 if cap is not None: end = min(end, cap) @@ -149,7 +154,7 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None exhausted = True last_done = page break - page_is_backfill = page > _LATEST_PAGE_THRESHOLD + page_is_backfill = (page > _LATEST_PAGE_THRESHOLD) and not swept_once for raw in scenes: counters["seen"] += 1 try: @@ -163,11 +168,11 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None 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). + # 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( - "deep-crawl %s: run budget %ds hit at page %d (%d/%d stron) — stop czysto, " + "deep-crawl %s: run budget %ds hit at page %d (%d/%d stron), stop czysto, " "kursor zapisany, kontynuacja w następnym runie", sitetag, _RUN_BUDGET_SEC, page, page - start + 1, pages_per_run, ) @@ -179,6 +184,9 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None 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) diff --git a/app/scheduler/performer_driven.py b/app/scheduler/performer_driven.py index d8212e9..a8c850d 100644 --- a/app/scheduler/performer_driven.py +++ b/app/scheduler/performer_driven.py @@ -173,6 +173,10 @@ def run_performer_driven( iterator_factory=lambda s=scraper, t=target: s.search( t.canonical_name, page=1, limit=50 ), + # search-by-name zwraca całą karierę performera (stare + nowe) z + # fałszywą datą tube → traktuj jak backfill, nie "nowe" (review 4). + # Genuinnie świeże i tak wpadną fresh przez latest-crawl / TPDB delta. + backfill=True, ) counters.merge(SCRAPER_SOURCE_NAME, c) @@ -584,11 +588,16 @@ def _ingest_iter_into_run( source_name: str, run_label: str, iterator_factory, # type: ignore[no-untyped-def] + backfill: bool = False, ) -> dict[str, int]: """Wariant ingest_from_connector dla iteratorów ad-hoc (per-performer pull). Otwiera IngestRun, iteruje, _process_scene per scene, finalizuje run. Counters podobne do `ingest_from_connector` ale per-call. + + backfill=True → tworzone sceny to backward-fill katalogu (search po nazwie zwraca + całą karierę performera, głównie stare sceny z fałszywą datą tube), więc nie licza + się jako "nowe". Canonical (TPDB/StashDB delta) zostawia default False. Review 4. """ counters = {"seen": 0, "new": 0, "updated": 0, "skipped": 0, "errors": 0} @@ -607,7 +616,9 @@ def _ingest_iter_into_run( for raw in iterator_factory(): counters["seen"] += 1 try: - _process_scene(source_id=source_id, raw_scene=raw, counters=counters) + _process_scene( + source_id=source_id, raw_scene=raw, counters=counters, backfill=backfill + ) except Exception as exc: # pragma: no cover - obronnie counters["errors"] += 1 log.exception("performer-driven scene failed external_id=%s: %s", raw.external_id, exc) diff --git a/app/scheduler/source_stats.py b/app/scheduler/source_stats.py index 90b21dc..37fee38 100644 --- a/app/scheduler/source_stats.py +++ b/app/scheduler/source_stats.py @@ -27,7 +27,7 @@ log = logging.getLogger(__name__) _TELEMETRY_WINDOW_DAYS = 7 # Poniżej tego telemetria niemiarodajna → proxy. Próg podniesiony 10→25 (2026-06-29): -# freshporno (5★ fresh+rich, realnie gra — zweryfikowane 206/507MB) dostało health=0 → +# freshporno (5★ fresh+rich, realnie gra, zweryfikowane 206/507MB) dostało health=0 → # "OFFLINE" na podstawie 10 prób z jednego pechowego okna (CDN-node blip), user-report # cb526949. 25 prób to sensowniejszy próg pewności zanim ogłosimy źródło offline; przy # mniejszej próbce lecimy na proxy/heurystykę (nie zerujemy gwiazdek znanemu-dobremu). diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 3d324f8..8c2b1c1 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,18 +16,26 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-02b', + date: 'July 2026', + items: [ + 'Fixed a case where a removed or briefly-failing video could get stuck on "Reconnecting" instead of playing or showing the error.', + 'Quick play no longer occasionally re-opens the player by itself after you mark a source broken.', + ], + }, { id: '2026-07-02', date: 'July 2026', items: [ - 'Favorites "+N new" now counts genuinely new scenes only — bulk back-catalog imports (old content a site re-dated to today) no longer inflate the badge or show a NEW tag.', + 'Favorites "+N new" now counts genuinely new scenes only, bulk back-catalog imports (old content a site re-dated to today) no longer inflate the badge or show a NEW tag.', ], }, { id: '2026-07-01b', date: 'July 2026', items: [ - 'Quick play: tap the ▶ on any thumbnail to start the best source right away — with a default quality set (Settings → Playback) it is one tap to video.', + 'Quick play: tap the ▶ on any thumbnail to start the best source right away, with a default quality set (Settings → Playback) it is one tap to video.', ], }, { @@ -35,7 +43,7 @@ export const CHANGELOG: ChangelogEntry[] = [ date: 'July 2026', items: [ 'Set a default video quality in Settings → Playback (e.g. 720p) to skip the quality chooser when that quality is available.', - 'The "+N new" count on a favorite now matches what you actually see when you open it — no more "+6" with nothing new.', + 'The "+N new" count on a favorite now matches what you actually see when you open it, no more "+6" with nothing new.', 'Smoother scrolling: when fresh scenes land at the top of a list, it no longer jumps under your finger.', 'Fixed: a video that plays fine no longer flashes "Playback failed / Mark broken" while it reconnects.', ], @@ -45,14 +53,14 @@ export const CHANGELOG: ChangelogEntry[] = [ date: 'June 2026', items: [ 'Seeking (swipe or drag) no longer pops a big pause button into the middle of the screen.', - 'Diagnostics "open in browser" now opens privately inside the app (incognito) — no cookies, no trace in your real browser.', + 'Diagnostics "open in browser" now opens privately inside the app (incognito), no cookies, no trace in your real browser.', ], }, { id: '2026-06-29', date: 'June 2026', items: [ - 'No more brief blast of sound when a video page is opening — it stays muted until you tap.', + 'No more brief blast of sound when a video page is opening, it stays muted until you tap.', ], }, { diff --git a/mobile/src/components/FavoriteSceneRow.tsx b/mobile/src/components/FavoriteSceneRow.tsx deleted file mode 100644 index dd24db2..0000000 --- a/mobile/src/components/FavoriteSceneRow.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// Wspólny komponent karty sceny dla list ulubionych (PerformerScenesScreen + -// StudioScenesScreen). Identyczny shape co SceneRow w ScenesScreen — thumb -// po lewej, tytuł / data · studio / performers / sources w kolumnie po prawej. -// Dodatkowo: NEW badge gdy `seenSince` < scene.created_at. -import { useNavigation } from '@react-navigation/native'; -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import * as Haptics from 'expo-haptics'; -import React from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import type { RootStackParamList } from '../navigation'; -import { theme } from '../theme'; -import type { SceneOut } from '../types'; -import { Thumb } from './Thumb'; - -interface Props { - scene: SceneOut; - /** Pokazuje NEW badge gdy `scene.created_at > seenSince`. */ - seenSince?: string; - /** Co wyświetlić w drugiej linijce — performers (dla studio scenes) lub studio (dla performer scenes). */ - secondLine?: 'performers' | 'studio'; -} - -export function FavoriteSceneRow({ scene, seenSince, secondLine = 'studio' }: Props) { - const navigation = - useNavigation>(); - const [isPreviewing, setIsPreviewing] = React.useState(false); - - const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url) - ?.animated_thumbnail_url; - const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url; - const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl; - - const startPreview = () => { - if (!animatedUrl) return; - setIsPreviewing(true); - Haptics.selectionAsync().catch(() => {}); - }; - - const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince); - const dim = scene.finished === true; - - // Drugi rządek (data + studio LUB data + performers) - const dateStr = scene.release_date ?? null; - let secondLineText: string | null = null; - if (secondLine === 'studio') { - const studio = scene.studio?.name ?? null; - secondLineText = [dateStr, studio].filter(Boolean).join(' · ') || null; - } else { - const performers = scene.performers - .slice(0, 3) - .map((p) => p.canonical_name) - .join(', '); - secondLineText = [dateStr, performers].filter(Boolean).join(' · ') || null; - } - - return ( - navigation.navigate('SceneDetail', { id: scene.id })} - onLongPress={startPreview} - onPressOut={() => setIsPreviewing(false)} - delayLongPress={180} - > - - {scene.is_favorite ? ( - - - - ) : null} - {isNew ? ( - - NEW - - ) : null} - - - {scene.title} - - {secondLineText ? ( - - {secondLineText} - - ) : null} - - {[...new Set(scene.external_refs.map((r) => r.source))].join(' · ')} - {scene.playback_sources.length > 0 - ? ` ▶ ${scene.playback_sources.length}` - : ''} - {dim ? ' ✓ watched' : ''} - - - - ); -} - -const styles = StyleSheet.create({ - row: { - flexDirection: 'row', - backgroundColor: theme.card, - borderColor: theme.border, - borderWidth: 1, - borderRadius: 8, - padding: 10, - marginBottom: 10, - gap: 12, - position: 'relative', - }, - rowDimmed: { opacity: 0.55 }, - thumbnail: { width: 110, aspectRatio: 16 / 9, flexShrink: 0 }, - rowContent: { flex: 1, justifyContent: 'center' }, - rowTitle: { color: theme.fg, fontWeight: '600', marginBottom: 4 }, - rowMuted: { color: theme.muted, fontSize: 13, marginBottom: 4 }, - rowSources: { - color: theme.accent, - fontSize: 11, - textTransform: 'uppercase', - marginTop: 2, - }, - favBadge: { - position: 'absolute', - top: 12, - left: 12, - backgroundColor: 'rgba(0,0,0,0.6)', - width: 22, - height: 22, - borderRadius: 11, - alignItems: 'center', - justifyContent: 'center', - }, - favBadgeText: { color: theme.accent, fontSize: 13, fontWeight: '800' }, - newBadge: { - position: 'absolute', - top: 8, - right: 8, - backgroundColor: theme.accent, - paddingHorizontal: 6, - paddingVertical: 1, - borderRadius: 4, - shadowColor: theme.accent, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.5, - shadowRadius: 4, - elevation: 3, - }, - newBadgeText: { color: theme.bg, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 }, -}); diff --git a/mobile/src/components/SceneTile.tsx b/mobile/src/components/SceneTile.tsx index c9149ae..f81ad6e 100644 --- a/mobile/src/components/SceneTile.tsx +++ b/mobile/src/components/SceneTile.tsx @@ -80,7 +80,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 — + // 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 dur = scene.duration_sec; diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index acea8e0..17c07fc 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -37,7 +37,7 @@ export type RootStackParamList = { // do title bara (np. 'hqporner.com'). SiteScenes: { origin: string; name: string }; MovieDetail: { id: string }; - // autoplay: wejście z ▶ na kafelku (quick-play) — SceneDetail sam odpala najlepsze źródło. + // autoplay: wejście z ▶ na kafelku (quick-play), SceneDetail sam odpala najlepsze źródło. SceneDetail: { id: string; autoplay?: boolean }; Performers: undefined; // `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen diff --git a/mobile/src/screens/AppLockSettingsScreen.tsx b/mobile/src/screens/AppLockSettingsScreen.tsx index 43cd561..e69ecc0 100644 --- a/mobile/src/screens/AppLockSettingsScreen.tsx +++ b/mobile/src/screens/AppLockSettingsScreen.tsx @@ -310,7 +310,7 @@ export function AppLockSettingsScreen() { Playback - Default quality — auto-picked when available so you skip the chooser. "Ask" shows + Default quality, auto-picked when available so you skip the chooser. "Ask" shows the quality menu every time (movie parts always ask). diff --git a/mobile/src/screens/PerformerScenesScreen.tsx b/mobile/src/screens/PerformerScenesScreen.tsx index 078b315..2ed9e9f 100644 --- a/mobile/src/screens/PerformerScenesScreen.tsx +++ b/mobile/src/screens/PerformerScenesScreen.tsx @@ -19,7 +19,6 @@ import { View, } from 'react-native'; import { useClient } from '../ClientContext'; -import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { MoviePosterCard } from '../components/MoviePosterCard'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { usePreferences } from '../PreferencesContext'; @@ -177,7 +176,7 @@ export function PerformerScenesScreen() { const newOnes: SceneOut[] = []; const rest: SceneOut[] = []; for (const s of items) { - // NEW-first pomija backfill (masowy import katalogu) — spójne z badge + licznikiem +N. + // 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 { diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index efd2408..09106e8 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -265,6 +265,13 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { }; }, [status, resolvePageUrl, playOrigin, playerError, player, url]); + // Re-resolve (IP-bound tube) jest STOSOWALNY tylko na initial-load i tylko dla nie-gone + // błędu (patrz warunki efektu wyżej). Gone / post-load error re-resolve się NIE uruchomi, + // więc ani łańcuch fallback (gate niżej), ani spinner recoveryPending nie mogą na niego + // czekać, inaczej deadlock: wieczny „Reconnecting", zero „Mark broken" (review 0/1/3). + const reResolveApplicable = + !!resolvePageUrl && !loadedOnceRef.current && !isGoneError(playerError?.message); + React.useEffect(() => { if (status !== 'error') return; // Step 0: post-load decode/seek error → recover in-place (przed proxy/WebView, @@ -286,14 +293,16 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { // disposed/za wcześnie — następny error tick spróbuje znów (do limitu) } }, 700); + return; // replace zaplanowany → czekamy na reload } catch { - // replace failed — przepuść do fallback chain niżej + // replace padł (player disposed) → wyczerp seek-recovery i PRZEPUŚĆ do łańcucha + // fallback niżej (wcześniej `return` blokował to mimo komentarza → stuck spinner). + seekRecoveryRef.current = 2; } - return; } - // Gate: dla IP-bound tubów (resolvePageUrl) poczekaj aż re-resolve się zakończy - // zanim ruszysz proxy/WebView. No-op gdy brak resolvePageUrl (reszta tubów). - if (resolvePageUrl && !reResolveDone) return; + // Gate: dla IP-bound tubów czekaj na re-resolve TYLKO gdy jest stosowalny (initial-load, + // nie-gone). Gone/post-load → re-resolve nie ruszy, więc nie blokuj łańcucha (deadlock). + if (reResolveApplicable && !reResolveDone) return; // Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL. if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) { didFallbackProxyRef.current = true; @@ -328,7 +337,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { mode: 'webview', }); } - }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveDone, playOrigin]); + }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveApplicable, reResolveDone, playOrigin]); // Telemetria odtwarzania (ranking źródeł). Tylko native-player path (WebView mode // ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping @@ -575,7 +584,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { .onStart(() => { cancelHide(); panStartTimeRef.current = player.currentTime || 0; - // NIE pokazujemy pełnych kontrolek przy swipe-seeku — pan ma własny popup + // NIE pokazujemy pełnych kontrolek przy swipe-seeku, pan ma własny popup // ±czas (panSeekBubble). Wcześniej setControlsVisible(true) wyrzucał duży // przycisk pauzy na środek i zostawiał go ~3.5s po puszczeniu (report dccc05e4). Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); @@ -688,7 +697,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { // Priorytet: scrubber (palec na pasku) > pan-seek (swipe na video) > playback. const panSeekRatio = panSeekTarget !== null && dur > 0 ? panSeekTarget / dur : null; const displayRatio = scrubbingRatio ?? panSeekRatio ?? progressRatio; - // Aktywne przewijanie (palec na pasku albo swipe) — chowamy wtedy duży środkowy + // Aktywne przewijanie (palec na pasku albo swipe), chowamy wtedy duży środkowy // przycisk play/pauza (report dccc05e4): przy seeku nie chcesz wielkiej pauzy na ekranie. const isSeeking = panSeekTarget !== null || scrubX !== null; const displayedTime = @@ -698,14 +707,14 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { ? panSeekTarget : position; - // Czy jest jeszcze JAKAKOLWIEK ścieżka ratunku w toku? Gdy tak — NIE pokazuj + // Czy jest jeszcze JAKAKOLWIEK ścieżka ratunku w toku? Gdy tak, NIE pokazuj // terminalnego błędu z „Mark broken", bo za chwilę zagra (report dafa8cdb: eporner/ // sxyprn/fpoxxx native pada na starcie, re-resolve podmienia URL ~1-3s później i gra; // przez tę chwilę migało „Playback failed / Mark broken"). Lustro logiki telemetrii: // re-resolve IP-bound, in-place seek-recovery, proxy albo WebView jeszcze nie próbowane. const recoveryPending = status === 'error' && - ((!!resolvePageUrl && !reResolveDone && !loadedOnceRef.current) || + ((reResolveApplicable && !reResolveDone) || (loadedOnceRef.current && seekRecoveryRef.current < 2 && !isGoneError(playerError?.message)) || (!!fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) || (!!fallbackEmbedUrl && !didFallbackWebViewRef.current)); @@ -856,7 +865,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { {fallbackEmbedUrl && !didFallbackWebViewRef.current - ? 'Native player failed — switching to embed…' + ? 'Native player failed, switching to embed…' : 'Reconnecting…'} @@ -896,7 +905,7 @@ const INJECTED_JS = ` window.__goonPatched = true; // -- 0a. Mute autoplay until the FIRST user gesture (kills the brief unmuted-audio - // flash gdy WebView otwiera stronę żeby przechwycić stream — report dccc05e4). + // flash gdy WebView otwiera stronę żeby przechwycić stream, report dccc05e4). // Override play() PRZED page JS: bez gestu → muted; po tapie usera → dźwięk // dozwolony (zgodne z "dźwięk dopiero po geście usera"). Element-level mute // niżej łapał za późno (autoplay z dźwiękiem zdążył ruszyć). diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index dbbecea..d8f7a6d 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -369,7 +369,11 @@ export function SceneDetailScreen() { sceneDurationSec={data.duration_sec ?? null} source={p} // Quick-play: pierwsze źródło (backend-ranked native-first) odpala się samo. + // onAutoPlayConsumed czyści param autoplay, żeby reorder listy (np. po + // "Mark broken" usuwa martwe źródło) nie odpalał kolejnego źródła sam + // z siebie, bo nowy i===0 dostałby autoPlay=true (review 2/6). autoPlay={!!autoplay && i === 0} + onAutoPlayConsumed={() => nav.setParams({ autoplay: false })} /> ))} long-press: open in browser / mark as broken @@ -475,11 +479,13 @@ function PlaybackButton({ sceneDurationSec, source, autoPlay = false, + onAutoPlayConsumed, }: { sceneId: string; sceneDurationSec: number | null; source: PlaybackSource; autoPlay?: boolean; + onAutoPlayConsumed?: () => void; }) { const client = useClient(); const queryClient = useQueryClient(); @@ -754,9 +760,10 @@ function PlaybackButton({ React.useEffect(() => { if (autoPlay && !didAutoPlayRef.current) { didAutoPlayRef.current = true; + onAutoPlayConsumed?.(); // wyczyść param, zanim reorder listy odpali kolejne źródło void onPress(); } - // onPress celowo poza deps — strzał raz na mount, nie przy każdym re-renderze. + // onPress celowo poza deps, strzał raz na mount, nie przy każdym re-renderze. // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoPlay]); diff --git a/mobile/src/screens/StudioScenesScreen.tsx b/mobile/src/screens/StudioScenesScreen.tsx index 03a693a..8870139 100644 --- a/mobile/src/screens/StudioScenesScreen.tsx +++ b/mobile/src/screens/StudioScenesScreen.tsx @@ -15,7 +15,6 @@ import { Text, View, } from 'react-native'; -import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { useClient } from '../ClientContext'; import { usePreferences } from '../PreferencesContext'; @@ -122,7 +121,7 @@ export function StudioScenesScreen() { const newOnes: SceneOut[] = []; const rest: SceneOut[] = []; for (const s of items) { - // NEW-first pomija backfill (masowy import katalogu) — spójne z badge + licznikiem +N. + // 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 { diff --git a/mobile/src/types.ts b/mobile/src/types.ts index 1774866..0c9624d 100644 --- a/mobile/src/types.ts +++ b/mobile/src/types.ts @@ -173,7 +173,7 @@ export interface SceneOut { // Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW" — gdy // `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen). created_at?: string | null; - // True = scena z masowego backfillu katalogu (deep-crawl) — stara treść, nie świeży + // True = scena z masowego backfillu katalogu (deep-crawl), stara treść, nie świeży // release. NIE pokazujemy na niej NEW badge nawet gdy created_at > seenSince. backfill?: boolean; // Watched indicator + favorite state. Backend dolicza z scene_play_progress + favorite_scenes. diff --git a/scripts/pilot_porndoe_deepcrawl.py b/scripts/pilot_porndoe_deepcrawl.py index 06a9f2c..fb242f8 100644 --- a/scripts/pilot_porndoe_deepcrawl.py +++ b/scripts/pilot_porndoe_deepcrawl.py @@ -61,7 +61,11 @@ def main() -> int: continue counters["seen"] += 1 try: - _process_scene(source_id=source_id, raw_scene=raw, counters=counters) + # deep-crawl pilot = backfill katalogu (stary content z fałszywą datą + # tube), więc nie licz jako "nowe" (spójne z app/scheduler/deep_crawl.py). + _process_scene( + source_id=source_id, raw_scene=raw, counters=counters, backfill=True + ) except Exception: counters["errors"] += 1 print(f"page {page}: {counters} ({time.time() - t0:.0f}s)", flush=True)