diff --git a/alembic/versions/20260622_0025_source_ranking.py b/alembic/versions/20260622_0025_source_ranking.py new file mode 100644 index 0000000..e0016a7 --- /dev/null +++ b/alembic/versions/20260622_0025_source_ranking.py @@ -0,0 +1,66 @@ +"""source ranking: playback telemetry + per-origin source_stats + +Revision ID: 0025_source_ranking +Revises: 0024_saved_searches +Create Date: 2026-06-22 + +Ranking stron-źródeł na Sites screen (user request): ocena 0-5★ per origin wg +częstotliwości odświeżania, bogactwa metadanych i tego czy źródło realnie gra. +- playback_events: fire-and-forget telemetria odtwarzania z apki (sygnał health), +- source_stats: policzona offline ocena per origin (run_source_stats). +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0025_source_ranking" +down_revision: str | None = "0024_saved_searches" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "playback_events", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("origin", sa.String(length=64), nullable=False), + sa.Column("scene_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("error_kind", sa.String(length=64), nullable=True), + sa.Column("ttff_ms", sa.Integer(), nullable=True), + sa.Column("device_id", sa.String(length=64), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.PrimaryKeyConstraint("id", name="pk_playback_events"), + ) + op.create_index( + "ix_playback_events_origin_created", "playback_events", ["origin", "created_at"] + ) + + op.create_table( + "source_stats", + sa.Column("origin", sa.String(length=64), nullable=False), + sa.Column("stars", sa.SmallInteger(), nullable=False, server_default="0"), + sa.Column("freshness", sa.SmallInteger(), nullable=False, server_default="0"), + sa.Column("richness", sa.SmallInteger(), nullable=False, server_default="0"), + sa.Column("health", sa.SmallInteger(), nullable=True), + sa.Column("scenes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("new_7d", sa.Integer(), nullable=False, server_default="0"), + sa.Column("newest_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "components", postgresql.JSONB(), nullable=False, server_default="{}" + ), + sa.Column( + "computed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.PrimaryKeyConstraint("origin", name="pk_source_stats"), + ) + + +def downgrade() -> None: + op.drop_table("source_stats") + op.drop_index("ix_playback_events_origin_created", table_name="playback_events") + op.drop_table("playback_events") diff --git a/app/api/playback_events.py b/app/api/playback_events.py new file mode 100644 index 0000000..cdaf2a5 --- /dev/null +++ b/app/api/playback_events.py @@ -0,0 +1,49 @@ +"""Playback telemetry — POST /playback-events (fire-and-forget z apki). + +Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error). +Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d). +Best-effort: apka NIE czeka i ignoruje błąd — telemetria nie może psuć playbacku. +""" +from __future__ import annotations + +import uuid +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.api.device import get_device_id +from app.auth import require_api_key +from app.db import get_session +from app.models.playback_event import PlaybackEvent + +router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[Depends(require_api_key)]) + + +class PlaybackEventIn(BaseModel): + # 'tube:' — zgodne z playback_sources.origin. Bez tego ping bezużyteczny. + origin: str = Field(min_length=1, max_length=64) + status: Literal["success", "error"] + scene_id: uuid.UUID | None = None + error_kind: str | None = Field(default=None, max_length=64) + ttff_ms: int | None = Field(default=None, ge=0, le=600_000) + + +@router.post("", status_code=204) +def post_playback_event( + body: PlaybackEventIn, + session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], +) -> None: + session.add( + PlaybackEvent( + origin=body.origin, + scene_id=body.scene_id, + status=body.status, + error_kind=body.error_kind, + ttff_ms=body.ttff_ms if body.status == "success" else None, + device_id=device_id, + ) + ) + session.commit() diff --git a/app/api/sources.py b/app/api/sources.py index f25a817..4712399 100644 --- a/app/api/sources.py +++ b/app/api/sources.py @@ -26,12 +26,29 @@ from sqlalchemy.orm import Session from app.auth import require_api_key from app.db import get_session from app.models.playback_source import PlaybackSource +from app.models.source_stats import SourceStats log = logging.getLogger(__name__) router = APIRouter(prefix="/sources", tags=["sources"], dependencies=[Depends(require_api_key)]) +class SourceRating(BaseModel): + """Ocena źródła do rankingu na Sites screen. Pola = osie z user-requesta.""" + stars: int + """Ogólna ocena 0-5 (0 = offline). Główny sygnał do sortowania/wyświetlenia.""" + freshness: int + """0-5: jak często wpada nowy content (wiek najnowszej + wolumen 7d).""" + richness: int + """0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość).""" + health: int | None + """0-5: czy realnie gra (telemetria odtwarzania) — 0=offline. None gdy brak danych.""" + health_basis: str | None = None + """'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve).""" + components: dict | None = None + """Surowe składowe do rozkładu w UI (% per pole, success-rate, ttff).""" + + class SourceOut(BaseModel): origin: str """Raw origin string z DB — np. 'tube:hqpornercom'. Używany jako parametr @@ -54,6 +71,10 @@ class SourceOut(BaseModel): """MAX(last_seen_at) — najświeższy scrape dla tego origin. Pozwala mobile pokazać 'scrapowane Xh temu' i sortować świeżość.""" + rating: SourceRating | None = None + """Ocena 0-5★ (freshness/richness/health) z source_stats — None gdy jeszcze + nie policzona (job source-stats leci co kilka h).""" + class SourceListOut(BaseModel): items: list[SourceOut] @@ -110,12 +131,27 @@ def list_sources( .where(PlaybackSource.dead_at.is_(None)) .where(PlaybackSource.origin.like("tube:%")) .group_by(PlaybackSource.origin) - .order_by(func.count(PlaybackSource.id).desc()) ).all() + # Oceny z source_stats (policzone offline przez run_source_stats). Origin → row. + stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()} + items: list[SourceOut] = [] for origin, scene_count, last_scraped_at in rows: sitetag = origin.split(":", 1)[1] if origin.startswith("tube:") else origin + st = stats.get(origin) + rating = ( + SourceRating( + stars=st.stars, + freshness=st.freshness, + richness=st.richness, + health=st.health, + health_basis=(st.components or {}).get("health_basis"), + components=st.components, + ) + if st is not None + else None + ) items.append( SourceOut( origin=origin, @@ -123,7 +159,17 @@ def list_sources( display_name=_sitetag_to_display(sitetag), scene_count=scene_count, last_scraped_at=last_scraped_at, + rating=rating, ) ) + # Sort: najpierw ocena (stars desc, źródła bez oceny na końcu), potem rozmiar. + items.sort( + key=lambda it: ( + it.rating.stars if it.rating else -1, + it.scene_count, + ), + reverse=True, + ) + return SourceListOut(items=items, total=len(items)) diff --git a/app/config.py b/app/config.py index 524e453..b8afd91 100644 --- a/app/config.py +++ b/app/config.py @@ -159,6 +159,13 @@ class Settings(BaseSettings): default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS" ) + # Source ranking (Sites screen) — przelicz source_stats (freshness/richness/health + # per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po + # ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli). + sched_source_stats_hours: int = Field( + default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS" + ) + # Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB) — # używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare # 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN diff --git a/app/main.py b/app/main.py index 77b003f..6d67c41 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ from app.api.me import router as me_router from app.api.movies import router as movies_router from app.api.playback import movies_router as movies_playback_router from app.api.playback import router as playback_router +from app.api.playback_events import router as playback_events_router from app.api.saved_searches import router as saved_searches_router from app.api.scene_favorites import router as scene_favorites_router from app.api.scenes import router as scenes_router @@ -73,6 +74,7 @@ app.include_router(scenes_router) app.include_router(sources_router) app.include_router(movies_router) app.include_router(playback_router) +app.include_router(playback_events_router) app.include_router(movies_playback_router) app.include_router(scene_favorites_router) app.include_router(stream_proxy_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index d430569..fa3fe80 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -18,8 +18,10 @@ from app.models.movie import ( from app.models.movie_playback_source import MoviePlaybackSource from app.models.performer import Performer, PerformerAlias, PerformerExternalRef from app.models.play_progress import MoviePlayProgress, ScenePlayProgress +from app.models.playback_event import PlaybackEvent from app.models.playback_source import PlaybackSource from app.models.saved_search import SavedSearch +from app.models.source_stats import SourceStats from app.models.scene import ( Scene, SceneExternalRef, diff --git a/app/models/playback_event.py b/app/models/playback_event.py new file mode 100644 index 0000000..fd06a1a --- /dev/null +++ b/app/models/playback_event.py @@ -0,0 +1,45 @@ +"""Playback telemetry — fire-and-forget ping z apki po każdej próbie odtwarzania. + +Po co: ranking źródeł (Sites screen) potrzebuje realnego sygnału "czy to gra". +Świeżość i bogactwo metadanych liczymy z DB, ale "szybkość działania" znał tylko +telefon (resolve hosterów/WebView dzieje się phone-side). Bez tego sygnału źródło +może wyglądać świeżo i bogato, a serwować stub (problem hqfap/4k69). + +Append-only, agregowane przez `run_source_stats` (okno 7d → health score per origin). +Retencja: stats-job kasuje wpisy starsze niż 30 dni. Brak FK na scene (scene_id +informacyjne, scena może zniknąć przez dedup/merge). +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class PlaybackEvent(Base): + __tablename__ = "playback_events" + __table_args__ = ( + Index("ix_playback_events_origin_created", "origin", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + # 'tube:' — zgodne z playback_sources.origin (join po origin). + origin: Mapped[str] = mapped_column(String(64), nullable=False) + scene_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + # 'success' = wideo realnie ruszyło; 'error' = resolve/player padł. + status: Mapped[str] = mapped_column(String(16), nullable=False) + # np. 'no_source', 'resolve_failed', 'player_error', 'timeout' (tylko gdy error). + error_kind: Mapped[str | None] = mapped_column(String(64), nullable=True) + # time-to-first-frame [ms] — "szybkość" (tylko przy success; None gdy nie zmierzono). + ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/source_stats.py b/app/models/source_stats.py new file mode 100644 index 0000000..5103d77 --- /dev/null +++ b/app/models/source_stats.py @@ -0,0 +1,40 @@ +"""Source ranking — policzona ocena 0-5★ per origin (tube źródło) dla Sites screen. + +User request: ranking stron-źródeł wg (1) częstotliwości odświeżania, (2) bogactwa +metadanych (miniaturka/tagi/desc/aktorzy), (3) szybkości działania (czy realnie gra). +0★ = offline. Liczone okresowo przez `run_source_stats` (scheduler), bo richness to +agregat po ~2M live playback_sources × scenach — za drogie na request /sources. + +Jeden wiersz per origin. `components` (JSONB) trzyma surowe składowe do rozkładu w UI +(% thumb/tag/perf/desc/studio/dur, telemetria health, basis). Top-level kolumny to +gotowe gwiazdki, żeby /sources tylko czytał i sortował. +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, SmallInteger, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class SourceStats(Base): + __tablename__ = "source_stats" + + origin: Mapped[str] = mapped_column(String(64), primary_key=True) + # Ocena ogólna 0-5 (0 = offline). Gate: health==0 → stars 0. + stars: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) + freshness: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) + richness: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) + # None gdy brak jakiegokolwiek sygnału (nie powinno się zdarzyć — proxy zawsze daje). + health: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) + scenes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + new_7d: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + newest_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Surowe składowe do rozkładu w UI + diagnostyki. Patrz run_source_stats. + components: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + computed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py index 3033aa5..a8458e3 100644 --- a/app/scheduler/jobs.py +++ b/app/scheduler/jobs.py @@ -322,6 +322,17 @@ def _job_hetzner_monitor() -> None: log.exception("[scheduler] hetzner-monitor failed") +def _job_source_stats() -> None: + """Przelicz source_stats (ranking źródeł na Sites screen) — freshness/richness z DB + + health z telemetrii odtwarzania (playback_events) lub proxy. Ciężki agregat.""" + try: + from app.scheduler.source_stats import run_source_stats + + _run_with_timeout(run_source_stats, label="source-stats") + except Exception: + log.exception("[scheduler] source-stats failed") + + def _job_performer_continuous(refresh_after_days: int) -> None: """Continuous worker — 1 performer per tick, ORDER BY last_searched_at NULLS FIRST. @@ -464,6 +475,17 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler: ) log.info("scheduler: hetzner-monitor every %dh", cfg["hetzner_monitor_hours"]) + if cfg.get("source_stats_hours"): + sched.add_job( + _job_source_stats, + IntervalTrigger(hours=cfg["source_stats_hours"], start_date=INTERVAL_ANCHOR), + id="source_stats", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + log.info("scheduler: source-stats every %dh", cfg["source_stats_hours"]) + if cfg.get("movie_ingest_hours"): sched.add_job( _job_movie_ingest, diff --git a/app/scheduler/source_stats.py b/app/scheduler/source_stats.py new file mode 100644 index 0000000..8951eee --- /dev/null +++ b/app/scheduler/source_stats.py @@ -0,0 +1,274 @@ +"""run_source_stats — policz ocenę 0-5★ per origin (tube źródło) do source_stats. + +Trzy osie (user request): + 1. freshness — częstotliwość odświeżania (wiek najnowszej sceny + wolumen 7d), + 2. richness — bogactwo metadanych (% scen z thumb/tag/perf/desc/studio/dur), + 3. health — czy realnie gra. Z TELEMETRII (playback_events 7d: success rate + + ttff) gdy jest dość prób; inaczej PROXY z typu ekstraktora + (natywny mp4 > hoster/WebView) — z adnotacją basis. + +stars = ważona średnia osi, ale health==0 (offline) GATE'uje stars do 0 — źródło +świeże i bogate, które nie gra, ma być na dnie (to cały sens, casus hqfap/4k69). + +Liczone w workerze (scheduler), bo richness to agregat po ~2M live playback_sources. +Retencja telemetrii: kasuje playback_events starsze niż _EVENT_RETENTION_DAYS. +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta, timezone + +from sqlalchemy import text + +from app.db import session_scope + +log = logging.getLogger(__name__) + +_TELEMETRY_WINDOW_DAYS = 7 +_TELEMETRY_MIN_ATTEMPTS = 10 # poniżej tego telemetria niemiarodajna → proxy +_EVENT_RETENTION_DAYS = 30 +# Richness: wagi składowych (suma=1.0). thumb to minimum higieny, canonical-bogactwo +# (desc/studio/tag/perf) waży więcej. dur średnio. +_RICHNESS_WEIGHTS = { + "thumb": 0.12, + "tag": 0.20, + "perf": 0.20, + "desc": 0.16, + "studio": 0.16, + "dur": 0.16, +} + + +def _health_proxy_for(sitetag: str) -> tuple[int, str]: + """Proxy health gdy brak telemetrii: z mechanizmu resolve. Nie może dać 5★ — + bez realnego sygnału nie wiemy że gra, max 4. Zwraca (score, mechanizm).""" + try: + from app.extractors import _REGISTRY # type: ignore + + fn = _REGISTRY.get(sitetag) + except Exception: + fn = None + if fn is None: + return 2, "unknown" # nie ma ekstraktora → niepewna grywalność + mod = getattr(fn, "__module__", "") or "" + if mod.endswith("_vps_blocked_fallback"): + return 3, "webview" # gra w WebView (residential IP), ale wolniej/ciężej + if mod.endswith("_embed_iframe"): + return 3, "hoster" # phone-side resolve hostera (dood/luluvid) — średnio + if mod.endswith("_ytdlp"): + return 4, "ytdlp" # server-side, zwykle szybki/pewny + return 4, "native" # dedykowany natywny ekstraktor (direct mp4/HLS) + + +def _freshness_score(newest_at: datetime | None, new_7d: int, scenes: int) -> int: + if not scenes or newest_at is None: + return 0 + now = datetime.now(timezone.utc) + age_days = (now - newest_at).total_seconds() / 86400.0 + if age_days <= 2: + base = 5 + elif age_days <= 4: + base = 4 + elif age_days <= 10: + base = 3 + elif age_days <= 30: + base = 2 + else: + base = 1 + # Zamrożone źródło (świeżość tylko z dawnego importu) — brak nowych w 7d ścina do 1. + if new_7d == 0 and age_days > 4: + base = 1 + return base + + +def _richness_score(pcts: dict[str, float]) -> int: + weighted = sum(pcts.get(k, 0.0) * w for k, w in _RICHNESS_WEIGHTS.items()) + stars = round(weighted / 20.0) # 0..100% → 0..5 + return max(1, min(5, stars)) + + +def _health_score_from_telemetry(attempts: int, successes: int, p50_ttff_ms: int | None) -> int: + if attempts <= 0: + return 0 + rate = 100.0 * successes / attempts + if rate >= 90: + s = 5 + elif rate >= 75: + s = 4 + elif rate >= 50: + s = 3 + elif rate >= 25: + s = 2 + elif rate > 0: + s = 1 + else: + return 0 # ~0% sukcesów = offline + # Składowa "szybkość": wolny start (>8s mediana) ścina o 1 (nie poniżej 1). + if p50_ttff_ms is not None and p50_ttff_ms > 8000 and s > 1: + s -= 1 + return s + + +def _overall_stars(freshness: int, richness: int, health: int) -> int: + if health == 0: + return 0 # offline gate — nieważne jak świeże/bogate + raw = 0.40 * freshness + 0.30 * richness + 0.30 * health + return max(1, min(5, round(raw))) + + +def run_source_stats() -> dict: + """Przelicz source_stats dla wszystkich live tube origins. Zwraca podsumowanie.""" + now = datetime.now(timezone.utc) + win_start = now - timedelta(days=_TELEMETRY_WINDOW_DAYS) + + with session_scope() as session: + # 1) Freshness + richness: agregat po DISTINCT (origin, scene) z live sources, + # join do flag scen (has_tag/perf/desc/studio/dur policzone raz). + rows = session.execute( + text( + """ + WITH scene_flags AS ( + SELECT s.id, + (s.description IS NOT NULL AND length(btrim(s.description))>0) AS has_desc, + (s.studio_id IS NOT NULL) AS has_studio, + (s.duration_sec IS NOT NULL) AS has_dur, + EXISTS(SELECT 1 FROM scene_tags st WHERE st.scene_id=s.id) AS has_tag, + EXISTS(SELECT 1 FROM scene_performers sp WHERE sp.scene_id=s.id) AS has_perf + FROM scenes s + ), + live AS ( + SELECT DISTINCT ON (origin, scene_id) origin, scene_id, + (thumbnail_url IS NOT NULL) AS has_thumb, created_at + FROM playback_sources + WHERE dead_at IS NULL AND origin LIKE 'tube:%' + ) + SELECT l.origin, + count(*) AS scenes, + count(*) FILTER (WHERE l.created_at > :win) AS new_7d, + max(l.created_at) AS newest_at, + 100.0*avg(l.has_thumb::int) AS thumb, + 100.0*avg(f.has_tag::int) AS tag, + 100.0*avg(f.has_perf::int) AS perf, + 100.0*avg(f.has_desc::int) AS descr, + 100.0*avg(f.has_studio::int) AS studio, + 100.0*avg(f.has_dur::int) AS dur + FROM live l JOIN scene_flags f ON f.id = l.scene_id + GROUP BY l.origin + """ + ), + {"win": win_start}, + ).all() + + # 2) Telemetria health per origin (okno 7d). + tele = { + r.origin: r + for r in session.execute( + text( + """ + SELECT origin, + count(*) AS attempts, + count(*) FILTER (WHERE status='success') AS successes, + percentile_disc(0.5) WITHIN GROUP (ORDER BY ttff_ms) + FILTER (WHERE status='success' AND ttff_ms IS NOT NULL) AS p50_ttff + FROM playback_events + WHERE created_at > :win + GROUP BY origin + """ + ), + {"win": win_start}, + ).all() + } + + computed = 0 + for r in rows: + origin = r.origin + sitetag = origin.split(":", 1)[1] if ":" in origin else origin + pcts = { + "thumb": float(r.thumb or 0), + "tag": float(r.tag or 0), + "perf": float(r.perf or 0), + "desc": float(r.descr or 0), + "studio": float(r.studio or 0), + "dur": float(r.dur or 0), + } + freshness = _freshness_score(r.newest_at, int(r.new_7d), int(r.scenes)) + richness = _richness_score(pcts) + + t = tele.get(origin) + if t and int(t.attempts) >= _TELEMETRY_MIN_ATTEMPTS: + health = _health_score_from_telemetry( + int(t.attempts), int(t.successes), + int(t.p50_ttff) if t.p50_ttff is not None else None, + ) + health_basis = "telemetry" + pb_attempts = int(t.attempts) + pb_success_rate = round(100.0 * int(t.successes) / int(t.attempts)) + p50_ttff = int(t.p50_ttff) if t.p50_ttff is not None else None + mechanism = None + else: + health, mechanism = _health_proxy_for(sitetag) + health_basis = "proxy" + pb_attempts = int(t.attempts) if t else 0 + pb_success_rate = ( + round(100.0 * int(t.successes) / int(t.attempts)) if t and int(t.attempts) else None + ) + p50_ttff = None + + stars = _overall_stars(freshness, richness, health) + components = { + "pct": {k: round(v) for k, v in pcts.items()}, + "health_basis": health_basis, + "mechanism": mechanism, + "pb_attempts_7d": pb_attempts, + "pb_success_rate": pb_success_rate, + "p50_ttff_ms": p50_ttff, + } + + session.execute( + text( + """ + INSERT INTO source_stats + (origin, stars, freshness, richness, health, scenes, new_7d, + newest_at, components, computed_at) + VALUES + (:origin, :stars, :freshness, :richness, :health, :scenes, :new_7d, + :newest_at, CAST(:components AS jsonb), now()) + ON CONFLICT (origin) DO UPDATE SET + stars=EXCLUDED.stars, freshness=EXCLUDED.freshness, + richness=EXCLUDED.richness, health=EXCLUDED.health, + scenes=EXCLUDED.scenes, new_7d=EXCLUDED.new_7d, + newest_at=EXCLUDED.newest_at, components=EXCLUDED.components, + computed_at=now() + """ + ), + { + "origin": origin, + "stars": stars, + "freshness": freshness, + "richness": richness, + "health": health, + "scenes": int(r.scenes), + "new_7d": int(r.new_7d), + "newest_at": r.newest_at, + "components": json.dumps(components), + }, + ) + computed += 1 + + # 3) Sprzątanie: usuń source_stats dla origins bez już-live sources (np. hqfap/4k69 + # po ukryciu) + retencja telemetrii. + live_origins = {r.origin for r in rows} + stale = session.execute(text("SELECT origin FROM source_stats")).scalars().all() + for o in stale: + if o not in live_origins: + session.execute( + text("DELETE FROM source_stats WHERE origin=:o"), {"o": o} + ) + session.execute( + text("DELETE FROM playback_events WHERE created_at < :cut"), + {"cut": now - timedelta(days=_EVENT_RETENTION_DAYS)}, + ) + + log.info("[source-stats] computed %d origins", computed) + return {"computed": computed} diff --git a/app/scheduler/worker.py b/app/scheduler/worker.py index a88b485..f25bf93 100644 --- a/app/scheduler/worker.py +++ b/app/scheduler/worker.py @@ -226,6 +226,9 @@ def run_forever() -> int: # Hetzner Cloud bandwidth monitor — alert do Sentry przy progach % included. # No-op gdy brak HETZNER_API_TOKEN/SERVER_ID (sam job może być on). "hetzner_monitor_hours": getattr(settings, "sched_hetzner_monitor_hours", 6) or None, + # Source ranking — przelicz source_stats (freshness/richness/health per origin) + # dla Sites screen. Ciężki agregat → co 6h. + "source_stats_hours": getattr(settings, "sched_source_stats_hours", 6) or None, } sched = build_scheduler(cfg) log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs())) diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 7ddb0ad..f0a4580 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -288,6 +288,25 @@ export class GoonClient { return this.request('/sources'); } + /** Telemetria odtwarzania (fire-and-forget) — zasila health/szybkość w rankingu + * źródeł. Best-effort: błąd jest połykany, nie może psuć playbacku. */ + async reportPlaybackEvent(ev: { + origin: string; + status: 'success' | 'error'; + scene_id?: string; + error_kind?: string; + ttff_ms?: number; + }): Promise { + try { + await this.request('/playback-events', { + method: 'POST', + body: JSON.stringify(ev), + }); + } catch { + // świadomie ignorujemy — telemetria nie jest krytyczna + } + } + async listStudios(params: { q?: string; order?: 'name' | 'scene_count'; diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index adb1224..87ccaa3 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-06-22', + date: 'June 2026', + items: [ + 'Sites now show a 0-5★ rating — how fresh, how well-described, and how reliably each source plays. Tap the stars for the breakdown.', + 'Dropped two sources whose videos stopped playing (they served a "server down" clip).', + 'latestpornvideo now keeps up with its newest videos.', + ], + }, { id: '2026-06-21b', date: 'June 2026', diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index 2fb531f..6e16415 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -55,6 +55,9 @@ export type RootStackParamList = { Player: { url: string; sceneId: string; + // 'tube:' źródła — telemetria odtwarzania zasilająca ranking źródeł. + // Opcjonalne; brak → telemetria pomijana (canonical/non-tube). + origin?: string; // 'movie' = MovieDetail wywołał Player z movieId zamiast sceneId. Backend // ma /movies/{id}/progress oddzielnie od /scenes/{id}/progress (2026-05-28). // Default 'scene' dla back-compat z istniejącymi nav callami. diff --git a/mobile/src/screens/MovieDetailScreen.tsx b/mobile/src/screens/MovieDetailScreen.tsx index 02eecd7..14c6a47 100644 --- a/mobile/src/screens/MovieDetailScreen.tsx +++ b/mobile/src/screens/MovieDetailScreen.tsx @@ -198,6 +198,7 @@ function WatchChip({ const pIsDirect = !!pDirect && pDirect !== p.stream_url; navigation.navigate('Player', { url: pDirect || p.stream_url || p.embed_url || pb.page_url, + origin: pb.origin, sceneId: movieId, playbackId: pb.id, entityKind: 'movie', @@ -224,6 +225,7 @@ function WatchChip({ if (best && (best.direct_url || best.stream_url)) { navigation.navigate('Player', { url: best.direct_url || best.stream_url!, + origin: pb.origin, sceneId: movieId, playbackId: pb.id, entityKind: 'movie', @@ -274,6 +276,7 @@ function WatchChip({ const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url; navigation.navigate('Player', { url: target, + origin: pb.origin, // sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny), // ale dla entityKind='movie' Player rzutuje to do /movies/{id}/progress. // Bug-report b207ff17 2026-05-26 ("oznaczenie obejrzanych filmów") — backend diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index bfcd315..c0003dd 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -27,6 +27,9 @@ import { theme } from '../theme'; interface RouteParams { url: string; sceneId: string; + // 'tube:' źródła — do telemetrii odtwarzania (ranking źródeł). Opcjonalne; + // brak → telemetria pomijana (np. canonical/paradisehill bez tube-origin). + origin?: string; // 'scene' (default — back-compat z istniejącymi nav callami) lub 'movie'. // Player dispatcheruje upsertProgress vs upsertMovieProgress. Wcześniej // MovieDetail przekazywał movieId jako sceneId — backend /scenes// @@ -137,7 +140,7 @@ export function PlayerScreen() { function NativeVideoPlayer({ params }: { params: RouteParams }) { const client = useClient(); const nav = useNavigation>(); - const { url, sceneId, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; + const { url, sceneId, origin: playOrigin, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params); // 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress. const upsertProgress = React.useCallback( @@ -241,6 +244,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { nav.replace('Player', { url: fallbackProxyUrl, sceneId, + origin: playOrigin, playbackId: params.playbackId, entityKind, durationSec, @@ -259,6 +263,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { nav.replace('Player', { url: fallbackEmbedUrl, sceneId, + origin: playOrigin, playbackId: params.playbackId, entityKind, durationSec, @@ -269,6 +274,41 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { } }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError]); + // Telemetria odtwarzania (ranking źródeł). Tylko native-player path (WebView mode + // ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping + // per mount. SUCCESS = pierwszy readyToPlay (z ttff). ERROR = terminal native error + // bez pozostałych fallbacków i bez wcześniejszego zagrania (nie penalizujemy źródeł + // które jeszcze się ratują proxy/WebView). + const playTelemetryMountRef = React.useRef(null); + if (playTelemetryMountRef.current === null) playTelemetryMountRef.current = Date.now(); + const playTelemetrySentRef = React.useRef(false); + React.useEffect(() => { + if (playTelemetrySentRef.current || !playOrigin) return; + if (status === 'readyToPlay') { + playTelemetrySentRef.current = true; + client.reportPlaybackEvent({ + origin: playOrigin, + status: 'success', + scene_id: sceneId, + ttff_ms: Date.now() - (playTelemetryMountRef.current ?? Date.now()), + }); + } + }, [status, playOrigin, client, sceneId]); + React.useEffect(() => { + if (playTelemetrySentRef.current || !playOrigin) return; + if (status !== 'error' || loadedOnceRef.current) return; + const proxyPending = !!fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl; + const webviewPending = !!fallbackEmbedUrl && !didFallbackWebViewRef.current; + if (proxyPending || webviewPending) return; // jeszcze jest fallback do spróbowania + playTelemetrySentRef.current = true; + client.reportPlaybackEvent({ + origin: playOrigin, + status: 'error', + scene_id: sceneId, + error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error', + }); + }, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]); + const lastReportedRef = React.useRef(0); // Lokalny tick co 500ms — driver dla custom scrubber + time labels. expo-video // ma `timeUpdate` event ale firuje z mniejszą częstotliwością niż chcemy dla UI. diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index da34bfb..6b4771f 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -551,6 +551,7 @@ function PlaybackButton({ nav.navigate('Player', { url: initialUrl, sceneId, + origin: source.origin, playbackId: source.id, durationSec: sceneDurationSec, refererHost, @@ -668,6 +669,7 @@ function PlaybackButton({ nav.navigate('Player', { url: target, sceneId, + origin: source.origin, playbackId: source.id, durationSec: sceneDurationSec, refererHost, @@ -744,6 +746,7 @@ function PlaybackButton({ nav.navigate('Player', { url: link.embed_url, sceneId, + origin: source.origin, playbackId: source.id, durationSec: sceneDurationSec, refererHost, diff --git a/mobile/src/screens/SitesScreen.tsx b/mobile/src/screens/SitesScreen.tsx index fafa18d..672bf46 100644 --- a/mobile/src/screens/SitesScreen.tsx +++ b/mobile/src/screens/SitesScreen.tsx @@ -11,7 +11,9 @@ import React, { useMemo, useState } from 'react'; import { ActivityIndicator, FlatList, + Modal, Pressable, + ScrollView, StyleSheet, Text, TextInput, @@ -20,7 +22,7 @@ import { import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; -import type { SourceOut } from '../types'; +import type { SourceOut, SourceRating } from '../types'; type Order = 'popular' | 'recent'; @@ -32,6 +34,8 @@ export function SitesScreen() { const [debouncedQ, setDebouncedQ] = useState(''); const [order, setOrder] = useState('popular'); const [searchFocused, setSearchFocused] = useState(false); + // Źródło którego rozkład oceny pokazujemy w modalu (tap w gwiazdki). + const [detail, setDetail] = useState(null); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 250); @@ -73,7 +77,7 @@ export function SitesScreen() { Sites {items.length} - tap a tube → newest scenes from that site + tap a tube → newest scenes · tap the ★ → rating breakdown setDetail(item)} /> )} refreshing={isRefetching} @@ -127,6 +132,8 @@ export function SitesScreen() { } contentContainerStyle={{ paddingBottom: 24 }} /> + + setDetail(null)} /> ); } @@ -171,26 +178,130 @@ function formatRelativeTime(iso: string | null): string | null { return null; } -function SiteChip({ source, onPress }: { source: SourceOut; onPress: () => void }) { +// Rząd gwiazdek 0-5. 0 = offline (czerwony label zamiast gwiazdek). Brak oceny → null. +function Stars({ value, size = 13 }: { value: number | null | undefined; size?: number }) { + if (value == null) return — not rated; + if (value <= 0) return ● OFFLINE; + const full = '★'.repeat(value); + const empty = '☆'.repeat(5 - value); + return ( + + {full} + {empty} + + ); +} + +function SiteChip({ + source, + onPress, + onShowRating, +}: { + source: SourceOut; + onPress: () => void; + onShowRating: () => void; +}) { const rel = formatRelativeTime(source.last_scraped_at); + const stars = source.rating?.stars; return ( [styles.chip, pressed && styles.chipPressed]} onPress={onPress} > - - - {prettySiteName(source.display_name)} - - {rel ? {rel} : null} - - - {source.scene_count} + + + + {prettySiteName(source.display_name)} + + {rel ? {rel} : null} + + + {source.scene_count} + + [styles.starsTap, pressed && { opacity: 0.6 }]} + > + + ); } +function AxisBar({ label, value }: { label: string; value: number | null }) { + const v = value ?? 0; + const pct = Math.max(0, Math.min(100, (v / 5) * 100)); + return ( + + {label} + + + + {value == null ? '—' : `${value}/5`} + + ); +} + +const _PCT_LABELS: Record = { + thumb: 'Thumbnails', + tag: 'Tags', + perf: 'Performers', + desc: 'Descriptions', + studio: 'Studio', + dur: 'Duration', +}; + +function RatingModal({ source, onClose }: { source: SourceOut | null; onClose: () => void }) { + const r: SourceRating | null | undefined = source?.rating; + const pct = r?.components?.pct ?? {}; + const basis = r?.health_basis ?? r?.components?.health_basis; + const attempts = r?.components?.pb_attempts_7d ?? 0; + const successRate = r?.components?.pb_success_rate; + return ( + + + {}}> + + {source ? prettySiteName(source.display_name) : ''} + {!r ? ( + Not rated yet — check back soon. + ) : ( + <> + + + + + + + + Metadata coverage + {Object.keys(_PCT_LABELS).map((k) => ( + + {_PCT_LABELS[k]} + {pct[k] != null ? `${pct[k]}%` : '—'} + + ))} + + Playback + + {basis === 'telemetry' + ? `Measured from real playback: ${successRate ?? 0}% success over ${attempts} plays (7d).` + : 'Estimated from how this source streams (no playback data yet — collecting).'} + + + )} + + Close + + + + + + ); +} + const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 }, @@ -248,9 +359,6 @@ const styles = StyleSheet.create({ gridRow: { gap: 10, marginBottom: 10 }, chip: { flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', gap: 8, backgroundColor: theme.card, borderColor: theme.border, @@ -264,6 +372,12 @@ const styles = StyleSheet.create({ shadowRadius: 3, elevation: 2, }, + chipTopRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated }, chipMain: { flex: 1, gap: 2 }, chipName: { @@ -293,4 +407,67 @@ const styles = StyleSheet.create({ emptyText: { color: theme.muted, textAlign: 'center', marginTop: 48, fontSize: 16 }, error: { color: theme.bad, padding: 16 }, + + starsTap: { alignSelf: 'flex-start', paddingVertical: 2, paddingRight: 8 }, + starsRow: { letterSpacing: 1 }, + starsFull: { color: '#f5c518' }, // złoto + starsEmpty: { color: theme.mutedDim }, + starsDim: { color: theme.mutedDim, fontStyle: 'italic' }, + starsOffline: { color: theme.bad, fontWeight: '700', fontSize: 11 }, + + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + padding: 24, + }, + modalCard: { + backgroundColor: theme.card, + borderColor: theme.border, + borderWidth: 1, + borderRadius: 16, + padding: 18, + maxHeight: '80%', + }, + modalTitle: { color: theme.fg, fontSize: 20, fontWeight: '800', marginBottom: 8 }, + modalStarsBig: { marginBottom: 14 }, + modalSection: { + color: theme.muted, + fontSize: 11, + textTransform: 'uppercase', + letterSpacing: 1, + fontWeight: '700', + marginTop: 16, + marginBottom: 8, + }, + modalMuted: { color: theme.mutedDim, fontSize: 13, lineHeight: 18 }, + + axisRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }, + axisLabel: { color: theme.fg, fontSize: 13, width: 80 }, + axisTrack: { + flex: 1, + height: 8, + backgroundColor: theme.bgElevated, + borderRadius: 4, + overflow: 'hidden', + }, + axisFill: { height: 8, backgroundColor: theme.accent, borderRadius: 4 }, + axisVal: { color: theme.muted, fontSize: 12, width: 30, textAlign: 'right' }, + + pctRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 3, + }, + pctLabel: { color: theme.muted, fontSize: 13 }, + pctVal: { color: theme.fg, fontSize: 13, fontWeight: '600' }, + + modalClose: { + marginTop: 20, + backgroundColor: theme.accent, + borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', + }, + modalCloseText: { color: theme.fg, fontWeight: '700', fontSize: 14 }, }); diff --git a/mobile/src/types.ts b/mobile/src/types.ts index 9e6e49c..0542e6c 100644 --- a/mobile/src/types.ts +++ b/mobile/src/types.ts @@ -107,12 +107,29 @@ export interface StudioListOut { per_page: number; } +export interface SourceRating { + stars: number; // 0-5 ogólna (0 = offline) + freshness: number; // 0-5 + richness: number; // 0-5 + health: number | null; // 0-5 (0=offline), null gdy brak danych + health_basis?: string | null; // 'telemetry' | 'proxy' + components?: { + pct?: Record; // thumb/tag/perf/desc/studio/dur (%) + health_basis?: string; + mechanism?: string | null; + pb_attempts_7d?: number; + pb_success_rate?: number | null; + p50_ttff_ms?: number | null; + } | null; +} + export interface SourceOut { origin: string; sitetag: string; display_name: string; scene_count: number; last_scraped_at: string | null; + rating?: SourceRating | null; } export interface SourceListOut {