feat(sources): 0-5★ ranking on Sites (freshness/metadata/plays) + playback telemetry

Rates each source on three axes the user asked for:
- freshness: how recently/often new content arrives (newest age + 7d volume)
- richness: metadata coverage (thumbnail/tags/performers/description/studio/duration)
- plays: does it actually play — from real playback telemetry when available,
  else a proxy from the resolve mechanism. 0★ = offline (gates the overall stars,
  so a fresh+rich source that doesn't play still ranks bottom — the hqfap/4k69 case)

Backend:
- playback_events: fire-and-forget telemetry POST from the app per playback attempt
  (origin + success/error + time-to-first-frame), append-only, 30d retention
- source_stats: per-origin computed scores, refreshed by a scheduler job (6h);
  /sources joins it and sorts by stars
- models + local migration 0025; new GOON_SCHED_SOURCE_STATS_HOURS setting

Mobile:
- Sites rows show ★ rating; tap the stars for a breakdown (axes + metadata %, plus
  whether "plays" is measured or estimated)
- PlayerScreen reports playback success/failure per source (native path only —
  symmetric, conservative); origin threaded through Scene/Movie play callsites

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-22 10:00:59 +02:00
parent f34a75f4c6
commit c154deab37
19 changed files with 843 additions and 16 deletions

View file

@ -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")

View file

@ -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:<sitetag>' — 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()

View file

@ -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))

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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:<sitetag>' — 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
)

View file

@ -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
)

View file

@ -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,

View file

@ -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}

View file

@ -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()))

View file

@ -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<void> {
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';

View file

@ -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',

View file

@ -55,6 +55,9 @@ export type RootStackParamList = {
Player: {
url: string;
sceneId: string;
// 'tube:<sitetag>' ź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.

View file

@ -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

View file

@ -27,6 +27,9 @@ import { theme } from '../theme';
interface RouteParams {
url: string;
sceneId: string;
// 'tube:<sitetag>' ź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/<movieId>/
@ -137,7 +140,7 @@ export function PlayerScreen() {
function NativeVideoPlayer({ params }: { params: RouteParams }) {
const client = useClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
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<number | null>(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.

View file

@ -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,

View file

@ -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<Order>('popular');
const [searchFocused, setSearchFocused] = useState(false);
// Źródło którego rozkład oceny pokazujemy w modalu (tap w gwiazdki).
const [detail, setDetail] = useState<SourceOut | null>(null);
React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q), 250);
@ -73,7 +77,7 @@ export function SitesScreen() {
<Text style={styles.headerLabel}>Sites</Text>
<Text style={styles.headerCount}>{items.length}</Text>
</View>
<Text style={styles.hint}>tap a tube newest scenes from that site</Text>
<Text style={styles.hint}>tap a tube newest scenes · tap the rating breakdown</Text>
<View style={styles.toolbar}>
<TextInput
@ -118,6 +122,7 @@ export function SitesScreen() {
name: prettySiteName(item.display_name),
})
}
onShowRating={() => setDetail(item)}
/>
)}
refreshing={isRefetching}
@ -127,6 +132,8 @@ export function SitesScreen() {
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
<RatingModal source={detail} onClose={() => setDetail(null)} />
</View>
);
}
@ -171,13 +178,37 @@ 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 <Text style={[styles.starsDim, { fontSize: size }]}> not rated</Text>;
if (value <= 0) return <Text style={[styles.starsOffline, { fontSize: size }]}> OFFLINE</Text>;
const full = '★'.repeat(value);
const empty = '☆'.repeat(5 - value);
return (
<Text style={[styles.starsRow, { fontSize: size }]}>
<Text style={styles.starsFull}>{full}</Text>
<Text style={styles.starsEmpty}>{empty}</Text>
</Text>
);
}
function SiteChip({
source,
onPress,
onShowRating,
}: {
source: SourceOut;
onPress: () => void;
onShowRating: () => void;
}) {
const rel = formatRelativeTime(source.last_scraped_at);
const stars = source.rating?.stars;
return (
<Pressable
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
onPress={onPress}
>
<View style={styles.chipTopRow}>
<View style={styles.chipMain}>
<Text style={styles.chipName} numberOfLines={1}>
{prettySiteName(source.display_name)}
@ -187,7 +218,87 @@ function SiteChip({ source, onPress }: { source: SourceOut; onPress: () => void
<View style={styles.chipCountWrap}>
<Text style={styles.chipCount}>{source.scene_count}</Text>
</View>
</View>
<Pressable
onPress={onShowRating}
hitSlop={8}
style={({ pressed }) => [styles.starsTap, pressed && { opacity: 0.6 }]}
>
<Stars value={stars} />
</Pressable>
</Pressable>
);
}
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 (
<View style={styles.axisRow}>
<Text style={styles.axisLabel}>{label}</Text>
<View style={styles.axisTrack}>
<View style={[styles.axisFill, { width: `${pct}%` }]} />
</View>
<Text style={styles.axisVal}>{value == null ? '—' : `${value}/5`}</Text>
</View>
);
}
const _PCT_LABELS: Record<string, string> = {
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 (
<Modal visible={!!source} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.modalBackdrop} onPress={onClose}>
<Pressable style={styles.modalCard} onPress={() => {}}>
<ScrollView>
<Text style={styles.modalTitle}>{source ? prettySiteName(source.display_name) : ''}</Text>
{!r ? (
<Text style={styles.modalMuted}>Not rated yet check back soon.</Text>
) : (
<>
<View style={styles.modalStarsBig}>
<Stars value={r.stars} size={22} />
</View>
<AxisBar label="Freshness" value={r.freshness} />
<AxisBar label="Metadata" value={r.richness} />
<AxisBar label="Plays" value={r.health} />
<Text style={styles.modalSection}>Metadata coverage</Text>
{Object.keys(_PCT_LABELS).map((k) => (
<View key={k} style={styles.pctRow}>
<Text style={styles.pctLabel}>{_PCT_LABELS[k]}</Text>
<Text style={styles.pctVal}>{pct[k] != null ? `${pct[k]}%` : '—'}</Text>
</View>
))}
<Text style={styles.modalSection}>Playback</Text>
<Text style={styles.modalMuted}>
{basis === 'telemetry'
? `Measured from real playback: ${successRate ?? 0}% success over ${attempts} plays (7d).`
: 'Estimated from how this source streams (no playback data yet — collecting).'}
</Text>
</>
)}
<Pressable style={styles.modalClose} onPress={onClose}>
<Text style={styles.modalCloseText}>Close</Text>
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
}
@ -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 },
});

View file

@ -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<string, number>; // 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 {