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:
parent
07cc3fdfba
commit
15e7c1646d
19 changed files with 108 additions and 202 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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ć).
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue