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
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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ć).
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue