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 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. (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. `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/ Dodatkowo indeks na scene_performers(performer_id) pod zapytanie licznika ulubionych
StashDB → False. Patrz app/scheduler/deep_crawl.py, app/resolve/scene_resolver.py. (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 from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op from alembic import op
revision: str = "0026_scene_backfill_flag" revision: str = "0026_scene_backfill_flag"
@ -22,18 +25,17 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
op.add_column( op.execute(
"scenes", "ALTER TABLE scenes ADD COLUMN IF NOT EXISTS backfill boolean NOT NULL DEFAULT false"
sa.Column( )
"backfill", op.execute("CREATE INDEX IF NOT EXISTS ix_scenes_backfill ON scenes (backfill)")
sa.Boolean(), op.execute(
nullable=False, "CREATE INDEX IF NOT EXISTS ix_scene_performers_performer_id "
server_default=sa.text("false"), "ON scene_performers (performer_id)"
),
) )
op.create_index("ix_scenes_backfill", "scenes", ["backfill"])
def downgrade() -> None: def downgrade() -> None:
op.drop_index("ix_scenes_backfill", table_name="scenes") op.execute("DROP INDEX IF EXISTS ix_scene_performers_performer_id")
op.drop_column("scenes", "backfill") 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 # 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/ # 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 # 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. # 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 # 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. # 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 # 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 + # 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 # 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 = {} new_counts: dict = {}
if perf_ids: if perf_ids:
from sqlalchemy import func from sqlalchemy import func
@ -201,7 +201,7 @@ def list_favorites(
) )
): ):
ls = last_seen_by_perf.get(gid) 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: if not backfill and created_at is not None and ls is not None and created_at > ls:
new_counts[gid] = new_counts.get(gid, 0) + 1 new_counts[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_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
scene_counts: dict = {st.id: st.scene_count for _, st in rows} 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/ # + okno co lista StudioScenes. apply_stub=True: studio-scena bez performera/release/
# canonical to stub który lista odsiewa, więc licznik też musi. # canonical to stub który lista odsiewa, więc licznik też musi.
new_counts: dict = {} new_counts: dict = {}
@ -351,7 +351,7 @@ def list_favorite_studios(
) )
): ):
ls = last_seen_by_studio.get(gid) 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: if not backfill and created_at is not None and ls is not None and created_at > ls:
new_counts[gid] = new_counts.get(gid, 0) + 1 new_counts[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 > last_seen_at` (favorite) → badge.
created_at: datetime | None = None created_at: datetime | None = None
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej # 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 backfill: bool = False
# Watched indicator (z `scene_play_progress`): mobile dim'uje kafelek gdy # Watched indicator (z `scene_play_progress`): mobile dim'uje kafelek gdy
# `finished=True`, pokazuje progress bar gdy `position_sec > 0`. # `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. # scena zachowuje datę pierwszego pojawienia, nie datę re-ingestu.
if drop.created_at and keep.created_at and drop.created_at < keep.created_at: if drop.created_at and keep.created_at and drop.created_at < keep.created_at:
keep.created_at = drop.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( def _close_pending_candidates(

View file

@ -73,7 +73,7 @@ def resolve_scene(
) -> SceneResolveResult: ) -> SceneResolveResult:
"""backfill=True → nowo utworzona scena to masowy import katalogu (deep-crawl głębokie """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" 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).""" 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 = resolve_studio(session, norm=norm.studio, source_id=source_id) if norm.studio else None
studio_id = studio.id if 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 # 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 # 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, # 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 _RUN_BUDGET_SEC = 3000
# Strony <= tego progu = "latest" (świeże posty tube'a) → nowe sceny stąd to genuine # 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]() scraper = scrapers[sitetag]()
cap = _PAGE_CAP.get(sitetag) # mega-tube depth cap (None = crawl do końca katalogu) 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 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 end = start + pages_per_run - 1
if cap is not None: if cap is not None:
end = min(end, cap) end = min(end, cap)
@ -149,7 +154,7 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None
exhausted = True exhausted = True
last_done = page last_done = page
break break
page_is_backfill = page > _LATEST_PAGE_THRESHOLD page_is_backfill = (page > _LATEST_PAGE_THRESHOLD) and not swept_once
for raw in scenes: for raw in scenes:
counters["seen"] += 1 counters["seen"] += 1
try: try:
@ -163,11 +168,11 @@ def run_deep_crawl(*, pages_per_run: int = 60, sitetags: list[str] | None = None
counters["errors"] += 1 counters["errors"] += 1
last_done = page last_done = page
# Miękki budżet: stop po skończonej stronie (kursor=last_done zapisany niżej), # 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: if time.time() - t0 > _RUN_BUDGET_SEC:
budget_hit = True budget_hit = True
log.warning( 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", "kursor zapisany, kontynuacja w następnym runie",
sitetag, _RUN_BUDGET_SEC, page, page - start + 1, pages_per_run, 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 = state.setdefault(sitetag, {})
st["last_page"] = last_done st["last_page"] = last_done
st["exhausted"] = exhausted 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()) st["updated_at"] = int(time.time())
_save_state(state) _save_state(state)

View file

@ -173,6 +173,10 @@ def run_performer_driven(
iterator_factory=lambda s=scraper, t=target: s.search( iterator_factory=lambda s=scraper, t=target: s.search(
t.canonical_name, page=1, limit=50 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) counters.merge(SCRAPER_SOURCE_NAME, c)
@ -584,11 +588,16 @@ def _ingest_iter_into_run(
source_name: str, source_name: str,
run_label: str, run_label: str,
iterator_factory, # type: ignore[no-untyped-def] iterator_factory, # type: ignore[no-untyped-def]
backfill: bool = False,
) -> dict[str, int]: ) -> dict[str, int]:
"""Wariant ingest_from_connector dla iteratorów ad-hoc (per-performer pull). """Wariant ingest_from_connector dla iteratorów ad-hoc (per-performer pull).
Otwiera IngestRun, iteruje, _process_scene per scene, finalizuje run. Counters Otwiera IngestRun, iteruje, _process_scene per scene, finalizuje run. Counters
podobne do `ingest_from_connector` ale per-call. 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} 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(): for raw in iterator_factory():
counters["seen"] += 1 counters["seen"] += 1
try: 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 except Exception as exc: # pragma: no cover - obronnie
counters["errors"] += 1 counters["errors"] += 1
log.exception("performer-driven scene failed external_id=%s: %s", raw.external_id, exc) 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 _TELEMETRY_WINDOW_DAYS = 7
# Poniżej tego telemetria niemiarodajna → proxy. Próg podniesiony 10→25 (2026-06-29): # 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 # "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 # 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). # 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[] = [ 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', id: '2026-07-02',
date: 'July 2026', date: 'July 2026',
items: [ 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', id: '2026-07-01b',
date: 'July 2026', date: 'July 2026',
items: [ 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', date: 'July 2026',
items: [ items: [
'Set a default video quality in Settings → Playback (e.g. 720p) to skip the quality chooser when that quality is available.', '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.', '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.', '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', date: 'June 2026',
items: [ items: [
'Seeking (swipe or drag) no longer pops a big pause button into the middle of the screen.', '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', id: '2026-06-29',
date: 'June 2026', date: 'June 2026',
items: [ 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; 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. // tube podaje datę importu jako release_date, więc udawałby świeżość). Spójne z licznikiem +N.
const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince && !scene.backfill); const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince && !scene.backfill);
const dur = scene.duration_sec; const dur = scene.duration_sec;

View file

@ -37,7 +37,7 @@ export type RootStackParamList = {
// do title bara (np. 'hqporner.com'). // do title bara (np. 'hqporner.com').
SiteScenes: { origin: string; name: string }; SiteScenes: { origin: string; name: string };
MovieDetail: { id: 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 }; SceneDetail: { id: string; autoplay?: boolean };
Performers: undefined; Performers: undefined;
// `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen // `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen

View file

@ -310,7 +310,7 @@ export function AppLockSettingsScreen() {
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Playback</Text> <Text style={styles.sectionTitle}>Playback</Text>
<Text style={styles.hint}> <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). the quality menu every time (movie parts always ask).
</Text> </Text>
<View style={styles.chipRow}> <View style={styles.chipRow}>

View file

@ -19,7 +19,6 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { MoviePosterCard } from '../components/MoviePosterCard'; import { MoviePosterCard } from '../components/MoviePosterCard';
import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { usePreferences } from '../PreferencesContext'; import { usePreferences } from '../PreferencesContext';
@ -177,7 +176,7 @@ export function PerformerScenesScreen() {
const newOnes: SceneOut[] = []; const newOnes: SceneOut[] = [];
const rest: SceneOut[] = []; const rest: SceneOut[] = [];
for (const s of items) { 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) { if (s.created_at && s.created_at > seenSince && !s.backfill) {
newOnes.push(s); newOnes.push(s);
} else { } else {

View file

@ -265,6 +265,13 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
}; };
}, [status, resolvePageUrl, playOrigin, playerError, player, url]); }, [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(() => { React.useEffect(() => {
if (status !== 'error') return; if (status !== 'error') return;
// Step 0: post-load decode/seek error → recover in-place (przed proxy/WebView, // 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) // disposed/za wcześnie — następny error tick spróbuje znów (do limitu)
} }
}, 700); }, 700);
return; // replace zaplanowany → czekamy na reload
} catch { } 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 // Gate: dla IP-bound tubów czekaj na re-resolve TYLKO gdy jest stosowalny (initial-load,
// zanim ruszysz proxy/WebView. No-op gdy brak resolvePageUrl (reszta tubów). // nie-gone). Gone/post-load → re-resolve nie ruszy, więc nie blokuj łańcucha (deadlock).
if (resolvePageUrl && !reResolveDone) return; if (reResolveApplicable && !reResolveDone) return;
// Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL. // Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL.
if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) { if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) {
didFallbackProxyRef.current = true; didFallbackProxyRef.current = true;
@ -328,7 +337,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
mode: 'webview', 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 // Telemetria odtwarzania (ranking źródeł). Tylko native-player path (WebView mode
// ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping // ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping
@ -575,7 +584,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
.onStart(() => { .onStart(() => {
cancelHide(); cancelHide();
panStartTimeRef.current = player.currentTime || 0; 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 // ±czas (panSeekBubble). Wcześniej setControlsVisible(true) wyrzucał duży
// przycisk pauzy na środek i zostawiał go ~3.5s po puszczeniu (report dccc05e4). // przycisk pauzy na środek i zostawiał go ~3.5s po puszczeniu (report dccc05e4).
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); 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. // Priorytet: scrubber (palec na pasku) > pan-seek (swipe na video) > playback.
const panSeekRatio = panSeekTarget !== null && dur > 0 ? panSeekTarget / dur : null; const panSeekRatio = panSeekTarget !== null && dur > 0 ? panSeekTarget / dur : null;
const displayRatio = scrubbingRatio ?? panSeekRatio ?? progressRatio; 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. // przycisk play/pauza (report dccc05e4): przy seeku nie chcesz wielkiej pauzy na ekranie.
const isSeeking = panSeekTarget !== null || scrubX !== null; const isSeeking = panSeekTarget !== null || scrubX !== null;
const displayedTime = const displayedTime =
@ -698,14 +707,14 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
? panSeekTarget ? panSeekTarget
: position; : 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/ // 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; // 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: // 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. // re-resolve IP-bound, in-place seek-recovery, proxy albo WebView jeszcze nie próbowane.
const recoveryPending = const recoveryPending =
status === 'error' && status === 'error' &&
((!!resolvePageUrl && !reResolveDone && !loadedOnceRef.current) || ((reResolveApplicable && !reResolveDone) ||
(loadedOnceRef.current && seekRecoveryRef.current < 2 && !isGoneError(playerError?.message)) || (loadedOnceRef.current && seekRecoveryRef.current < 2 && !isGoneError(playerError?.message)) ||
(!!fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) || (!!fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) ||
(!!fallbackEmbedUrl && !didFallbackWebViewRef.current)); (!!fallbackEmbedUrl && !didFallbackWebViewRef.current));
@ -856,7 +865,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
<ActivityIndicator color={theme.fg} size="large" /> <ActivityIndicator color={theme.fg} size="large" />
<Text style={styles.overlayText}> <Text style={styles.overlayText}>
{fallbackEmbedUrl && !didFallbackWebViewRef.current {fallbackEmbedUrl && !didFallbackWebViewRef.current
? 'Native player failed — switching to embed…' ? 'Native player failed, switching to embed…'
: 'Reconnecting…'} : 'Reconnecting…'}
</Text> </Text>
</View> </View>
@ -896,7 +905,7 @@ const INJECTED_JS = `
window.__goonPatched = true; window.__goonPatched = true;
// -- 0a. Mute autoplay until the FIRST user gesture (kills the brief unmuted-audio // -- 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 // 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 // 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ć). // 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} sceneDurationSec={data.duration_sec ?? null}
source={p} source={p}
// Quick-play: pierwsze źródło (backend-ranked native-first) odpala się samo. // 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} autoPlay={!!autoplay && i === 0}
onAutoPlayConsumed={() => nav.setParams({ autoplay: false })}
/> />
))} ))}
<Text style={styles.tagHint}>long-press: open in browser / mark as broken</Text> <Text style={styles.tagHint}>long-press: open in browser / mark as broken</Text>
@ -475,11 +479,13 @@ function PlaybackButton({
sceneDurationSec, sceneDurationSec,
source, source,
autoPlay = false, autoPlay = false,
onAutoPlayConsumed,
}: { }: {
sceneId: string; sceneId: string;
sceneDurationSec: number | null; sceneDurationSec: number | null;
source: PlaybackSource; source: PlaybackSource;
autoPlay?: boolean; autoPlay?: boolean;
onAutoPlayConsumed?: () => void;
}) { }) {
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -754,9 +760,10 @@ function PlaybackButton({
React.useEffect(() => { React.useEffect(() => {
if (autoPlay && !didAutoPlayRef.current) { if (autoPlay && !didAutoPlayRef.current) {
didAutoPlayRef.current = true; didAutoPlayRef.current = true;
onAutoPlayConsumed?.(); // wyczyść param, zanim reorder listy odpali kolejne źródło
void onPress(); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPlay]); }, [autoPlay]);

View file

@ -15,7 +15,6 @@ import {
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext'; import { usePreferences } from '../PreferencesContext';
@ -122,7 +121,7 @@ export function StudioScenesScreen() {
const newOnes: SceneOut[] = []; const newOnes: SceneOut[] = [];
const rest: SceneOut[] = []; const rest: SceneOut[] = [];
for (const s of items) { 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) { if (s.created_at && s.created_at > seenSince && !s.backfill) {
newOnes.push(s); newOnes.push(s);
} else { } else {

View file

@ -173,7 +173,7 @@ export interface SceneOut {
// Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW" — gdy // Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW" — gdy
// `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen). // `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen).
created_at?: string | null; 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. // release. NIE pokazujemy na niej NEW badge nawet gdy created_at > seenSince.
backfill?: boolean; backfill?: boolean;
// Watched indicator + favorite state. Backend dolicza z scene_play_progress + favorite_scenes. // Watched indicator + favorite state. Backend dolicza z scene_play_progress + favorite_scenes.

View file

@ -61,7 +61,11 @@ def main() -> int:
continue continue
counters["seen"] += 1 counters["seen"] += 1
try: 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: except Exception:
counters["errors"] += 1 counters["errors"] += 1
print(f"page {page}: {counters} ({time.time() - t0:.0f}s)", flush=True) print(f"page {page}: {counters} ({time.time() - t0:.0f}s)", flush=True)