fix(review): player recovery deadlocks, quick-play re-fire, backfill gaps, em-dashes
Some checks are pending
Backend tests / test (push) Waiting to run

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:
jtrzupek 2026-07-02 09:20:40 +02:00
parent 8c7fa36437
commit 7895148c0f
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)