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:
parent
f34a75f4c6
commit
c154deab37
19 changed files with 843 additions and 16 deletions
66
alembic/versions/20260622_0025_source_ranking.py
Normal file
66
alembic/versions/20260622_0025_source_ranking.py
Normal 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")
|
||||||
49
app/api/playback_events.py
Normal file
49
app/api/playback_events.py
Normal 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()
|
||||||
|
|
@ -26,12 +26,29 @@ from sqlalchemy.orm import Session
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
|
from app.models.source_stats import SourceStats
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/sources", tags=["sources"], dependencies=[Depends(require_api_key)])
|
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):
|
class SourceOut(BaseModel):
|
||||||
origin: str
|
origin: str
|
||||||
"""Raw origin string z DB — np. 'tube:hqpornercom'. Używany jako parametr
|
"""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ć
|
"""MAX(last_seen_at) — najświeższy scrape dla tego origin. Pozwala mobile pokazać
|
||||||
'scrapowane Xh temu' i sortować świeżość."""
|
'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):
|
class SourceListOut(BaseModel):
|
||||||
items: list[SourceOut]
|
items: list[SourceOut]
|
||||||
|
|
@ -110,12 +131,27 @@ def list_sources(
|
||||||
.where(PlaybackSource.dead_at.is_(None))
|
.where(PlaybackSource.dead_at.is_(None))
|
||||||
.where(PlaybackSource.origin.like("tube:%"))
|
.where(PlaybackSource.origin.like("tube:%"))
|
||||||
.group_by(PlaybackSource.origin)
|
.group_by(PlaybackSource.origin)
|
||||||
.order_by(func.count(PlaybackSource.id).desc())
|
|
||||||
).all()
|
).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] = []
|
items: list[SourceOut] = []
|
||||||
for origin, scene_count, last_scraped_at in rows:
|
for origin, scene_count, last_scraped_at in rows:
|
||||||
sitetag = origin.split(":", 1)[1] if origin.startswith("tube:") else origin
|
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(
|
items.append(
|
||||||
SourceOut(
|
SourceOut(
|
||||||
origin=origin,
|
origin=origin,
|
||||||
|
|
@ -123,7 +159,17 @@ def list_sources(
|
||||||
display_name=_sitetag_to_display(sitetag),
|
display_name=_sitetag_to_display(sitetag),
|
||||||
scene_count=scene_count,
|
scene_count=scene_count,
|
||||||
last_scraped_at=last_scraped_at,
|
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))
|
return SourceListOut(items=items, total=len(items))
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,13 @@ class Settings(BaseSettings):
|
||||||
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
|
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) —
|
# 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
|
# 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
|
# 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN
|
||||||
|
|
|
||||||
|
|
@ -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.movies import router as movies_router
|
||||||
from app.api.playback import movies_router as movies_playback_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 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.saved_searches import router as saved_searches_router
|
||||||
from app.api.scene_favorites import router as scene_favorites_router
|
from app.api.scene_favorites import router as scene_favorites_router
|
||||||
from app.api.scenes import router as scenes_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(sources_router)
|
||||||
app.include_router(movies_router)
|
app.include_router(movies_router)
|
||||||
app.include_router(playback_router)
|
app.include_router(playback_router)
|
||||||
|
app.include_router(playback_events_router)
|
||||||
app.include_router(movies_playback_router)
|
app.include_router(movies_playback_router)
|
||||||
app.include_router(scene_favorites_router)
|
app.include_router(scene_favorites_router)
|
||||||
app.include_router(stream_proxy_router)
|
app.include_router(stream_proxy_router)
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ from app.models.movie import (
|
||||||
from app.models.movie_playback_source import MoviePlaybackSource
|
from app.models.movie_playback_source import MoviePlaybackSource
|
||||||
from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
|
from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
|
||||||
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
|
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.playback_source import PlaybackSource
|
||||||
from app.models.saved_search import SavedSearch
|
from app.models.saved_search import SavedSearch
|
||||||
|
from app.models.source_stats import SourceStats
|
||||||
from app.models.scene import (
|
from app.models.scene import (
|
||||||
Scene,
|
Scene,
|
||||||
SceneExternalRef,
|
SceneExternalRef,
|
||||||
|
|
|
||||||
45
app/models/playback_event.py
Normal file
45
app/models/playback_event.py
Normal 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
|
||||||
|
)
|
||||||
40
app/models/source_stats.py
Normal file
40
app/models/source_stats.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -322,6 +322,17 @@ def _job_hetzner_monitor() -> None:
|
||||||
log.exception("[scheduler] hetzner-monitor failed")
|
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:
|
def _job_performer_continuous(refresh_after_days: int) -> None:
|
||||||
"""Continuous worker — 1 performer per tick, ORDER BY last_searched_at NULLS FIRST.
|
"""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"])
|
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"):
|
if cfg.get("movie_ingest_hours"):
|
||||||
sched.add_job(
|
sched.add_job(
|
||||||
_job_movie_ingest,
|
_job_movie_ingest,
|
||||||
|
|
|
||||||
274
app/scheduler/source_stats.py
Normal file
274
app/scheduler/source_stats.py
Normal 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}
|
||||||
|
|
@ -226,6 +226,9 @@ def run_forever() -> int:
|
||||||
# Hetzner Cloud bandwidth monitor — alert do Sentry przy progach % included.
|
# 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).
|
# 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,
|
"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)
|
sched = build_scheduler(cfg)
|
||||||
log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs()))
|
log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs()))
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,25 @@ export class GoonClient {
|
||||||
return this.request('/sources');
|
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: {
|
async listStudios(params: {
|
||||||
q?: string;
|
q?: string;
|
||||||
order?: 'name' | 'scene_count';
|
order?: 'name' | 'scene_count';
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-06-21b',
|
||||||
date: 'June 2026',
|
date: 'June 2026',
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ export type RootStackParamList = {
|
||||||
Player: {
|
Player: {
|
||||||
url: string;
|
url: string;
|
||||||
sceneId: 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
|
// 'movie' = MovieDetail wywołał Player z movieId zamiast sceneId. Backend
|
||||||
// ma /movies/{id}/progress oddzielnie od /scenes/{id}/progress (2026-05-28).
|
// ma /movies/{id}/progress oddzielnie od /scenes/{id}/progress (2026-05-28).
|
||||||
// Default 'scene' dla back-compat z istniejącymi nav callami.
|
// Default 'scene' dla back-compat z istniejącymi nav callami.
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,7 @@ function WatchChip({
|
||||||
const pIsDirect = !!pDirect && pDirect !== p.stream_url;
|
const pIsDirect = !!pDirect && pDirect !== p.stream_url;
|
||||||
navigation.navigate('Player', {
|
navigation.navigate('Player', {
|
||||||
url: pDirect || p.stream_url || p.embed_url || pb.page_url,
|
url: pDirect || p.stream_url || p.embed_url || pb.page_url,
|
||||||
|
origin: pb.origin,
|
||||||
sceneId: movieId,
|
sceneId: movieId,
|
||||||
playbackId: pb.id,
|
playbackId: pb.id,
|
||||||
entityKind: 'movie',
|
entityKind: 'movie',
|
||||||
|
|
@ -224,6 +225,7 @@ function WatchChip({
|
||||||
if (best && (best.direct_url || best.stream_url)) {
|
if (best && (best.direct_url || best.stream_url)) {
|
||||||
navigation.navigate('Player', {
|
navigation.navigate('Player', {
|
||||||
url: best.direct_url || best.stream_url!,
|
url: best.direct_url || best.stream_url!,
|
||||||
|
origin: pb.origin,
|
||||||
sceneId: movieId,
|
sceneId: movieId,
|
||||||
playbackId: pb.id,
|
playbackId: pb.id,
|
||||||
entityKind: 'movie',
|
entityKind: 'movie',
|
||||||
|
|
@ -274,6 +276,7 @@ function WatchChip({
|
||||||
const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url;
|
const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url;
|
||||||
navigation.navigate('Player', {
|
navigation.navigate('Player', {
|
||||||
url: target,
|
url: target,
|
||||||
|
origin: pb.origin,
|
||||||
// sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny),
|
// sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny),
|
||||||
// ale dla entityKind='movie' Player rzutuje to do /movies/{id}/progress.
|
// ale dla entityKind='movie' Player rzutuje to do /movies/{id}/progress.
|
||||||
// Bug-report b207ff17 2026-05-26 ("oznaczenie obejrzanych filmów") — backend
|
// Bug-report b207ff17 2026-05-26 ("oznaczenie obejrzanych filmów") — backend
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ import { theme } from '../theme';
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
url: string;
|
url: string;
|
||||||
sceneId: 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'.
|
// 'scene' (default — back-compat z istniejącymi nav callami) lub 'movie'.
|
||||||
// Player dispatcheruje upsertProgress vs upsertMovieProgress. Wcześniej
|
// Player dispatcheruje upsertProgress vs upsertMovieProgress. Wcześniej
|
||||||
// MovieDetail przekazywał movieId jako sceneId — backend /scenes/<movieId>/
|
// MovieDetail przekazywał movieId jako sceneId — backend /scenes/<movieId>/
|
||||||
|
|
@ -137,7 +140,7 @@ export function PlayerScreen() {
|
||||||
function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
|
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);
|
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
|
||||||
// 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
|
// 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
|
||||||
const upsertProgress = React.useCallback(
|
const upsertProgress = React.useCallback(
|
||||||
|
|
@ -241,6 +244,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
nav.replace('Player', {
|
nav.replace('Player', {
|
||||||
url: fallbackProxyUrl,
|
url: fallbackProxyUrl,
|
||||||
sceneId,
|
sceneId,
|
||||||
|
origin: playOrigin,
|
||||||
playbackId: params.playbackId,
|
playbackId: params.playbackId,
|
||||||
entityKind,
|
entityKind,
|
||||||
durationSec,
|
durationSec,
|
||||||
|
|
@ -259,6 +263,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
nav.replace('Player', {
|
nav.replace('Player', {
|
||||||
url: fallbackEmbedUrl,
|
url: fallbackEmbedUrl,
|
||||||
sceneId,
|
sceneId,
|
||||||
|
origin: playOrigin,
|
||||||
playbackId: params.playbackId,
|
playbackId: params.playbackId,
|
||||||
entityKind,
|
entityKind,
|
||||||
durationSec,
|
durationSec,
|
||||||
|
|
@ -269,6 +274,41 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
}
|
}
|
||||||
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError]);
|
}, [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);
|
const lastReportedRef = React.useRef(0);
|
||||||
// Lokalny tick co 500ms — driver dla custom scrubber + time labels. expo-video
|
// 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.
|
// ma `timeUpdate` event ale firuje z mniejszą częstotliwością niż chcemy dla UI.
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,7 @@ function PlaybackButton({
|
||||||
nav.navigate('Player', {
|
nav.navigate('Player', {
|
||||||
url: initialUrl,
|
url: initialUrl,
|
||||||
sceneId,
|
sceneId,
|
||||||
|
origin: source.origin,
|
||||||
playbackId: source.id,
|
playbackId: source.id,
|
||||||
durationSec: sceneDurationSec,
|
durationSec: sceneDurationSec,
|
||||||
refererHost,
|
refererHost,
|
||||||
|
|
@ -668,6 +669,7 @@ function PlaybackButton({
|
||||||
nav.navigate('Player', {
|
nav.navigate('Player', {
|
||||||
url: target,
|
url: target,
|
||||||
sceneId,
|
sceneId,
|
||||||
|
origin: source.origin,
|
||||||
playbackId: source.id,
|
playbackId: source.id,
|
||||||
durationSec: sceneDurationSec,
|
durationSec: sceneDurationSec,
|
||||||
refererHost,
|
refererHost,
|
||||||
|
|
@ -744,6 +746,7 @@ function PlaybackButton({
|
||||||
nav.navigate('Player', {
|
nav.navigate('Player', {
|
||||||
url: link.embed_url,
|
url: link.embed_url,
|
||||||
sceneId,
|
sceneId,
|
||||||
|
origin: source.origin,
|
||||||
playbackId: source.id,
|
playbackId: source.id,
|
||||||
durationSec: sceneDurationSec,
|
durationSec: sceneDurationSec,
|
||||||
refererHost,
|
refererHost,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
|
@ -20,7 +22,7 @@ import {
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
import type { SourceOut } from '../types';
|
import type { SourceOut, SourceRating } from '../types';
|
||||||
|
|
||||||
type Order = 'popular' | 'recent';
|
type Order = 'popular' | 'recent';
|
||||||
|
|
||||||
|
|
@ -32,6 +34,8 @@ export function SitesScreen() {
|
||||||
const [debouncedQ, setDebouncedQ] = useState('');
|
const [debouncedQ, setDebouncedQ] = useState('');
|
||||||
const [order, setOrder] = useState<Order>('popular');
|
const [order, setOrder] = useState<Order>('popular');
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
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(() => {
|
React.useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedQ(q), 250);
|
const t = setTimeout(() => setDebouncedQ(q), 250);
|
||||||
|
|
@ -73,7 +77,7 @@ export function SitesScreen() {
|
||||||
<Text style={styles.headerLabel}>Sites</Text>
|
<Text style={styles.headerLabel}>Sites</Text>
|
||||||
<Text style={styles.headerCount}>{items.length}</Text>
|
<Text style={styles.headerCount}>{items.length}</Text>
|
||||||
</View>
|
</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}>
|
<View style={styles.toolbar}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|
@ -118,6 +122,7 @@ export function SitesScreen() {
|
||||||
name: prettySiteName(item.display_name),
|
name: prettySiteName(item.display_name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onShowRating={() => setDetail(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
refreshing={isRefetching}
|
refreshing={isRefetching}
|
||||||
|
|
@ -127,6 +132,8 @@ export function SitesScreen() {
|
||||||
}
|
}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RatingModal source={detail} onClose={() => setDetail(null)} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -171,13 +178,37 @@ function formatRelativeTime(iso: string | null): string | null {
|
||||||
return 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 rel = formatRelativeTime(source.last_scraped_at);
|
||||||
|
const stars = source.rating?.stars;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
|
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
|
<View style={styles.chipTopRow}>
|
||||||
<View style={styles.chipMain}>
|
<View style={styles.chipMain}>
|
||||||
<Text style={styles.chipName} numberOfLines={1}>
|
<Text style={styles.chipName} numberOfLines={1}>
|
||||||
{prettySiteName(source.display_name)}
|
{prettySiteName(source.display_name)}
|
||||||
|
|
@ -187,7 +218,87 @@ function SiteChip({ source, onPress }: { source: SourceOut; onPress: () => void
|
||||||
<View style={styles.chipCountWrap}>
|
<View style={styles.chipCountWrap}>
|
||||||
<Text style={styles.chipCount}>{source.scene_count}</Text>
|
<Text style={styles.chipCount}>{source.scene_count}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={onShowRating}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => [styles.starsTap, pressed && { opacity: 0.6 }]}
|
||||||
|
>
|
||||||
|
<Stars value={stars} />
|
||||||
</Pressable>
|
</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 },
|
gridRow: { gap: 10, marginBottom: 10 },
|
||||||
chip: {
|
chip: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 8,
|
gap: 8,
|
||||||
backgroundColor: theme.card,
|
backgroundColor: theme.card,
|
||||||
borderColor: theme.border,
|
borderColor: theme.border,
|
||||||
|
|
@ -264,6 +372,12 @@ const styles = StyleSheet.create({
|
||||||
shadowRadius: 3,
|
shadowRadius: 3,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
|
chipTopRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated },
|
chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated },
|
||||||
chipMain: { flex: 1, gap: 2 },
|
chipMain: { flex: 1, gap: 2 },
|
||||||
chipName: {
|
chipName: {
|
||||||
|
|
@ -293,4 +407,67 @@ const styles = StyleSheet.create({
|
||||||
|
|
||||||
emptyText: { color: theme.muted, textAlign: 'center', marginTop: 48, fontSize: 16 },
|
emptyText: { color: theme.muted, textAlign: 'center', marginTop: 48, fontSize: 16 },
|
||||||
error: { color: theme.bad, padding: 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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,29 @@ export interface StudioListOut {
|
||||||
per_page: number;
|
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 {
|
export interface SourceOut {
|
||||||
origin: string;
|
origin: string;
|
||||||
sitetag: string;
|
sitetag: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
scene_count: number;
|
scene_count: number;
|
||||||
last_scraped_at: string | null;
|
last_scraped_at: string | null;
|
||||||
|
rating?: SourceRating | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceListOut {
|
export interface SourceListOut {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue