feat(telemetry): playback_events.via, skad przyszedl adres w odtwarzaczu

Po przejsciu porndoe na resolver po stronie telefonu 35 urzadzen dalej
dostawalo wylacznie 403 i nie dalo sie odroznic, czy pada nowa droga, zapas
przez WebView, czy stara paczka. Apka wysyla teraz via: phone, backend,
hoster, webview, proxy, movie; '-re' po ponownym rozwiazaniu w tym samym
ekranie, '<droga>>wv' gdy adres wyciagnal WebView jako zapas. NULL = paczka
sprzed tej zmiany. Migracja 0053 (kolumna nullable, sama zmiana metadanych).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-06 16:25:56 +02:00
parent 189dbdf17d
commit 2feef620a1
8 changed files with 77 additions and 5 deletions

View file

@ -0,0 +1,32 @@
"""playback_events.via: skad przyszedl adres, ktory gral (albo padl) w odtwarzaczu.
Po przejsciu porndoe na resolver po stronie telefonu (2026-10-02) 35 urzadzen dalej
dostawalo wylacznie 403 i nie dalo sie odroznic, czy pada nowa droga (link pobrany na
telefonie), zapas przez WebView, czy stara paczka. Apka wysyla teraz `via`
('phone', 'backend', 'hoster', 'webview', 'proxy', z koncowka '-re' po ponownym
rozwiazaniu); NULL = paczka sprzed tej zmiany.
Kolumna bez DEFAULT, nullable: ADD COLUMN to sama zmiana metadanych.
Revision ID: 0053_playback_events_via
Revises: 0052_clips_feed_counters
Create Date: 2026-10-06
"""
from __future__ import annotations
from alembic import op
revision = "0053_playback_events_via"
down_revision: str | None = "0052_clips_feed_counters"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE playback_events ADD COLUMN IF NOT EXISTS via varchar(16)")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE playback_events DROP COLUMN IF EXISTS via")

View file

@ -32,6 +32,9 @@ class PlaybackEventIn(BaseModel):
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error). # padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
error_detail: str | None = Field(default=None, max_length=512) error_detail: str | None = Field(default=None, max_length=512)
ttff_ms: int | None = Field(default=None, ge=0, le=600_000) ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
# Skad przyszedl adres w odtwarzaczu (patrz PlaybackEvent.via). Opcjonalne:
# starsze paczki go nie wysylaja.
via: str | None = Field(default=None, max_length=16)
@router.post("", status_code=204) @router.post("", status_code=204)
@ -49,6 +52,7 @@ def post_playback_event(
error_detail=body.error_detail if body.status == "error" else None, error_detail=body.error_detail if body.status == "error" else None,
ttff_ms=body.ttff_ms if body.status == "success" else None, ttff_ms=body.ttff_ms if body.status == "success" else None,
device_id=device_id, device_id=device_id,
via=body.via,
) )
) )
session.commit() session.commit()

View file

@ -42,6 +42,9 @@ class PlaybackEvent(Base):
error_detail: Mapped[str | None] = mapped_column(String(512), nullable=True) error_detail: Mapped[str | None] = mapped_column(String(512), nullable=True)
# time-to-first-frame [ms], "szybkość" (tylko przy success; None gdy nie zmierzono). # time-to-first-frame [ms], "szybkość" (tylko przy success; None gdy nie zmierzono).
ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Skad przyszedl adres w odtwarzaczu: 'phone', 'backend', 'hoster', 'webview',
# 'proxy' (z '-re' po ponownym rozwiazaniu). NULL = paczka sprzed 2026-10-06.
via: Mapped[str | None] = mapped_column(String(16), nullable=True)
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False

View file

@ -333,6 +333,7 @@ export class GoonClient {
error_kind?: string; error_kind?: string;
error_detail?: string; error_detail?: string;
ttff_ms?: number; ttff_ms?: number;
via?: string;
}): Promise<void> { }): Promise<void> {
try { try {
await this.request('/playback-events', { await this.request('/playback-events', {

View file

@ -116,6 +116,10 @@ export type RootStackParamList = {
// się automatycznie na WebView z tym URL'em, tam JS embed page sam pobiera // się automatycznie na WebView z tym URL'em, tam JS embed page sam pobiera
// m3u8 we własnym kontekście (Cookies + JA3 + IP set ok). // m3u8 we własnym kontekście (Cookies + JA3 + IP set ok).
fallbackEmbedUrl?: string; fallbackEmbedUrl?: string;
// Skad przyszedl adres (telemetria playback_events.via): 'phone' (resolver na
// telefonie w SceneDetail), 'backend', 'hoster' (resolver hostera w Playerze),
// 'webview' (wyciagniety ze strony), 'proxy' (zapasowe proxy), 'movie'.
via?: string;
}; };
}; };

View file

@ -212,6 +212,7 @@ function WatchChip({
const pDirect = p.direct_url; const pDirect = p.direct_url;
const pIsDirect = !!pDirect && pDirect !== p.stream_url; const pIsDirect = !!pDirect && pDirect !== p.stream_url;
navigation.navigate('Player', { navigation.navigate('Player', {
via: 'movie',
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, origin: pb.origin,
sceneId: movieId, sceneId: movieId,
@ -239,6 +240,7 @@ function WatchChip({
const best = links[0]; const best = links[0];
if (best && (best.direct_url || best.stream_url)) { if (best && (best.direct_url || best.stream_url)) {
navigation.navigate('Player', { navigation.navigate('Player', {
via: 'movie',
url: best.direct_url || best.stream_url!, url: best.direct_url || best.stream_url!,
origin: pb.origin, origin: pb.origin,
sceneId: movieId, sceneId: movieId,
@ -290,6 +292,7 @@ function WatchChip({
const target = bestDirect || res.best?.stream_url || res.best?.embed_url || pb.page_url; const target = bestDirect || res.best?.stream_url || res.best?.embed_url || pb.page_url;
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', {
via: 'movie',
url: target, url: target,
origin: pb.origin, 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),

View file

@ -62,6 +62,8 @@ interface RouteParams {
headers?: Record<string, string>; headers?: Record<string, string>;
fallbackProxyUrl?: string; fallbackProxyUrl?: string;
fallbackEmbedUrl?: string; fallbackEmbedUrl?: string;
// Skad przyszedl adres, do telemetrii (patrz RootStackParamList.Player.via).
via?: string;
} }
// Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako // Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako
@ -264,6 +266,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// przyciskow siedzi z boku, stad tez lewy i prawy. // przyciskow siedzi z boku, stad tez lewy i prawy.
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { url, sceneId, origin: playOrigin, resolvePageUrl, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; const { url, sceneId, origin: playOrigin, resolvePageUrl, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params;
// Skad przyszedl adres, do telemetrii; ponowne rozwiazanie w tym samym ekranie dopisuje '-re'.
const viaRef = React.useRef(params.via || 'other');
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(
@ -517,6 +521,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
if (!cancelled && freshUrl && freshUrl !== url) { if (!cancelled && freshUrl && freshUrl !== url) {
player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl); player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl);
player.play(); player.play();
viaRef.current = `${viaRef.current.replace(/-re$/, '')}-re`;
return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy
} }
// Phone-resolve zadziałał, ale dał TEN SAM URL, czyli strona parsuje się dobrze, // Phone-resolve zadziałał, ale dał TEN SAM URL, czyli strona parsuje się dobrze,
@ -534,6 +539,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
.find((u): u is string => !!u && u !== url); .find((u): u is string => !!u && u !== url);
if (!cancelled && viaProxy) { if (!cancelled && viaProxy) {
player.replace(viaProxy); player.replace(viaProxy);
viaRef.current = 'proxy-re';
player.play(); player.play();
return; return;
} }
@ -605,6 +611,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
headers: undefined, headers: undefined,
fallbackProxyUrl: undefined, // już użyty fallbackProxyUrl: undefined, // już użyty
fallbackEmbedUrl, // zostawiamy do step 3 fallbackEmbedUrl, // zostawiamy do step 3
via: 'proxy',
}); });
return; return;
} }
@ -621,6 +628,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
refererHost, refererHost,
title, title,
mode: 'webview', mode: 'webview',
// Droga, ktora wlasnie padla; WebView dopisze do niej '>wv', gdy wyciagnie adres.
via: viaRef.current,
}); });
} }
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveApplicable, reResolveDone, playOrigin]); }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveApplicable, reResolveDone, playOrigin]);
@ -642,6 +651,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
status: 'success', status: 'success',
scene_id: sceneId, scene_id: sceneId,
ttff_ms: Date.now() - (playTelemetryMountRef.current ?? Date.now()), ttff_ms: Date.now() - (playTelemetryMountRef.current ?? Date.now()),
via: viaRef.current,
}); });
} }
}, [status, playOrigin, client, sceneId]); }, [status, playOrigin, client, sceneId]);
@ -658,6 +668,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
scene_id: sceneId, scene_id: sceneId,
error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error', error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error',
error_detail: playerError?.message ? String(playerError.message).slice(0, 512) : undefined, error_detail: playerError?.message ? String(playerError.message).slice(0, 512) : undefined,
via: viaRef.current,
}); });
}, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]); }, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]);
@ -1855,6 +1866,9 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
[client, sceneId, entityKind], [client, sceneId, entityKind],
); );
const [extractedUrl, setExtractedUrl] = React.useState<string | null>(null); const [extractedUrl, setExtractedUrl] = React.useState<string | null>(null);
// Telemetria: adres wyciagniety z tej strony. Gdy WebView byl zapasem po padnietym
// odtwarzaczu, zostaje slad poprzedniej drogi (np. 'phone>wv').
const webViewVia = params.via ? `${params.via}>wv`.slice(0, 16) : 'webview';
const [resolveStatus, setResolveStatus] = React.useState<'idle' | 'pending' | 'failed'>('idle'); const [resolveStatus, setResolveStatus] = React.useState<'idle' | 'pending' | 'failed'>('idle');
const [resolveError, setResolveError] = React.useState<string | null>(null); const [resolveError, setResolveError] = React.useState<string | null>(null);
const [skipResolve, setSkipResolve] = React.useState(false); const [skipResolve, setSkipResolve] = React.useState(false);
@ -1968,6 +1982,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
refererHost, refererHost,
headers: result.headers, headers: result.headers,
mode: 'video', mode: 'video',
via: 'hoster',
}); });
} else { } else {
// Cichy resolver padl. Zamiast slepego zaulka z przyciskiem wchodzimy OD RAZU do // Cichy resolver padl. Zamiast slepego zaulka z przyciskiem wchodzimy OD RAZU do
@ -2098,8 +2113,9 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
durationSec, durationSec,
refererHost: refererHost || (initialHostRef.current || undefined), refererHost: refererHost || (initialHostRef.current || undefined),
mode: 'video', mode: 'video',
via: webViewVia,
}); });
}, [extractedUrl, nav, sceneId, durationSec, refererHost, params.origin, params.title, params.playbackId, entityKind]); }, [extractedUrl, nav, sceneId, durationSec, refererHost, params.origin, params.title, params.playbackId, entityKind, webViewVia]);
React.useEffect(() => { React.useEffect(() => {
upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch( upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch(
@ -2268,6 +2284,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
durationSec, durationSec,
refererHost, refererHost,
mode: 'video', mode: 'video',
via: webViewVia,
}) })
} }
> >

View file

@ -566,6 +566,8 @@ function PlaybackButton({
const { defaultQuality } = usePreferences(); const { defaultQuality } = usePreferences();
const [resolving, setResolving] = React.useState(false); const [resolving, setResolving] = React.useState(false);
const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null); const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null);
// Skad pochodza linki w pickerze jakosci (telemetria via): resolver na telefonie czy serwer.
const [qualityVia, setQualityVia] = React.useState<'phone' | 'backend'>('backend');
// Auto-quality: 1 link → graj; wiele → spróbuj dopasować domyślną jakość usera // Auto-quality: 1 link → graj; wiele → spróbuj dopasować domyślną jakość usera
// (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje // (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje
@ -616,6 +618,7 @@ function PlaybackButton({
link: StreamLink, link: StreamLink,
fallbackEmbedUrl?: string, fallbackEmbedUrl?: string,
allLinks?: StreamLink[], allLinks?: StreamLink[],
via: 'phone' | 'backend' = 'backend',
) => { ) => {
let refererHost: string | undefined; let refererHost: string | undefined;
try { try {
@ -657,6 +660,7 @@ function PlaybackButton({
headers: isDirect && link.headers ? link.headers : undefined, headers: isDirect && link.headers ? link.headers : undefined,
fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined, fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined,
fallbackEmbedUrl, fallbackEmbedUrl,
via,
// Pozostałe jakości → przełącznik w playerze (bez powrotu do listy źródeł). // Pozostałe jakości → przełącznik w playerze (bez powrotu do listy źródeł).
qualityLinks: (allLinks && allLinks.length > 1) qualityLinks: (allLinks && allLinks.length > 1)
? allLinks ? allLinks
@ -711,8 +715,11 @@ function PlaybackButton({
if (links.length > 0) { if (links.length > 0) {
markStarted(); markStarted();
const pick = pickAuto(links); const pick = pickAuto(links);
if (pick) await openAsVideo(pick, source.page_url); if (pick) await openAsVideo(pick, source.page_url, undefined, 'phone');
else setQualityLinks(links); else {
setQualityVia('phone');
setQualityLinks(links);
}
return; return;
} }
// pusto → spadnij na backend resolve poniżej (HosterDead → mark dead dla // pusto → spadnij na backend resolve poniżej (HosterDead → mark dead dla
@ -792,9 +799,10 @@ function PlaybackButton({
const autoPick = pickAuto(directLinks); const autoPick = pickAuto(directLinks);
if (autoPick) { if (autoPick) {
markStarted(); markStarted();
await openAsVideo(autoPick, fallbackEmbedUrl, directLinks); await openAsVideo(autoPick, fallbackEmbedUrl, directLinks, 'backend');
return; return;
} }
setQualityVia('backend');
setQualityLinks(directLinks); setQualityLinks(directLinks);
} catch (e) { } catch (e) {
// 410 = backend zaznaczył link jako PERMANENT dead (HosterDead exception // 410 = backend zaznaczył link jako PERMANENT dead (HosterDead exception
@ -855,7 +863,7 @@ function PlaybackButton({
// direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc // direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc
// szukamy embed'a w `links` na resolve response wcześniej; tu już go nie // szukamy embed'a w `links` na resolve response wcześniej; tu już go nie
// mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct). // mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct).
await openAsVideo(link, undefined, qualityLinks || undefined); await openAsVideo(link, undefined, qualityLinks || undefined, qualityVia);
} else if (link.embed_url) { } else if (link.embed_url) {
let refererHost: string | undefined; let refererHost: string | undefined;
try { try {