Compare commits
4 commits
cfe72195ed
...
617dda0bbe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
617dda0bbe | ||
|
|
8753243e15 | ||
|
|
92f83b93fe | ||
|
|
27309d24f2 |
19 changed files with 361 additions and 47 deletions
33
alembic/versions/20260930_0051_movies_tpdb_checked_at.py
Normal file
33
alembic/versions/20260930_0051_movies_tpdb_checked_at.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""movies.tpdb_checked_at: kiedy film ostatnio szukano w TPDB (kolejka wzbogacania).
|
||||||
|
|
||||||
|
Wzbogacanie filmow z TPDB (co 6 h po 200) bralo zawsze 200 NAJNOWSZYCH kandydatow i nie
|
||||||
|
pamietalo, ktore juz sprawdzilo. Przebieg o 05:00, po dziennym ingescie, wzbogacal 14-19
|
||||||
|
nowych filmow, a trzy kolejne mielily te same 200 bez trafienia i konczyly z zerem.
|
||||||
|
12,9 tys. kandydatow starszych niz miesiac nie doczekalo sie ani jednego sprawdzenia
|
||||||
|
(pomiar 2026-09-30). Z data sprawdzenia kolejka idzie od niesprawdzonych, a film bez
|
||||||
|
trafienia wraca najwczesniej po 30 dniach.
|
||||||
|
|
||||||
|
Kolumna nullable bez DEFAULT, ALTER natychmiastowy; NULL = jeszcze nie sprawdzany.
|
||||||
|
|
||||||
|
Revision ID: 0051_movies_tpdb_checked_at
|
||||||
|
Revises: 0050_tag_vertical_counts_no_fk
|
||||||
|
Create Date: 2026-09-30
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0051_movies_tpdb_checked_at"
|
||||||
|
down_revision: str | None = "0050_tag_vertical_counts_no_fk"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("SET LOCAL lock_timeout = '5s'")
|
||||||
|
op.execute("ALTER TABLE movies ADD COLUMN IF NOT EXISTS tpdb_checked_at timestamptz")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("SET LOCAL lock_timeout = '5s'")
|
||||||
|
op.execute("ALTER TABLE movies DROP COLUMN IF EXISTS tpdb_checked_at")
|
||||||
|
|
@ -17,6 +17,7 @@ bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do t
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
@ -479,9 +480,22 @@ def clip_events(
|
||||||
if changed:
|
if changed:
|
||||||
_save_affinity(session, device_id, row, aff, changed)
|
_save_affinity(session, device_id, row, aff, changed)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
broken = list({e.clip_id for e in events if e.kind == "error"})
|
||||||
|
if broken:
|
||||||
|
# W tle, zeby apka nie czekala na HEAD do RedGifs.
|
||||||
|
threading.Thread(target=_check_broken, args=(broken,), name="clip-check", daemon=True).start()
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_broken(clip_ids: list) -> None:
|
||||||
|
from app.scheduler.clips_ingest import check_clips_now
|
||||||
|
|
||||||
|
try:
|
||||||
|
check_clips_now(clip_ids)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("clips: sprawdzenie zgloszonych klipow nie wyszlo")
|
||||||
|
|
||||||
|
|
||||||
# ---- onboarding, afinicznosc ------------------------------------------------------------
|
# ---- onboarding, afinicznosc ------------------------------------------------------------
|
||||||
_OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
|
_OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,11 +200,19 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
"""Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści)."""
|
"""gid -> (nowe sceny, nowe filmy). Apka od 2026-10 pokazuje je osobno (zgłoszenie
|
||||||
counts = _new_counts(session, device_id, kind=kind)
|
5a724cc8), starsze wersje dostają sumę w `new_count`."""
|
||||||
for gid, n in _new_movie_counts(session, device_id, kind=kind).items():
|
scenes = _new_counts(session, device_id, kind=kind)
|
||||||
counts[gid] = counts.get(gid, 0) + n
|
movies = _new_movie_counts(session, device_id, kind=kind)
|
||||||
return counts
|
return {gid: (scenes.get(gid, 0), movies.get(gid, 0)) for gid in scenes.keys() | movies.keys()}
|
||||||
|
|
||||||
|
|
||||||
|
_NO_NEW = (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _favorites_order(new: dict, gid, name: str) -> tuple:
|
||||||
|
"""Najpierw ulubione z nowościami, w obu grupach alfabetycznie (zgłoszenie 5a724cc8)."""
|
||||||
|
return (sum(new.get(gid, _NO_NEW)) == 0, (name or "").lower())
|
||||||
|
|
||||||
|
|
||||||
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
|
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
|
||||||
|
|
@ -234,7 +242,7 @@ def _store_counts(key: tuple[str, str], started: float, counts: dict) -> None:
|
||||||
# mark_seen w trakcie liczenia: wynik policzono ze starym last_seen_at
|
# mark_seen w trakcie liczenia: wynik policzono ze starym last_seen_at
|
||||||
for gid, at in _counts_seen.get(key, {}).items():
|
for gid, at in _counts_seen.get(key, {}).items():
|
||||||
if at >= started:
|
if at >= started:
|
||||||
counts[gid] = 0
|
counts[gid] = _NO_NEW
|
||||||
_counts_seen.pop(key, None)
|
_counts_seen.pop(key, None)
|
||||||
_counts_cache[key] = (time.monotonic(), counts)
|
_counts_cache[key] = (time.monotonic(), counts)
|
||||||
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
|
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
|
||||||
|
|
@ -292,7 +300,7 @@ def _mark_counts_seen(device_id: str, kind: str, gid: uuid.UUID) -> None:
|
||||||
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
|
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
|
||||||
hit = _counts_cache.get(key)
|
hit = _counts_cache.get(key)
|
||||||
if hit is not None and hit[1].get(gid):
|
if hit is not None and hit[1].get(gid):
|
||||||
hit[1][gid] = 0
|
hit[1][gid] = _NO_NEW
|
||||||
|
|
||||||
|
|
||||||
class FavoriteOut(BaseModel):
|
class FavoriteOut(BaseModel):
|
||||||
|
|
@ -300,7 +308,9 @@ class FavoriteOut(BaseModel):
|
||||||
canonical_name: str
|
canonical_name: str
|
||||||
slug: str | None
|
slug: str | None
|
||||||
scene_count: int
|
scene_count: int
|
||||||
new_count: int # sceny od last_seen_at
|
new_count: int # sceny + filmy od last_seen_at (dla starszych wersji apki)
|
||||||
|
new_scene_count: int = 0
|
||||||
|
new_movie_count: int = 0
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
@ -334,19 +344,22 @@ def list_favorites(
|
||||||
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||||
new_counts = _cached_new_counts(device_id, kind="performer") if counts else {}
|
new_counts = _cached_new_counts(device_id, kind="performer") if counts else {}
|
||||||
|
rows = sorted(rows, key=lambda r: _favorites_order(new_counts, r[1].id, r[1].canonical_name))
|
||||||
|
|
||||||
items: list[FavoriteOut] = []
|
items: list[FavoriteOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
for fav, perf in rows:
|
for fav, perf in rows:
|
||||||
nc = new_counts.get(perf.id, 0)
|
ns, nm = new_counts.get(perf.id, _NO_NEW)
|
||||||
new_total += nc
|
new_total += ns + nm
|
||||||
items.append(
|
items.append(
|
||||||
FavoriteOut(
|
FavoriteOut(
|
||||||
performer_id=perf.id,
|
performer_id=perf.id,
|
||||||
canonical_name=perf.canonical_name,
|
canonical_name=perf.canonical_name,
|
||||||
slug=perf.slug,
|
slug=perf.slug,
|
||||||
scene_count=scene_counts.get(perf.id, 0),
|
scene_count=scene_counts.get(perf.id, 0),
|
||||||
new_count=nc,
|
new_count=ns + nm,
|
||||||
|
new_scene_count=ns,
|
||||||
|
new_movie_count=nm,
|
||||||
last_seen_at=fav.last_seen_at,
|
last_seen_at=fav.last_seen_at,
|
||||||
created_at=fav.created_at,
|
created_at=fav.created_at,
|
||||||
)
|
)
|
||||||
|
|
@ -427,6 +440,8 @@ class FavoriteStudioOut(BaseModel):
|
||||||
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
|
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
|
||||||
movie_count: int = 0
|
movie_count: int = 0
|
||||||
new_count: int
|
new_count: int
|
||||||
|
new_scene_count: int = 0
|
||||||
|
new_movie_count: int = 0
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
@ -499,12 +514,13 @@ def list_favorite_studios(
|
||||||
).all()
|
).all()
|
||||||
}
|
}
|
||||||
new_counts = _cached_new_counts(device_id, kind="studio")
|
new_counts = _cached_new_counts(device_id, kind="studio")
|
||||||
|
rows = sorted(rows, key=lambda r: _favorites_order(new_counts, r[1].id, r[1].name))
|
||||||
|
|
||||||
items: list[FavoriteStudioOut] = []
|
items: list[FavoriteStudioOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
for fav, st in rows:
|
for fav, st in rows:
|
||||||
nc = new_counts.get(st.id, 0)
|
ns, nm = new_counts.get(st.id, _NO_NEW)
|
||||||
new_total += nc
|
new_total += ns + nm
|
||||||
items.append(
|
items.append(
|
||||||
FavoriteStudioOut(
|
FavoriteStudioOut(
|
||||||
studio_id=st.id,
|
studio_id=st.id,
|
||||||
|
|
@ -513,7 +529,9 @@ def list_favorite_studios(
|
||||||
network=st.network,
|
network=st.network,
|
||||||
scene_count=scene_counts.get(st.id, 0),
|
scene_count=scene_counts.get(st.id, 0),
|
||||||
movie_count=movie_counts.get(st.id, 0),
|
movie_count=movie_counts.get(st.id, 0),
|
||||||
new_count=nc,
|
new_count=ns + nm,
|
||||||
|
new_scene_count=ns,
|
||||||
|
new_movie_count=nm,
|
||||||
last_seen_at=fav.last_seen_at,
|
last_seen_at=fav.last_seen_at,
|
||||||
created_at=fav.created_at,
|
created_at=fav.created_at,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,10 @@ EVENT_DELTAS: dict[str, tuple[float, float, float]] = {
|
||||||
"not_interested": (-4.0, -4.0, -3.0),
|
"not_interested": (-4.0, -4.0, -3.0),
|
||||||
}
|
}
|
||||||
REASON_PENALTY = -8.0
|
REASON_PENALTY = -8.0
|
||||||
|
#: `error`: odtwarzacz w apce sie wywrocil albo stal bez obrazu (reason 'stall'). Nie
|
||||||
|
#: rusza wag, a API od razu sprawdza plik i oznacza klip martwy (check_clips_now).
|
||||||
ALLOWED_KINDS = frozenset(
|
ALLOWED_KINDS = frozenset(
|
||||||
{"impression", "skip", "complete", "like", "unlike", "not_interested", "autoswipe", "open"}
|
{"impression", "skip", "complete", "like", "unlike", "not_interested", "autoswipe", "open", "error"}
|
||||||
)
|
)
|
||||||
#: Kolejność „siły" wyniku w `clip_seen`: nie cofamy do słabszego.
|
#: Kolejność „siły" wyniku w `clip_seen`: nie cofamy do słabszego.
|
||||||
OUTCOME_RANK = {"served": 0, "skip": 1, "complete": 2, "like": 3, "not_interested": 4}
|
OUTCOME_RANK = {"served": 0, "skip": 1, "complete": 2, "like": 3, "not_interested": 4}
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,11 @@ class Settings(BaseSettings):
|
||||||
sched_clips_maintenance_hours: int = Field(
|
sched_clips_maintenance_hours: int = Field(
|
||||||
default=24, validation_alias="GOON_SCHED_CLIPS_MAINTENANCE_HOURS"
|
default=24, validation_alias="GOON_SCHED_CLIPS_MAINTENANCE_HOURS"
|
||||||
)
|
)
|
||||||
|
# Osobny reaper martwych klipow co godzine. Sama konserwacja (2000/dobe) nie nadazala:
|
||||||
|
# 68 tys. nowych klipow tygodniowo, a ~3% zywych w feedzie dawalo juz 403 (czarne
|
||||||
|
# ekrany w Edge, 2026-10-02). 1000/h = pelny obieg katalogu w okolo tydzien.
|
||||||
|
sched_clips_reap_hours: int = Field(default=1, validation_alias="GOON_SCHED_CLIPS_REAP_HOURS")
|
||||||
|
clips_reap_batch: int = Field(default=1000, validation_alias="GOON_CLIPS_REAP_BATCH")
|
||||||
# Feed podaje tylko klipy do tej długości (RedGifs p90 = 60 s; dłuższe to nie „swipe").
|
# Feed podaje tylko klipy do tej długości (RedGifs p90 = 60 s; dłuższe to nie „swipe").
|
||||||
clips_feed_max_duration_sec: int = Field(default=120, validation_alias="GOON_CLIPS_FEED_MAX_DURATION_SEC")
|
clips_feed_max_duration_sec: int = Field(default=120, validation_alias="GOON_CLIPS_FEED_MAX_DURATION_SEC")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from rapidfuzz import fuzz
|
from rapidfuzz import fuzz
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.connectors.tpdb import TPDBConnector, _parse_movie
|
from app.connectors.tpdb import TPDBConnector, _parse_movie
|
||||||
|
|
@ -45,7 +45,11 @@ from app.resolve.studio_resolver import resolve_studio
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DASH = str.maketrans({"–": "-", "—": "-", "‑": "-"})
|
# Film bez trafienia w TPDB wraca do kolejki dopiero po tym czasie (TPDB dopisuje filmy,
|
||||||
|
# wiec kiedys warto sprawdzic ponownie, ale nie cztery razy dziennie).
|
||||||
|
_RECHECK_AFTER = timedelta(days=30)
|
||||||
|
|
||||||
|
_DASH =str.maketrans({"–": "-", "—": "-", "‑": "-"})
|
||||||
|
|
||||||
|
|
||||||
def _cand_year(raw: dict) -> int | None:
|
def _cand_year(raw: dict) -> int | None:
|
||||||
|
|
@ -282,7 +286,12 @@ def enrich_movie(
|
||||||
|
|
||||||
def _candidate_movies(session: Session, *, source_id: uuid.UUID, limit: int) -> list[Movie]:
|
def _candidate_movies(session: Session, *, source_id: uuid.UUID, limit: int) -> list[Movie]:
|
||||||
"""Filmy jeszcze nie wzbogacone TPDB, priorytet: mają żywy playback (są grywalne)
|
"""Filmy jeszcze nie wzbogacone TPDB, priorytet: mają żywy playback (są grywalne)
|
||||||
i brak obsady LUB brak studia (największa luka). Reszta później."""
|
i brak obsady LUB brak studia (największa luka). Reszta później.
|
||||||
|
|
||||||
|
Kolejność: najpierw nigdy nie sprawdzane (najnowsze pierwsze), potem te sprawdzone
|
||||||
|
najdawniej, ale nie wcześniej niż _RECHECK_AFTER od ostatniego razu. Bez tego każdy
|
||||||
|
przebieg brał te same 200 najnowszych bez trafienia, a starszych nie ruszał wcale
|
||||||
|
(migracja 0051)."""
|
||||||
has_tpdb = (
|
has_tpdb = (
|
||||||
select(MovieExternalRef.movie_id)
|
select(MovieExternalRef.movie_id)
|
||||||
.where(MovieExternalRef.source_id == source_id)
|
.where(MovieExternalRef.source_id == source_id)
|
||||||
|
|
@ -307,7 +316,11 @@ def _candidate_movies(session: Session, *, source_id: uuid.UUID, limit: int) ->
|
||||||
Movie.id.in_(has_playback),
|
Movie.id.in_(has_playback),
|
||||||
)
|
)
|
||||||
.where((perf_count == 0) | (Movie.studio_id.is_(None)))
|
.where((perf_count == 0) | (Movie.studio_id.is_(None)))
|
||||||
.order_by(Movie.created_at.desc())
|
.where(
|
||||||
|
Movie.tpdb_checked_at.is_(None)
|
||||||
|
| (Movie.tpdb_checked_at < datetime.now(UTC) - _RECHECK_AFTER)
|
||||||
|
)
|
||||||
|
.order_by(Movie.tpdb_checked_at.asc().nulls_first(), Movie.created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
return list(session.execute(stmt).scalars().all())
|
return list(session.execute(stmt).scalars().all())
|
||||||
|
|
@ -345,6 +358,12 @@ def run_tpdb_movie_enrich(
|
||||||
outcome = enrich_movie(
|
outcome = enrich_movie(
|
||||||
session, movie, connector=connector, source_id=source_id, min_title=min_title
|
session, movie, connector=connector, source_id=source_id, min_title=min_title
|
||||||
)
|
)
|
||||||
|
# Surowy UPDATE: nie rusza updated_at, a po merge skasowany film po prostu
|
||||||
|
# nie ma czego oznaczyc. Blad (wyjatek nizej) nie oznacza, wroci w nastepnym
|
||||||
|
# przebiegu, bo moze to byc chwilowa awaria TPDB.
|
||||||
|
session.execute(
|
||||||
|
text("UPDATE movies SET tpdb_checked_at = now() WHERE id = :id"), {"id": mid}
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
counters[outcome] = counters.get(outcome, 0) + 1
|
counters[outcome] = counters.get(outcome, 0) + 1
|
||||||
except Exception as e: # pragma: no cover - defensywnie, jeden film nie wywala batcha
|
except Exception as e: # pragma: no cover - defensywnie, jeden film nie wywala batcha
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ class Movie(UUIDPKMixin, TimestampMixin, Base):
|
||||||
poster_url: Mapped[str | None] = mapped_column(String(2048))
|
poster_url: Mapped[str | None] = mapped_column(String(2048))
|
||||||
backdrop_url: Mapped[str | None] = mapped_column(String(2048))
|
backdrop_url: Mapped[str | None] = mapped_column(String(2048))
|
||||||
rating: Mapped[float | None] = mapped_column(Float)
|
rating: Mapped[float | None] = mapped_column(Float)
|
||||||
|
# Ostatnie szukanie w TPDB (app/enrich/tpdb_movies.py), NULL = jeszcze nie. Kolejka
|
||||||
|
# wzbogacania idzie od niesprawdzonych, bez trafienia wraca po 30 dniach (migracja 0051).
|
||||||
|
tpdb_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
class MovieExternalRef(Base):
|
class MovieExternalRef(Base):
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,48 @@ _DEAD_CODES = {403, 404, 410}
|
||||||
_REAP_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
|
_REAP_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
|
||||||
|
|
||||||
|
|
||||||
def reap_dead(*, limit: int = 2000, apply: bool = True) -> dict[str, int]:
|
def _head_dead(http, url: str) -> str | None:
|
||||||
|
"""'http 403' itp., gdy plik klipu zniknal; None, gdy zyje albo nie wiadomo (blad sieci)."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = http.head(url)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
return f"http {r.status_code}" if r.status_code in _DEAD_CODES else None
|
||||||
|
|
||||||
|
|
||||||
|
def check_clips_now(clip_ids: list) -> int:
|
||||||
|
"""Natychmiastowe sprawdzenie klipow zgloszonych przez apke jako zepsute (zdarzenie
|
||||||
|
`error`). Ten sam test co reaper, tylko bez czekania na kolejke. Zwraca ile oznaczono."""
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
if not clip_ids:
|
||||||
|
return 0
|
||||||
|
with session_scope() as session:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Clip.id, Clip.hd_url, Clip.sd_url).where(Clip.id.in_(clip_ids), Clip.dead_at.is_(None))
|
||||||
|
).all()
|
||||||
|
marked = 0
|
||||||
|
with httpx.Client(headers=_REAP_UA, timeout=15, follow_redirects=True) as http:
|
||||||
|
for cid, hd, sd in rows:
|
||||||
|
url = hd or sd
|
||||||
|
reason = _head_dead(http, url) if url else None
|
||||||
|
if reason is None:
|
||||||
|
continue
|
||||||
|
with session_scope() as session:
|
||||||
|
session.execute(
|
||||||
|
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
|
||||||
|
{"r": f"{reason} (apka)", "id": cid},
|
||||||
|
)
|
||||||
|
marked += 1
|
||||||
|
if marked:
|
||||||
|
log.info("clips: %d zgloszonych z apki oznaczone jako martwe", marked)
|
||||||
|
return marked
|
||||||
|
|
||||||
|
|
||||||
|
def reap_dead(*, limit: int = 2000, apply: bool = True, sleep: float = 0.2) -> dict[str, int]:
|
||||||
"""Reaper martwych klipow: tworcy kasuja klipy, RedGifs oddaje wtedy 404/410 na pliku.
|
"""Reaper martwych klipow: tworcy kasuja klipy, RedGifs oddaje wtedy 404/410 na pliku.
|
||||||
|
|
||||||
HEAD na `hd_url` (albo `sd_url`) dla najdawniej sprawdzanych zywych klipow
|
HEAD na `hd_url` (albo `sd_url`) dla najdawniej sprawdzanych zywych klipow
|
||||||
|
|
@ -214,7 +255,7 @@ def reap_dead(*, limit: int = 2000, apply: bool = True) -> dict[str, int]:
|
||||||
if r.status_code in _DEAD_CODES:
|
if r.status_code in _DEAD_CODES:
|
||||||
dead += 1
|
dead += 1
|
||||||
dead_ids.append((cid, f"http {r.status_code}"))
|
dead_ids.append((cid, f"http {r.status_code}"))
|
||||||
_time.sleep(0.2)
|
_time.sleep(sleep)
|
||||||
if apply and rows:
|
if apply and rows:
|
||||||
dead_set = {d[0] for d in dead_ids}
|
dead_set = {d[0] for d in dead_ids}
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
|
|
|
||||||
|
|
@ -524,6 +524,16 @@ def _job_clips_latest(pages: int) -> None:
|
||||||
log.exception("[scheduler] clips-latest failed")
|
log.exception("[scheduler] clips-latest failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _job_clips_reap(batch: int) -> None:
|
||||||
|
"""Feed Swipe: HEAD na plikach najdawniej sprawdzanych klipow, 403/404/410 = martwy."""
|
||||||
|
from app.scheduler.clips_ingest import reap_dead
|
||||||
|
|
||||||
|
try:
|
||||||
|
_run_with_timeout(lambda: reap_dead(limit=batch, apply=True, sleep=0.1), label="clips-reap")
|
||||||
|
except Exception:
|
||||||
|
log.exception("[scheduler] clips-reap failed")
|
||||||
|
|
||||||
|
|
||||||
def _job_clips_maintenance() -> None:
|
def _job_clips_maintenance() -> None:
|
||||||
"""Feed Swipe: decay afiniczności, retencja zdarzeń, reaper martwych plików."""
|
"""Feed Swipe: decay afiniczności, retencja zdarzeń, reaper martwych plików."""
|
||||||
from app.scheduler.clips_ingest import run_clips_maintenance
|
from app.scheduler.clips_ingest import run_clips_maintenance
|
||||||
|
|
@ -637,6 +647,18 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
|
||||||
)
|
)
|
||||||
log.info("scheduler: clips-maintenance every %dh", cfg["clips_maintenance_hours"])
|
log.info("scheduler: clips-maintenance every %dh", cfg["clips_maintenance_hours"])
|
||||||
|
|
||||||
|
if cfg.get("clips_reap_hours"):
|
||||||
|
reap_batch = cfg.get("clips_reap_batch") or 1000
|
||||||
|
sched.add_job(
|
||||||
|
lambda: _job_clips_reap(reap_batch),
|
||||||
|
IntervalTrigger(hours=cfg["clips_reap_hours"], start_date=INTERVAL_ANCHOR),
|
||||||
|
id="clips_reap",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
log.info("scheduler: clips-reap every %dh (batch=%d)", cfg["clips_reap_hours"], reap_batch)
|
||||||
|
|
||||||
if cfg.get("bulk_dedup_hours"):
|
if cfg.get("bulk_dedup_hours"):
|
||||||
sched.add_job(
|
sched.add_job(
|
||||||
_job_bulk_dedup_performers,
|
_job_bulk_dedup_performers,
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,9 @@ def run_source_stats() -> dict:
|
||||||
FILTER (WHERE status='success' AND ttff_ms IS NOT NULL) AS p50_ttff
|
FILTER (WHERE status='success' AND ttff_ms IS NOT NULL) AS p50_ttff
|
||||||
FROM playback_events
|
FROM playback_events
|
||||||
WHERE created_at > :win
|
WHERE created_at > :win
|
||||||
|
-- cichy resolver w apce (np. DoodStream) pada, a WebView moze jeszcze
|
||||||
|
-- zagrac, wiec to nie jest porazka zrodla
|
||||||
|
AND error_kind IS DISTINCT FROM 'resolver'
|
||||||
GROUP BY origin
|
GROUP BY origin
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,8 @@ def run_forever() -> int:
|
||||||
"clips_hours": getattr(settings, "sched_clips_hours", 1) or None,
|
"clips_hours": getattr(settings, "sched_clips_hours", 1) or None,
|
||||||
"clips_latest_pages": getattr(settings, "clips_latest_pages", 5),
|
"clips_latest_pages": getattr(settings, "clips_latest_pages", 5),
|
||||||
"clips_maintenance_hours": getattr(settings, "sched_clips_maintenance_hours", 24) or None,
|
"clips_maintenance_hours": getattr(settings, "sched_clips_maintenance_hours", 24) or None,
|
||||||
|
"clips_reap_hours": getattr(settings, "sched_clips_reap_hours", 1) or None,
|
||||||
|
"clips_reap_batch": getattr(settings, "clips_reap_batch", 1000),
|
||||||
}
|
}
|
||||||
sched = build_scheduler(cfg)
|
sched = build_scheduler(cfg)
|
||||||
log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs()))
|
log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs()))
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,16 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-10-02a',
|
||||||
|
date: 'October 2026',
|
||||||
|
items: [
|
||||||
|
'Favorites: performers and studios with something new are listed first, and the badge says how many new scenes and how many new movies there are, instead of one number for both.',
|
||||||
|
'Edge: a clip its creator deleted no longer leaves you on a black screen. The app skips to the next one and the clip is taken out of the feed for everyone. On a creator page the grid loads smaller previews, so it fills in faster.',
|
||||||
|
'Movies on myvidplay, playmogo and doply: the app took every one of them for a deleted video, because the host page always carries a hidden "not found" block. It now tries the direct stream first and only falls back to the web page when that fails.',
|
||||||
|
'A certificate error from a host is no longer reported as a deleted video, and the app no longer gets stuck when the phone fails to save its device key.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-09-17b',
|
id: '2026-09-17b',
|
||||||
date: 'September 2026',
|
date: 'September 2026',
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,30 @@ interface Props {
|
||||||
onCreatorPress?: (creator: string) => void;
|
onCreatorPress?: (creator: string) => void;
|
||||||
/** Ref pagera dla wywolujacego (autoSwipe robi `setPage`). */
|
/** Ref pagera dla wywolujacego (autoSwipe robi `setPage`). */
|
||||||
pagerRef?: React.MutableRefObject<any>;
|
pagerRef?: React.MutableRefObject<any>;
|
||||||
|
/**
|
||||||
|
* Klip na stronie `index` sie nie odtworzy. `reason` to komunikat odtwarzacza albo
|
||||||
|
* 'stall' (po STALL_MS dalej bez obrazu). Przy bledzie wywolujacy przewija dalej, przy
|
||||||
|
* 'stall' tylko zglasza: wolne lacze to nie zepsuty klip.
|
||||||
|
*/
|
||||||
|
onError?: (index: number, reason: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tyle czekamy na pierwszy obraz, zanim zglosimy klip jako stojacy. */
|
||||||
|
const STALL_MS = 12000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plik klipu zniknal (twórca skasowal): ExoPlayer konczy sie kodem HTTP 403/404/410.
|
||||||
|
* Tylko wtedy przewijamy sami. Bledy sieci i TLS (np. SSLHandshakeException na
|
||||||
|
* emulatorze, blokada RedGifs w danym kraju) dotycza wszystkich klipow naraz, wiec
|
||||||
|
* samo przewijanie przelecialoby przez caly feed w kilka sekund.
|
||||||
|
*/
|
||||||
|
export function clipGone(reason: string): boolean {
|
||||||
|
return /response code: (403|404|410)\b/i.test(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Po tylu zepsutych klipach z rzedu przestajemy przewijac: to raczej siec niz klipy. */
|
||||||
|
export const MAX_AUTO_SKIPS = 3;
|
||||||
|
|
||||||
/** Adres wideo klipu (HD, a gdy go nie ma, SD). Klip bez zadnego z nich sie nie odtworzy. */
|
/** Adres wideo klipu (HD, a gdy go nie ma, SD). Klip bez zadnego z nich sie nie odtworzy. */
|
||||||
export function clipSource(item: ClipFeedItem): string | null {
|
export function clipSource(item: ClipFeedItem): string | null {
|
||||||
return item.clip.hd_url || item.clip.sd_url || null;
|
return item.clip.hd_url || item.clip.sd_url || null;
|
||||||
|
|
@ -69,6 +91,7 @@ export function ClipPager({
|
||||||
onNotInterested,
|
onNotInterested,
|
||||||
onCreatorPress,
|
onCreatorPress,
|
||||||
pagerRef,
|
pagerRef,
|
||||||
|
onError,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const localRef = React.useRef<any>(null);
|
const localRef = React.useRef<any>(null);
|
||||||
const ref = pagerRef ?? localRef;
|
const ref = pagerRef ?? localRef;
|
||||||
|
|
@ -122,6 +145,8 @@ export function ClipPager({
|
||||||
itemsRef.current = items;
|
itemsRef.current = items;
|
||||||
const soundRef = React.useRef(soundOn);
|
const soundRef = React.useRef(soundOn);
|
||||||
soundRef.current = soundOn;
|
soundRef.current = soundOn;
|
||||||
|
const errorRef = React.useRef(onError);
|
||||||
|
errorRef.current = onError;
|
||||||
const activeSrc = items[index] ? clipSource(items[index]) : null;
|
const activeSrc = items[index] ? clipSource(items[index]) : null;
|
||||||
const nextSrc = items[index + 1] ? clipSource(items[index + 1]) : null;
|
const nextSrc = items[index + 1] ? clipSource(items[index + 1]) : null;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -170,6 +195,24 @@ export function ClipPager({
|
||||||
const sub = active.addListener('playToEnd', () => {
|
const sub = active.addListener('playToEnd', () => {
|
||||||
loopsRef.current += 1;
|
loopsRef.current += 1;
|
||||||
});
|
});
|
||||||
|
// Czarny ekran (zgloszenie Jana 2026-10-02): skasowany klip daje 403 i na wideo, i na
|
||||||
|
// plakacie, a nic tego nie zglaszalo. Klip mogl sie wywrocic jeszcze w buforze
|
||||||
|
// (player standby), wtedy statusChange juz nie przyjdzie, stad sprawdzenie od razu.
|
||||||
|
// Kazdy rodzaj raz na wejscie na strone; po 'stall' blad moze jeszcze przyjsc.
|
||||||
|
const reported = new Set<string>();
|
||||||
|
const report = (reason: string) => {
|
||||||
|
const kind = reason === 'stall' ? 'stall' : 'error';
|
||||||
|
if (reported.has(kind) || reported.has('error')) return;
|
||||||
|
reported.add(kind);
|
||||||
|
errorRef.current?.(index, reason);
|
||||||
|
};
|
||||||
|
const subStatus = active.addListener('statusChange', (e) => {
|
||||||
|
if (e.status === 'error') report(e.error?.message || 'error');
|
||||||
|
});
|
||||||
|
if (active.status === 'error') report('error');
|
||||||
|
const stall = setTimeout(() => {
|
||||||
|
if (active.status !== 'readyToPlay' && active.status !== 'error') report('stall');
|
||||||
|
}, STALL_MS);
|
||||||
|
|
||||||
if (nextSrc) {
|
if (nextSrc) {
|
||||||
ensureSource(standby, nextSrc);
|
ensureSource(standby, nextSrc);
|
||||||
|
|
@ -180,7 +223,11 @@ export function ClipPager({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Haptics.selectionAsync().catch(() => {});
|
Haptics.selectionAsync().catch(() => {});
|
||||||
return () => sub.remove();
|
return () => {
|
||||||
|
sub.remove();
|
||||||
|
subStatus.remove();
|
||||||
|
clearTimeout(stall);
|
||||||
|
};
|
||||||
}, [index, activeSrc, nextSrc, ensureSource]);
|
}, [index, activeSrc, nextSrc, ensureSource]);
|
||||||
|
|
||||||
// Zmiana dzwieku bez zmiany strony.
|
// Zmiana dzwieku bez zmiany strony.
|
||||||
|
|
|
||||||
|
|
@ -174,13 +174,23 @@ async function _continueAfterFetch(
|
||||||
// pass_md5, a CDN oddaje jakiś plik, więc bez tej detekcji resolver uznaje usunięte
|
// pass_md5, a CDN oddaje jakiś plik, więc bez tej detekcji resolver uznaje usunięte
|
||||||
// wideo za sukces i user dostaje placeholder albo "Source error" z ExoPlayera.
|
// wideo za sukces i user dostaje placeholder albo "Source error" z ExoPlayera.
|
||||||
// `no_video_` to obrazek-zaślepka (i.doodcdn.io/img/no_video_3.svg), najpewniejszy marker.
|
// `no_video_` to obrazek-zaślepka (i.doodcdn.io/img/no_video_3.svg), najpewniejszy marker.
|
||||||
|
//
|
||||||
|
// 2026-10-02: szablon playera playmogo ma ten sam blok „Not Found" ZAWSZE, tylko ukryty
|
||||||
|
// klasa `d-none` (<div class="... d-none"><img src=".../no_video_3.svg"><h1>Not Found</h1>).
|
||||||
|
// Stare sprawdzanie samych slow uznawalo wiec KAZDY film za usuniety, resolver poddawal
|
||||||
|
// sie od razu i cala rodzina (myvidplay, playmogo, doply) szla do WebView z klikanym
|
||||||
|
// Cloudflare, choc pass_md5 jest w HTML, a adres gra bez zadnej weryfikacji (206
|
||||||
|
// video/mp4, sprawdzone z IP domowego). Naprawde usuniety film ma tytul „Video not
|
||||||
|
// found", blok bez `d-none` i nie ma pass_md5.
|
||||||
const _low = html.toLowerCase();
|
const _low = html.toLowerCase();
|
||||||
|
const notFoundHidden = /class="[^"]*\bd-none\b[^"]*"\s*>\s*<img[^>]*no_video_/i.test(html);
|
||||||
|
const titleNotFound = /<title>[^<]*not found/i.test(html);
|
||||||
if (
|
if (
|
||||||
_low.includes('video not found') ||
|
titleNotFound ||
|
||||||
_low.includes('is not found') ||
|
|
||||||
_low.includes('no_video_') ||
|
|
||||||
_low.includes('video has been deleted') ||
|
_low.includes('video has been deleted') ||
|
||||||
_low.includes('file was deleted')
|
_low.includes('file was deleted') ||
|
||||||
|
(!notFoundHidden &&
|
||||||
|
(_low.includes('video not found') || _low.includes('is not found') || _low.includes('no_video_')))
|
||||||
) {
|
) {
|
||||||
return { error: 'video_deleted' };
|
return { error: 'video_deleted' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View }
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import { ClipPager, clipSource } from '../components/swipe/ClipPager';
|
import { ClipPager, clipGone, clipSource } from '../components/swipe/ClipPager';
|
||||||
import { newSessionId } from '../lib/swipeTelemetry';
|
import { newSessionId } from '../lib/swipeTelemetry';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { fonts, theme } from '../theme';
|
import { fonts, theme } from '../theme';
|
||||||
|
|
@ -52,6 +52,7 @@ export function ClipCreatorScreen() {
|
||||||
const [soundOn, setSoundOn] = React.useState(false);
|
const [soundOn, setSoundOn] = React.useState(false);
|
||||||
const [liked, setLiked] = React.useState<Set<string>>(new Set());
|
const [liked, setLiked] = React.useState<Set<string>>(new Set());
|
||||||
const sessionId = React.useRef(newSessionId());
|
const sessionId = React.useRef(newSessionId());
|
||||||
|
const pagerRef = React.useRef<any>(null);
|
||||||
|
|
||||||
const q = useInfiniteQuery({
|
const q = useInfiniteQuery({
|
||||||
queryKey: ['clips-by-creator', creator],
|
queryKey: ['clips-by-creator', creator],
|
||||||
|
|
@ -164,6 +165,7 @@ export function ClipCreatorScreen() {
|
||||||
items={items}
|
items={items}
|
||||||
index={playing}
|
index={playing}
|
||||||
initialPage={playing}
|
initialPage={playing}
|
||||||
|
pagerRef={pagerRef}
|
||||||
onPageSelected={setPlaying}
|
onPageSelected={setPlaying}
|
||||||
soundOn={soundOn}
|
soundOn={soundOn}
|
||||||
onToggleSound={() => setSoundOn((v) => !v)}
|
onToggleSound={() => setSoundOn((v) => !v)}
|
||||||
|
|
@ -173,6 +175,16 @@ export function ClipCreatorScreen() {
|
||||||
sendEvent(it.clip.id, 'not_interested', `creator:${creator}`);
|
sendEvent(it.clip.id, 'not_interested', `creator:${creator}`);
|
||||||
setPlaying(null);
|
setPlaying(null);
|
||||||
}}
|
}}
|
||||||
|
onError={(i, reason) => {
|
||||||
|
const it = items[i];
|
||||||
|
if (!it) return;
|
||||||
|
client
|
||||||
|
.postClipEvents(sessionId.current, [
|
||||||
|
{ clip_id: it.clip.id, kind: 'error', reason: reason.slice(0, 150), slot: 'creator' },
|
||||||
|
])
|
||||||
|
.catch(() => {});
|
||||||
|
if (clipGone(reason) && i + 1 < items.length) setTimeout(() => pagerRef.current?.setPage(i + 1), 600);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Modal rysuje sie od samej gory ekranu, wiec bez insetu przycisk laduje pod
|
{/* Modal rysuje sie od samej gory ekranu, wiec bez insetu przycisk laduje pod
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// (zeruje licznik). Klucz: przekazujemy `seenSince` (last_seen_at SPRZED markSeen)
|
// (zeruje licznik). Klucz: przekazujemy `seenSince` (last_seen_at SPRZED markSeen)
|
||||||
// żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych
|
// żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych
|
||||||
// scenach.
|
// scenach.
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { formatCount } from '../lib/formatCount';
|
import { formatCount } from '../lib/formatCount';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
|
@ -85,7 +86,7 @@ export function FavoritesScreen() {
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
items: prev.items.map((f) =>
|
items: prev.items.map((f) =>
|
||||||
f.performer_id === id ? { ...f, new_count: 0 } : f,
|
f.performer_id === id ? { ...f, new_count: 0, new_scene_count: 0, new_movie_count: 0 } : f,
|
||||||
),
|
),
|
||||||
new_total: Math.max(
|
new_total: Math.max(
|
||||||
0,
|
0,
|
||||||
|
|
@ -103,7 +104,7 @@ export function FavoritesScreen() {
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
items: prev.items.map((f) =>
|
items: prev.items.map((f) =>
|
||||||
f.studio_id === id ? { ...f, new_count: 0 } : f,
|
f.studio_id === id ? { ...f, new_count: 0, new_scene_count: 0, new_movie_count: 0 } : f,
|
||||||
),
|
),
|
||||||
new_total: Math.max(
|
new_total: Math.max(
|
||||||
0,
|
0,
|
||||||
|
|
@ -337,6 +338,35 @@ function initials(name: string): string {
|
||||||
return (first + last).toUpperCase() || '?';
|
return (first + last).toUpperCase() || '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// „+N" osobno dla scen i filmow (zgloszenie 5a724cc8). Starszy backend nie podaje
|
||||||
|
// rozbicia, wtedy jeden znaczek jak dawniej. Kolejnosc listy (nowosci na gorze) robi backend.
|
||||||
|
function NewBadges({ fav }: { fav: FavoriteOut | FavoriteStudioOut }) {
|
||||||
|
if (fav.new_count <= 0) return <Text style={styles.chevron}>›</Text>;
|
||||||
|
if (fav.new_scene_count == null || fav.new_movie_count == null) {
|
||||||
|
return (
|
||||||
|
<View style={styles.newBadge}>
|
||||||
|
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={styles.newBadges}>
|
||||||
|
{fav.new_scene_count > 0 ? (
|
||||||
|
<View style={styles.newBadge} accessibilityLabel={`${fav.new_scene_count} new scenes`}>
|
||||||
|
<MaterialCommunityIcons name="play-circle-outline" size={13} color={theme.fg} />
|
||||||
|
<Text style={styles.newBadgeText}>+{fav.new_scene_count}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{fav.new_movie_count > 0 ? (
|
||||||
|
<View style={styles.newBadge} accessibilityLabel={`${fav.new_movie_count} new movies`}>
|
||||||
|
<MaterialCommunityIcons name="movie-open-outline" size={13} color={theme.fg} />
|
||||||
|
<Text style={styles.newBadgeText}>+{fav.new_movie_count}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PerformerRow({
|
function PerformerRow({
|
||||||
fav,
|
fav,
|
||||||
onPress,
|
onPress,
|
||||||
|
|
@ -364,13 +394,7 @@ function PerformerRow({
|
||||||
{formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
|
{formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{fav.new_count > 0 ? (
|
<NewBadges fav={fav} />
|
||||||
<View style={styles.newBadge}>
|
|
||||||
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.chevron}>›</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -456,13 +480,7 @@ function StudioRow({
|
||||||
{fav.network ? ` · ${fav.network}` : ''}
|
{fav.network ? ` · ${fav.network}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{fav.new_count > 0 ? (
|
<NewBadges fav={fav} />
|
||||||
<View style={styles.newBadge}>
|
|
||||||
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.chevron}>›</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -521,7 +539,11 @@ const styles = StyleSheet.create({
|
||||||
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
|
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
|
||||||
rowMuted: { color: theme.muted, fontSize: 13 },
|
rowMuted: { color: theme.muted, fontSize: 13 },
|
||||||
|
|
||||||
|
newBadges: { alignItems: 'flex-end', gap: 4 },
|
||||||
newBadge: {
|
newBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
backgroundColor: theme.accent,
|
backgroundColor: theme.accent,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
|
|
|
||||||
|
|
@ -1940,6 +1940,26 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
|
||||||
setResolveStatus('idle');
|
setResolveStatus('idle');
|
||||||
setResolveError(result.error || 'unknown');
|
setResolveError(result.error || 'unknown');
|
||||||
setSkipResolve(true);
|
setSkipResolve(true);
|
||||||
|
// Bez tego nie bylo wiadomo, czemu cichy resolver pada: ekran od razu przechodzi
|
||||||
|
// do WebView i blad znikal. myvidplay/playmogo przez miesiac szly do WebView, bo
|
||||||
|
// resolver bral ukryty szablon „Not Found" za usuniety film (2026-10-02).
|
||||||
|
// error_kind 'resolver' nie wchodzi do oceny zrodel (source_stats), bo WebView
|
||||||
|
// moze ten film jeszcze odtworzyc.
|
||||||
|
if (params.origin) {
|
||||||
|
let host = '';
|
||||||
|
try {
|
||||||
|
host = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
host = '';
|
||||||
|
}
|
||||||
|
client.reportPlaybackEvent({
|
||||||
|
origin: params.origin,
|
||||||
|
status: 'error',
|
||||||
|
scene_id: sceneId,
|
||||||
|
error_kind: 'resolver',
|
||||||
|
error_detail: `${host}: ${result.error || 'unknown'}`.slice(0, 512),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [url, refererHost, sceneId, durationSec, resolveAttempted, skipResolve, nav]);
|
}, [url, refererHost, sceneId, durationSec, resolveAttempted, skipResolve, nav]);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import React from 'react';
|
||||||
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
|
import { ClipPager, MAX_AUTO_SKIPS, clipGone, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
|
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
|
||||||
import { setBarHidden } from '../lib/bottomBar';
|
import { setBarHidden } from '../lib/bottomBar';
|
||||||
|
|
@ -113,11 +113,34 @@ export function SwipeScreen() {
|
||||||
return () => t.stop();
|
return () => t.stop();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ---- zepsuty klip: zglos i przewin dalej ---------------------------------------------
|
||||||
|
// Skasowany u zrodla klip dawal czarny ekran, a zejscie z niego liczylo sie jak zwykly
|
||||||
|
// „skip" i psulo wagi tagow. Teraz idzie jako 'error' (serwer od razu sprawdza plik
|
||||||
|
// i oznacza martwy), a zejscie z takiej strony nie trafia do telemetrii.
|
||||||
|
const brokenRef = React.useRef<Set<string>>(new Set());
|
||||||
|
const autoSkipsRef = React.useRef(0);
|
||||||
|
const onClipError = React.useCallback(
|
||||||
|
(position: number, reason: string) => {
|
||||||
|
const it = items[position];
|
||||||
|
if (!it) return;
|
||||||
|
telemetry.current?.push({ clip_id: it.clip.id, kind: 'error', reason: reason.slice(0, 150), slot: it.slot, position });
|
||||||
|
if (!clipGone(reason)) return;
|
||||||
|
brokenRef.current.add(it.clip.id);
|
||||||
|
if (autoSkipsRef.current >= MAX_AUTO_SKIPS) return;
|
||||||
|
autoSkipsRef.current += 1;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (position + 1 < items.length) pagerRef.current?.setPage(position + 1);
|
||||||
|
}, 600);
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
// ---- zejscie ze strony: jedno zdarzenie z dwell ------------------------------------
|
// ---- zejscie ze strony: jedno zdarzenie z dwell ------------------------------------
|
||||||
const onLeave = React.useCallback(
|
const onLeave = React.useCallback(
|
||||||
(prev: number, info: ClipLeaveInfo) => {
|
(prev: number, info: ClipLeaveInfo) => {
|
||||||
const left = items[prev];
|
const left = items[prev];
|
||||||
if (!left) return;
|
if (!left || brokenRef.current.has(left.clip.id)) return;
|
||||||
|
autoSkipsRef.current = 0; // normalny klip po drodze: licznik przewiniec od nowa
|
||||||
telemetry.current?.push({
|
telemetry.current?.push({
|
||||||
clip_id: left.clip.id,
|
clip_id: left.clip.id,
|
||||||
kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
|
kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
|
||||||
|
|
@ -248,6 +271,7 @@ export function SwipeScreen() {
|
||||||
onLike={likeItem}
|
onLike={likeItem}
|
||||||
onNotInterested={(it) => setSheetItem(it)}
|
onNotInterested={(it) => setSheetItem(it)}
|
||||||
onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
|
onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
|
||||||
|
onError={onClipError}
|
||||||
/>
|
/>
|
||||||
) : feed.isLoading ? (
|
) : feed.isLoading ? (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,9 @@ export interface FavoriteOut {
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
scene_count: number;
|
scene_count: number;
|
||||||
new_count: number;
|
new_count: number;
|
||||||
|
/** Rozbicie new_count na sceny i filmy (backend od 2026-10). Brak = starszy backend. */
|
||||||
|
new_scene_count?: number;
|
||||||
|
new_movie_count?: number;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +372,8 @@ export interface FavoriteStudioOut {
|
||||||
* setki filmów), a lista pokazywała wtedy samo „0 scenes". */
|
* setki filmów), a lista pokazywała wtedy samo „0 scenes". */
|
||||||
movie_count?: number;
|
movie_count?: number;
|
||||||
new_count: number;
|
new_count: number;
|
||||||
|
new_scene_count?: number;
|
||||||
|
new_movie_count?: number;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -486,7 +491,9 @@ export type ClipEventKind =
|
||||||
| 'unlike'
|
| 'unlike'
|
||||||
| 'not_interested'
|
| 'not_interested'
|
||||||
| 'autoswipe'
|
| 'autoswipe'
|
||||||
| 'open';
|
| 'open'
|
||||||
|
/** Odtwarzacz nie dal rady (np. 403 po skasowaniu klipu) albo stoi bez obrazu. */
|
||||||
|
| 'error';
|
||||||
|
|
||||||
export interface ClipEventIn {
|
export interface ClipEventIn {
|
||||||
clip_id: string;
|
clip_id: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue