goon/mobile/src/types.ts
jtrzupek c154deab37 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>
2026-06-22 10:00:59 +02:00

398 lines
9 KiB
TypeScript

// Lustro modeli z FastAPI app/api/schemas.py + admin endpoints.
export interface ExternalRef {
source: string;
external_id: string;
url?: string | null;
last_seen?: string | null;
}
export interface StudioOut {
id: string;
name: string;
slug: string;
network?: string | null;
}
export interface PerformerOut {
id: string;
canonical_name: string;
slug: string;
gender?: string | null;
as_alias?: string | null;
}
export interface TagOut {
id: string;
name: string;
slug: string;
}
export interface PlaybackSource {
id: string;
origin: string;
page_url: string;
embed_url?: string | null;
stream_url?: string | null;
quality?: string | null;
duration_sec?: number | null;
thumbnail_url?: string | null;
animated_thumbnail_url?: string | null;
}
export interface StreamLink {
// proxy URL (fallback gdy direct fails). Backend re-fetchuje content z VPS IP +
// streamuje do mobile.
stream_url?: string | null;
// hoster embed URL — mobile otwiera w WebView gdy `type=hoster`.
embed_url?: string | null;
// raw CDN URL + headers do bezpośredniego fetchu z urządzenia (preferred — 0
// bandwidth przez VPS). Mobile próbuje to PIERWSZE; fallback na `stream_url` na error.
direct_url?: string | null;
headers?: Record<string, string> | null;
quality?: string | null;
type?: string | null;
// Backend metadata (origin, host, part_label dla paradisehill multipart, mobile_direct_ok...).
// Untyped bag — mobile czyta selektywnie (np. raw.part_label w movie part-pickerze).
raw?: Record<string, unknown> | null;
}
export interface ResolveOut {
source: PlaybackSource;
best?: StreamLink | null;
links: StreamLink[];
}
export interface TagCount {
id: string;
name: string;
slug: string;
scene_count: number;
}
export interface TagListOut {
items: TagCount[];
total: number;
page: number;
per_page: number;
}
export interface PerformerCount {
id: string;
canonical_name: string;
slug: string;
gender?: string | null;
scene_count: number;
}
export interface PerformerListOut {
items: PerformerCount[];
total: number;
page: number;
per_page: number;
}
export interface StudioCount {
id: string;
name: string;
slug: string;
network?: string | null;
scene_count: number;
}
export interface StudioListOut {
items: StudioCount[];
total: number;
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 {
origin: string;
sitetag: string;
display_name: string;
scene_count: number;
last_scraped_at: string | null;
rating?: SourceRating | null;
}
export interface SourceListOut {
items: SourceOut[];
total: number;
}
export type ScenesSort = 'created_at' | 'release_date' | 'title' | 'studio';
export interface ScenesListParams {
q?: string;
studio_slugs?: string[];
tags?: string[];
performer_ids?: string[];
has_playback?: boolean;
min_duration_sec?: number;
max_duration_sec?: number;
released_within_days?: number;
min_quality_p?: number;
include_stubs?: boolean;
origin?: string;
sort?: ScenesSort;
page?: number;
per_page?: number;
}
export interface SceneOut {
id: string;
title: string;
slug?: string | null;
release_date?: string | null;
duration_sec?: number | null;
description?: string | null;
code?: string | null;
director?: string | null;
studio?: StudioOut | null;
performers: PerformerOut[];
tags: TagOut[];
external_refs: ExternalRef[];
playback_sources: PlaybackSource[];
// Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW" — gdy
// `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen).
created_at?: string | null;
// Watched indicator + favorite state. Backend dolicza z scene_play_progress + favorite_scenes.
last_played_at?: string | null;
finished?: boolean;
position_sec?: number;
is_favorite?: boolean;
}
export interface SceneListOut {
items: SceneOut[];
total: number;
page: number;
per_page: number;
// has_more: czy jest kolejna strona (z fetcha per_page+1). Paginuj po TYM, nie po
// `total` — bo dla list filtrowanych `total` jest bounded ("1000+"). Optional dla
// wstecznej zgodności ze starym backendem (undefined → fallback na total w UI).
has_more?: boolean;
// total_capped: true gdy `total` to cap (jest >total wyników) → pokaż "{total}+".
total_capped?: boolean;
}
export interface MovieChapterOut {
chapter_index: number;
title?: string | null;
start_sec?: number | null;
end_sec?: number | null;
scene_id?: string | null;
}
export interface MovieOut {
id: string;
title: string;
slug?: string | null;
release_year?: number | null;
release_date?: string | null;
duration_sec?: number | null;
description?: string | null;
director?: string | null;
country?: string | null;
rating?: number | null;
poster_url?: string | null;
backdrop_url?: string | null;
studio?: StudioOut | null;
performers: PerformerOut[];
tags: TagOut[];
chapters: MovieChapterOut[];
external_refs: ExternalRef[];
playback_sources: PlaybackSource[];
created_at?: string | null;
is_favorite?: boolean;
// Watched / continue-watching state (mirror SceneOut). Backend zaczął
// zwracać 2026-05-28 — pre-0.2.0 buildy zostawią defaults (false/0/null).
last_played_at?: string | null;
finished?: boolean;
position_sec?: number;
}
export interface MovieListOut {
items: MovieOut[];
total: number;
page: number;
per_page: number;
}
export interface FavoriteMovieOut {
movie_id: string;
title: string;
slug: string | null;
poster_url: string | null;
release_year: number | null;
studio_name: string | null;
last_seen_at: string;
created_at: string;
}
export interface FavoriteMovieListOut {
items: FavoriteMovieOut[];
total: number;
}
export interface FavoriteMovieAddOut {
movie_id: string;
created: boolean;
}
export type MoviesSort = 'created_at' | 'release_year' | 'release_date' | 'title' | 'rating';
export interface MoviesListParams {
q?: string;
studio_slugs?: string[];
tags?: string[];
performer_ids?: string[];
year_from?: number;
year_to?: number;
has_playback?: boolean;
sort?: MoviesSort;
page?: number;
per_page?: number;
}
export interface MergeCandidateSummary {
id: string;
kind: string;
left_id: string;
right_id: string;
score: number;
status: 'pending' | 'auto_merged' | 'merged' | 'rejected';
left_title?: string | null;
right_title?: string | null;
left_thumbnail_url?: string | null;
left_animated_thumbnail_url?: string | null;
right_thumbnail_url?: string | null;
right_animated_thumbnail_url?: string | null;
}
export interface MergeCandidateListOut {
items: MergeCandidateSummary[];
total: number;
page: number;
per_page: number;
}
export interface MergeCandidateDetail {
id: string;
kind: string;
score: number;
status: string;
reasons: Record<string, unknown>;
left: SceneOut | null;
right: SceneOut | null;
}
export interface ResolveResult {
id: string;
status: string;
keep_id?: string | null;
drop_id?: string | null;
}
export type ResolveAction = 'merge' | 'reject';
export type KeepSide = 'left' | 'right';
export interface FavoriteOut {
performer_id: string;
canonical_name: string;
slug: string | null;
scene_count: number;
new_count: number;
last_seen_at: string;
created_at: string;
}
export interface FavoriteListOut {
items: FavoriteOut[];
total: number;
new_total: number;
}
export interface FavoriteAddOut {
performer_id: string;
created: boolean;
}
export interface FavoriteStudioOut {
studio_id: string;
name: string;
slug: string;
network: string | null;
scene_count: number;
new_count: number;
last_seen_at: string;
created_at: string;
}
export interface FavoriteStudioListOut {
items: FavoriteStudioOut[];
total: number;
new_total: number;
}
export interface FavoriteStudioAddOut {
studio_id: string;
created: boolean;
}
export interface BlacklistEntry {
id: string;
name: string;
slug?: string | null;
}
export interface BlacklistOut {
performers: BlacklistEntry[];
studios: BlacklistEntry[];
tags: BlacklistEntry[];
}
export type BlacklistKind = 'performer' | 'studio' | 'tag';
export interface SavedSearchOut {
id: string;
query: string;
created_at: string;
}
export interface ProgressOut {
scene_id: string;
position_sec: number;
duration_sec: number | null;
finished: boolean;
last_played_at: string;
}
export interface WatchEntry {
scene: SceneOut;
position_sec: number;
duration_sec: number | null;
finished: boolean;
last_played_at: string;
}
export interface WatchListOut {
items: WatchEntry[];
}