fix(review): player recovery deadlocks, quick-play re-fire, backfill gaps, em-dashes

Addresses the ultra-review findings on this branch:

Player (PlayerScreen.tsx): the new recoveryPending mirrored the fallback-chain guards
by hand and could deadlock into a permanent "Reconnecting" spinner with no way to Mark
broken — for gone (410) sources on IP-bound tubes (re-resolve bails before setting
reResolveDone) and for any post-load error on those tubes (re-resolve is initial-load
only). Derive one reResolveApplicable flag (IP-bound AND initial-load AND not-gone) and
use it for both the chain gate and the spinner, so gone/post-load errors fall through to
proxy/WebView or the terminal error card. Seek-recovery now falls through to the chain
when player.replace() throws instead of returning.

Quick-play (SceneDetail): the autoplay route param persisted and autoPlay={i===0} re-fired
when the source list reordered (e.g. after Mark broken drops the dead source), bouncing the
user into the player. Consume it once via onAutoPlayConsumed -> nav.setParams({autoplay:false}).

Backfill semantics: performer-driven direct-scraper "backward fill" now tags scenes
backfill=True (search-by-name pulls the whole old catalog); merge coalesces backfill
(keep AND drop) so a fresh scene merged into a dead dup keeps NEW; deep-crawl only tags
backfill on a tube's FIRST sweep (swept_once) so re-sweep catalog growth stays genuine;
pilot script tags backfill.

Perf/migration: migration 0026 is now idempotent (IF NOT EXISTS; prod got the column via
manual ALTER) and adds ix_scene_performers_performer_id (favorites count filtered
performer_id with no index); index also created on prod.

Cleanup: deleted dead FavoriteSceneRow (unused import in two screens, stale isNew without
the backfill guard); removed em-dashes from all lines this branch added (user CLAUDE.md
rule), including the user-facing changelog / Settings / player-overlay strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-02 09:20:40 +02:00
parent 07cc3fdfba
commit 15e7c1646d
19 changed files with 108 additions and 202 deletions

View file

@ -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")

View file

@ -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

View file

@ -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`.

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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).

View file

@ -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.',
],
},
{

View file

@ -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<NativeStackNavigationProp<RootStackParamList>>();
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 (
<Pressable
style={[styles.row, dim && styles.rowDimmed]}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
>
<Thumb url={displayUrl} style={styles.thumbnail} />
{scene.is_favorite ? (
<View style={styles.favBadge}>
<Text style={styles.favBadgeText}></Text>
</View>
) : null}
{isNew ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text>
</View>
) : null}
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{scene.title}
</Text>
{secondLineText ? (
<Text style={styles.rowMuted} numberOfLines={1}>
{secondLineText}
</Text>
) : null}
<Text style={styles.rowSources}>
{[...new Set(scene.external_refs.map((r) => r.source))].join(' · ')}
{scene.playback_sources.length > 0
? `${scene.playback_sources.length}`
: ''}
{dim ? ' ✓ watched' : ''}
</Text>
</View>
</Pressable>
);
}
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 },
});

View file

@ -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;

View file

@ -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

View file

@ -310,7 +310,7 @@ export function AppLockSettingsScreen() {
<View style={styles.section}>
<Text style={styles.sectionTitle}>Playback</Text>
<Text style={styles.hint}>
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).
</Text>
<View style={styles.chipRow}>

View file

@ -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 {

View file

@ -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 }) {
<ActivityIndicator color={theme.fg} size="large" />
<Text style={styles.overlayText}>
{fallbackEmbedUrl && !didFallbackWebViewRef.current
? 'Native player failed switching to embed…'
? 'Native player failed, switching to embed…'
: 'Reconnecting…'}
</Text>
</View>
@ -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ć).

View file

@ -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 })}
/>
))}
<Text style={styles.tagHint}>long-press: open in browser / mark as broken</Text>
@ -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]);

View file

@ -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 {

View file

@ -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.

View file

@ -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)