Compare commits

..

No commits in common. "617dda0bbe3cfd4f7d0893169b3ab6fc49689587" and "cfe72195ed535c21ea4f355c9e13d92590e95f25" have entirely different histories.

19 changed files with 47 additions and 361 deletions

View file

@ -1,33 +0,0 @@
"""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")

View file

@ -17,7 +17,6 @@ bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do t
from __future__ import annotations
import logging
import threading
import time
import uuid
from datetime import UTC, datetime
@ -480,22 +479,9 @@ def clip_events(
if changed:
_save_affinity(session, device_id, row, aff, changed)
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)
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 ------------------------------------------------------------
_OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}

View file

@ -200,19 +200,11 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
"""gid -> (nowe sceny, nowe filmy). Apka od 2026-10 pokazuje je osobno (zgłoszenie
5a724cc8), starsze wersje dostają sumę w `new_count`."""
scenes = _new_counts(session, device_id, kind=kind)
movies = _new_movie_counts(session, device_id, kind=kind)
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())
"""Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści)."""
counts = _new_counts(session, device_id, kind=kind)
for gid, n in _new_movie_counts(session, device_id, kind=kind).items():
counts[gid] = counts.get(gid, 0) + n
return counts
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
@ -242,7 +234,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
for gid, at in _counts_seen.get(key, {}).items():
if at >= started:
counts[gid] = _NO_NEW
counts[gid] = 0
_counts_seen.pop(key, None)
_counts_cache[key] = (time.monotonic(), counts)
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
@ -300,7 +292,7 @@ def _mark_counts_seen(device_id: str, kind: str, gid: uuid.UUID) -> None:
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
hit = _counts_cache.get(key)
if hit is not None and hit[1].get(gid):
hit[1][gid] = _NO_NEW
hit[1][gid] = 0
class FavoriteOut(BaseModel):
@ -308,9 +300,7 @@ class FavoriteOut(BaseModel):
canonical_name: str
slug: str | None
scene_count: int
new_count: int # sceny + filmy od last_seen_at (dla starszych wersji apki)
new_scene_count: int = 0
new_movie_count: int = 0
new_count: int # sceny od last_seen_at
last_seen_at: datetime
created_at: datetime
@ -344,22 +334,19 @@ def list_favorites(
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
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] = []
new_total = 0
for fav, perf in rows:
ns, nm = new_counts.get(perf.id, _NO_NEW)
new_total += ns + nm
nc = new_counts.get(perf.id, 0)
new_total += nc
items.append(
FavoriteOut(
performer_id=perf.id,
canonical_name=perf.canonical_name,
slug=perf.slug,
scene_count=scene_counts.get(perf.id, 0),
new_count=ns + nm,
new_scene_count=ns,
new_movie_count=nm,
new_count=nc,
last_seen_at=fav.last_seen_at,
created_at=fav.created_at,
)
@ -440,8 +427,6 @@ class FavoriteStudioOut(BaseModel):
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
movie_count: int = 0
new_count: int
new_scene_count: int = 0
new_movie_count: int = 0
last_seen_at: datetime
created_at: datetime
@ -514,13 +499,12 @@ def list_favorite_studios(
).all()
}
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] = []
new_total = 0
for fav, st in rows:
ns, nm = new_counts.get(st.id, _NO_NEW)
new_total += ns + nm
nc = new_counts.get(st.id, 0)
new_total += nc
items.append(
FavoriteStudioOut(
studio_id=st.id,
@ -529,9 +513,7 @@ def list_favorite_studios(
network=st.network,
scene_count=scene_counts.get(st.id, 0),
movie_count=movie_counts.get(st.id, 0),
new_count=ns + nm,
new_scene_count=ns,
new_movie_count=nm,
new_count=nc,
last_seen_at=fav.last_seen_at,
created_at=fav.created_at,
)

View file

@ -49,10 +49,8 @@ EVENT_DELTAS: dict[str, tuple[float, float, float]] = {
"not_interested": (-4.0, -4.0, -3.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(
{"impression", "skip", "complete", "like", "unlike", "not_interested", "autoswipe", "open", "error"}
{"impression", "skip", "complete", "like", "unlike", "not_interested", "autoswipe", "open"}
)
#: 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}

View file

@ -245,11 +245,6 @@ class Settings(BaseSettings):
sched_clips_maintenance_hours: int = Field(
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").
clips_feed_max_duration_sec: int = Field(default=120, validation_alias="GOON_CLIPS_FEED_MAX_DURATION_SEC")

View file

@ -25,10 +25,10 @@ from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from rapidfuzz import fuzz
from sqlalchemy import func, select, text
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.connectors.tpdb import TPDBConnector, _parse_movie
@ -45,10 +45,6 @@ from app.resolve.studio_resolver import resolve_studio
log = logging.getLogger(__name__)
# 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({"–": "-", "—": "-", "‑": "-"})
@ -286,12 +282,7 @@ def enrich_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)
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)."""
i brak obsady LUB brak studia (największa luka). Reszta później."""
has_tpdb = (
select(MovieExternalRef.movie_id)
.where(MovieExternalRef.source_id == source_id)
@ -316,11 +307,7 @@ def _candidate_movies(session: Session, *, source_id: uuid.UUID, limit: int) ->
Movie.id.in_(has_playback),
)
.where((perf_count == 0) | (Movie.studio_id.is_(None)))
.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())
.order_by(Movie.created_at.desc())
.limit(limit)
)
return list(session.execute(stmt).scalars().all())
@ -358,12 +345,6 @@ def run_tpdb_movie_enrich(
outcome = enrich_movie(
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()
counters[outcome] = counters.get(outcome, 0) + 1
except Exception as e: # pragma: no cover - defensywnie, jeden film nie wywala batcha

View file

@ -46,9 +46,6 @@ class Movie(UUIDPKMixin, TimestampMixin, Base):
poster_url: Mapped[str | None] = mapped_column(String(2048))
backdrop_url: Mapped[str | None] = mapped_column(String(2048))
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):

View file

@ -177,48 +177,7 @@ _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"}
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]:
def reap_dead(*, limit: int = 2000, apply: bool = True) -> dict[str, int]:
"""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
@ -255,7 +214,7 @@ def reap_dead(*, limit: int = 2000, apply: bool = True, sleep: float = 0.2) -> d
if r.status_code in _DEAD_CODES:
dead += 1
dead_ids.append((cid, f"http {r.status_code}"))
_time.sleep(sleep)
_time.sleep(0.2)
if apply and rows:
dead_set = {d[0] for d in dead_ids}
with session_scope() as session:

View file

@ -524,16 +524,6 @@ def _job_clips_latest(pages: int) -> None:
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:
"""Feed Swipe: decay afiniczności, retencja zdarzeń, reaper martwych plików."""
from app.scheduler.clips_ingest import run_clips_maintenance
@ -647,18 +637,6 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
)
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"):
sched.add_job(
_job_bulk_dedup_performers,

View file

@ -215,9 +215,6 @@ def run_source_stats() -> dict:
FILTER (WHERE status='success' AND ttff_ms IS NOT NULL) AS p50_ttff
FROM playback_events
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
"""
),

View file

@ -258,8 +258,6 @@ def run_forever() -> int:
"clips_hours": getattr(settings, "sched_clips_hours", 1) or None,
"clips_latest_pages": getattr(settings, "clips_latest_pages", 5),
"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)
log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs()))

View file

@ -35,16 +35,6 @@ export type 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',
date: 'September 2026',

View file

@ -49,30 +49,8 @@ interface Props {
onCreatorPress?: (creator: string) => void;
/** Ref pagera dla wywolujacego (autoSwipe robi `setPage`). */
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. */
export function clipSource(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
@ -91,7 +69,6 @@ export function ClipPager({
onNotInterested,
onCreatorPress,
pagerRef,
onError,
}: Props) {
const localRef = React.useRef<any>(null);
const ref = pagerRef ?? localRef;
@ -145,8 +122,6 @@ export function ClipPager({
itemsRef.current = items;
const soundRef = React.useRef(soundOn);
soundRef.current = soundOn;
const errorRef = React.useRef(onError);
errorRef.current = onError;
const activeSrc = items[index] ? clipSource(items[index]) : null;
const nextSrc = items[index + 1] ? clipSource(items[index + 1]) : null;
React.useEffect(() => {
@ -195,24 +170,6 @@ export function ClipPager({
const sub = active.addListener('playToEnd', () => {
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) {
ensureSource(standby, nextSrc);
@ -223,11 +180,7 @@ export function ClipPager({
}
}
Haptics.selectionAsync().catch(() => {});
return () => {
sub.remove();
subStatus.remove();
clearTimeout(stall);
};
return () => sub.remove();
}, [index, activeSrc, nextSrc, ensureSource]);
// Zmiana dzwieku bez zmiany strony.

View file

@ -174,23 +174,13 @@ async function _continueAfterFetch(
// 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.
// `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 notFoundHidden = /class="[^"]*\bd-none\b[^"]*"\s*>\s*<img[^>]*no_video_/i.test(html);
const titleNotFound = /<title>[^<]*not found/i.test(html);
if (
titleNotFound ||
_low.includes('video not found') ||
_low.includes('is not found') ||
_low.includes('no_video_') ||
_low.includes('video has been deleted') ||
_low.includes('file was deleted') ||
(!notFoundHidden &&
(_low.includes('video not found') || _low.includes('is not found') || _low.includes('no_video_')))
_low.includes('file was deleted')
) {
return { error: 'video_deleted' };
}

View file

@ -22,7 +22,7 @@ import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View }
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useClient } from '../ClientContext';
import { ClipPager, clipGone, clipSource } from '../components/swipe/ClipPager';
import { ClipPager, clipSource } from '../components/swipe/ClipPager';
import { newSessionId } from '../lib/swipeTelemetry';
import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme';
@ -52,7 +52,6 @@ export function ClipCreatorScreen() {
const [soundOn, setSoundOn] = React.useState(false);
const [liked, setLiked] = React.useState<Set<string>>(new Set());
const sessionId = React.useRef(newSessionId());
const pagerRef = React.useRef<any>(null);
const q = useInfiniteQuery({
queryKey: ['clips-by-creator', creator],
@ -165,7 +164,6 @@ export function ClipCreatorScreen() {
items={items}
index={playing}
initialPage={playing}
pagerRef={pagerRef}
onPageSelected={setPlaying}
soundOn={soundOn}
onToggleSound={() => setSoundOn((v) => !v)}
@ -175,16 +173,6 @@ export function ClipCreatorScreen() {
sendEvent(it.clip.id, 'not_interested', `creator:${creator}`);
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}
{/* Modal rysuje sie od samej gory ekranu, wiec bez insetu przycisk laduje pod

View file

@ -3,7 +3,6 @@
// (zeruje licznik). Klucz: przekazujemy `seenSince` (last_seen_at SPRZED markSeen)
// żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych
// scenach.
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import { formatCount } from '../lib/formatCount';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
@ -86,7 +85,7 @@ export function FavoritesScreen() {
? {
...prev,
items: prev.items.map((f) =>
f.performer_id === id ? { ...f, new_count: 0, new_scene_count: 0, new_movie_count: 0 } : f,
f.performer_id === id ? { ...f, new_count: 0 } : f,
),
new_total: Math.max(
0,
@ -104,7 +103,7 @@ export function FavoritesScreen() {
? {
...prev,
items: prev.items.map((f) =>
f.studio_id === id ? { ...f, new_count: 0, new_scene_count: 0, new_movie_count: 0 } : f,
f.studio_id === id ? { ...f, new_count: 0 } : f,
),
new_total: Math.max(
0,
@ -338,35 +337,6 @@ function initials(name: string): string {
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({
fav,
onPress,
@ -394,7 +364,13 @@ function PerformerRow({
{formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
</Text>
</View>
<NewBadges fav={fav} />
{fav.new_count > 0 ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
</View>
) : (
<Text style={styles.chevron}>›</Text>
)}
</Pressable>
);
}
@ -480,7 +456,13 @@ function StudioRow({
{fav.network ? ` · ${fav.network}` : ''}
</Text>
</View>
<NewBadges fav={fav} />
{fav.new_count > 0 ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
</View>
) : (
<Text style={styles.chevron}>›</Text>
)}
</Pressable>
);
}
@ -539,11 +521,7 @@ const styles = StyleSheet.create({
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
rowMuted: { color: theme.muted, fontSize: 13 },
newBadges: { alignItems: 'flex-end', gap: 4 },
newBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
backgroundColor: theme.accent,
paddingHorizontal: 10,
paddingVertical: 4,

View file

@ -1940,26 +1940,6 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
setResolveStatus('idle');
setResolveError(result.error || 'unknown');
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]);

View file

@ -25,7 +25,7 @@ import React from 'react';
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useClient } from '../ClientContext';
import { ClipPager, MAX_AUTO_SKIPS, clipGone, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
import type { RootStackParamList } from '../navigation';
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
import { setBarHidden } from '../lib/bottomBar';
@ -113,34 +113,11 @@ export function SwipeScreen() {
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 ------------------------------------
const onLeave = React.useCallback(
(prev: number, info: ClipLeaveInfo) => {
const left = items[prev];
if (!left || brokenRef.current.has(left.clip.id)) return;
autoSkipsRef.current = 0; // normalny klip po drodze: licznik przewiniec od nowa
if (!left) return;
telemetry.current?.push({
clip_id: left.clip.id,
kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
@ -271,7 +248,6 @@ export function SwipeScreen() {
onLike={likeItem}
onNotInterested={(it) => setSheetItem(it)}
onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
onError={onClipError}
/>
) : feed.isLoading ? (
<View style={styles.center}>

View file

@ -344,9 +344,6 @@ export interface FavoriteOut {
slug: string | null;
scene_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;
created_at: string;
}
@ -372,8 +369,6 @@ export interface FavoriteStudioOut {
* setki filmów), a lista pokazywała wtedy samo „0 scenes". */
movie_count?: number;
new_count: number;
new_scene_count?: number;
new_movie_count?: number;
last_seen_at: string;
created_at: string;
}
@ -491,9 +486,7 @@ export type ClipEventKind =
| 'unlike'
| 'not_interested'
| 'autoswipe'
| 'open'
/** Odtwarzacz nie dal rady (np. 403 po skasowaniu klipu) albo stoi bez obrazu. */
| 'error';
| 'open';
export interface ClipEventIn {
clip_id: string;