Bind telemetry to playback sessions

This commit is contained in:
KhooLy 2026-08-01 21:09:47 +03:00
parent f74be653a7
commit 5e7df0002c
12 changed files with 177 additions and 69 deletions

1
src-tauri/Cargo.lock generated
View file

@ -2087,6 +2087,7 @@ dependencies = [
"sha1",
"sha2 0.11.0",
"tokio",
"url",
"web-time",
]

View file

@ -88,6 +88,22 @@ pub async fn vetted_client(
.map_err(|e| e.to_string())
}
/// Like [`vetted_client`], but leaves redirect handling to the caller. This is
/// required when the caller must resolve and pin every redirect target instead
/// of letting reqwest follow a domain target without another DNS check.
pub async fn vetted_client_without_redirects(
url_str: &str,
timeout: std::time::Duration,
) -> Result<reqwest::Client, String> {
let (host, addrs) = resolve_public_host(url_str).await?;
reqwest::Client::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(&host, &addrs)
.build()
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -37,48 +37,84 @@ impl PluginHttpClient for DesktopPluginHttpClient {
}
fn fetch(request: PluginHttpRequest) -> PluginHttpResponse {
let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => return failed_response(error.to_string()),
};
runtime.block_on(async move {
let client = match crate::net_guard::vetted_client(&request.url, Duration::from_secs(FETCH_TIMEOUT_SECS)).await {
Ok(client) => client,
Err(error) => return failed_response(error),
};
let method = match reqwest::Method::from_bytes(request.method.as_bytes()) {
Ok(method) => method,
Err(error) => return failed_response(error.to_string()),
};
let mut outgoing = client.request(method, &request.url);
for (name, value) in request.headers {
let (Ok(name), Ok(value)) = (
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
reqwest::header::HeaderValue::from_str(&value),
) else {
continue;
let mut url = match reqwest::Url::parse(&request.url) {
Ok(url) => url,
Err(error) => return failed_response(error.to_string()),
};
let mut redirects_left = request.follow_redirects.then_some(10).unwrap_or(0);
loop {
let client = match crate::net_guard::vetted_client_without_redirects(
url.as_str(),
Duration::from_secs(FETCH_TIMEOUT_SECS),
)
.await
{
Ok(client) => client,
Err(error) => return failed_response(error),
};
outgoing = outgoing.header(name, value);
}
if let Some(body) = request.body {
outgoing = outgoing.body(body);
}
match outgoing.send().await {
Ok(response) => {
let status = response.status().as_u16();
let headers = response.headers().iter().filter_map(|(name, value)| {
value.to_str().ok().map(|value| (name.to_string(), value.to_string()))
}).collect();
let body = response.text().await.unwrap_or_default();
PluginHttpResponse {
status,
headers,
body,
ok: (200..300).contains(&status),
error: None,
}
let mut outgoing = client.request(method.clone(), url.clone());
for (name, value) in &request.headers {
let (Ok(name), Ok(value)) = (
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
reqwest::header::HeaderValue::from_str(value),
) else {
continue;
};
outgoing = outgoing.header(name, value);
}
Err(error) => failed_response(error.to_string()),
if let Some(body) = &request.body {
outgoing = outgoing.body(body.clone());
}
let response = match outgoing.send().await {
Ok(response) => response,
Err(error) => return failed_response(error.to_string()),
};
if response.status().is_redirection() && redirects_left > 0 {
let Some(location) = response.headers().get(reqwest::header::LOCATION) else {
return failed_response("redirect without location".to_string());
};
let location = match location.to_str() {
Ok(location) => location,
Err(_) => return failed_response("redirect has invalid location".to_string()),
};
url = match url.join(location) {
Ok(url) => url,
Err(error) => return failed_response(error.to_string()),
};
redirects_left -= 1;
continue;
}
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.to_string(), value.to_string()))
})
.collect();
let body = response.text().await.unwrap_or_default();
return PluginHttpResponse {
status,
headers,
body,
ok: (200..300).contains(&status),
error: None,
};
}
})
}

View file

@ -1,8 +1,23 @@
use crate::{torrent_transport, DesktopState};
use crate::{DesktopState, torrent_transport};
use fluxa_core::FluxaCore;
use serde_json::{json, Value};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tauri::State;
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TorrentTelemetryContext {
link: String,
generation: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TorrentStreamStart {
url: String,
telemetry_context: TorrentTelemetryContext,
}
fn start_torrent_stream_inner(
data_dir: std::path::PathBuf,
stream_json: String,
@ -105,9 +120,7 @@ pub fn stream_magnet_link(stream_json: String) -> Option<String> {
FluxaCore::stream_magnet_link_json(&stream_json)
}
async fn ensure_healthy_torrent_base_url(
state: &State<'_, DesktopState>,
) -> Option<String> {
async fn ensure_healthy_torrent_base_url(state: &State<'_, DesktopState>) -> Option<String> {
let base_url = state.torrent.lock().unwrap().server_base_url.clone()?;
let healthy = tauri::async_runtime::spawn_blocking({
let base_url = base_url.clone();
@ -142,7 +155,7 @@ pub async fn start_torrent_stream(
title: Option<String>,
preferences: Option<Value>,
duration_ms: Option<u64>,
) -> Result<String, String> {
) -> Result<TorrentStreamStart, String> {
let data_dir = state
.data_dir
.lock()
@ -175,19 +188,32 @@ pub async fn start_torrent_stream(
}
let (stream_url, base_url, link, generation, file_id) =
tauri::async_runtime::spawn_blocking(move || {
start_torrent_stream_inner(data_dir, stream_json, title, preferences, existing_base_url, duration_ms)
start_torrent_stream_inner(
data_dir,
stream_json,
title,
preferences,
existing_base_url,
duration_ms,
)
})
.await
.map_err(|e| e.to_string())??;
let mut torrent = state.torrent.lock().unwrap();
torrent.server_base_url = Some(base_url);
torrent.stream_link = Some(link);
torrent.stream_link = Some(link.clone());
torrent.stream_file_id = file_id;
torrent.telemetry_generation = torrent.telemetry_generation.saturating_add(1);
if let Some(generation) = generation {
torrent.generation = Some(generation);
}
Ok(stream_url)
Ok(TorrentStreamStart {
url: stream_url,
telemetry_context: TorrentTelemetryContext {
link,
generation: torrent.telemetry_generation,
},
})
}
pub(crate) async fn resolve_torrent_download_url(
@ -226,7 +252,9 @@ pub async fn stop_torrent_stream(state: State<'_, DesktopState>) -> Result<bool,
};
let was_playing = link.is_some();
if let (Some(base_url), Some(link)) = (base_url, link) {
tauri::async_runtime::spawn_blocking(move || torrent_transport::deactivate(&base_url, &link));
tauri::async_runtime::spawn_blocking(move || {
torrent_transport::deactivate(&base_url, &link)
});
}
Ok(was_playing)
}
@ -263,21 +291,21 @@ pub async fn player_torrent_telemetry(
event: String,
elapsed_ms: Option<u64>,
session_id: String,
context: TorrentTelemetryContext,
) -> Result<bool, String> {
let (base_url, link, telemetry_generation) = {
let torrent = state.torrent.lock().map_err(|_| "torrent state unavailable")?;
(
torrent.server_base_url.clone(),
torrent.stream_link.clone(),
torrent.telemetry_generation,
)
let base_url = {
let torrent = state
.torrent
.lock()
.map_err(|_| "torrent state unavailable")?;
torrent.server_base_url.clone()
};
let (Some(base_url), Some(link)) = (base_url, link) else {
let Some(base_url) = base_url else {
return Ok(false);
};
let response = reqwest::Client::new()
.post(format!("{}/telemetry", base_url.trim_end_matches('/')))
.json(&json!({ "link": link, "sessionId": session_id, "sessionGeneration": telemetry_generation, "event": event, "elapsedMs": elapsed_ms }))
.json(&json!({ "link": context.link, "sessionId": session_id, "sessionGeneration": context.generation, "event": event, "elapsedMs": elapsed_ms }))
.timeout(std::time::Duration::from_secs(3))
.send()
.await

View file

@ -112,7 +112,7 @@ export default function App() {
setWelcomeCompleted,
} = useAppInit(updateState, setActiveRoute, storedPrefsRef);
const { playerLoadingOverlay, playerUrl, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, playingStreamRef, playingMetaRef, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit, skipSegmentCoverage } = usePlayer({
const { playerLoadingOverlay, playerUrl, playerTorrentTelemetryContext, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, playingStreamRef, playingMetaRef, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit, skipSegmentCoverage } = usePlayer({
stateRef,
activeProfile,
updateState,
@ -529,6 +529,7 @@ export default function App() {
streamRef={playingStreamRef}
metaRef={playingMetaRef}
playbackUrl={playerUrl}
torrentTelemetryContext={playerTorrentTelemetryContext}
prefs={prefs}
playbackError={playerPlaybackError}
subtitleWarning={playerSubtitleWarning}

View file

@ -4,6 +4,7 @@ import { ErrorBoundary } from './ErrorBoundary';
import { ReactPlayerOverlay } from '../appScreens';
import type { Meta, Stream, Video } from '../core/types';
import type { PlayerLoadingOverlayState } from '../hooks/usePlayer';
import type { TorrentTelemetryContext } from '../core/mpvPlayer';
interface Props {
active: boolean;
@ -22,6 +23,7 @@ interface Props {
streamRef: RefObject<Stream | null>;
metaRef: RefObject<Meta | null>;
playbackUrl: string | null;
torrentTelemetryContext: TorrentTelemetryContext | null;
prefs: Record<string, unknown>;
playbackError: string | null;
subtitleWarning: string[] | null;
@ -49,6 +51,7 @@ export function PlaybackHost({
streamRef,
metaRef,
playbackUrl,
torrentTelemetryContext,
prefs,
playbackError,
subtitleWarning,
@ -92,6 +95,7 @@ export function PlaybackHost({
streamRef={streamRef}
metaRef={metaRef}
playbackUrl={playbackUrl}
torrentTelemetryContext={torrentTelemetryContext}
prefs={prefs}
onDispatch={dispatch}
playbackError={playbackError}

View file

@ -3,6 +3,7 @@ import { t } from '../i18n';
import { getCurrentWindow } from '@tauri-apps/api/window';
import type { EmbeddedMpvStatus, TorrentStats } from '../core/mpvPlayer';
import { embeddedMpvSetCursorVisible } from '../core/mpvPlayer';
import type { TorrentTelemetryContext } from '../core/mpvPlayer';
import type { Meta, Stream, Video } from '../core/types';
import type { EpisodeInfo } from './player/EpisodePanel';
import { streamShellPlan } from '../core/streamLinks';
@ -58,6 +59,7 @@ interface Props {
streamRef?: RefObject<Stream | null>;
metaRef?: RefObject<Meta | null>;
playbackUrl?: string | null;
torrentTelemetryContext?: TorrentTelemetryContext | null;
playbackError?: string | null;
subtitleWarning?: string[] | null;
onDismissSubtitleWarning?: () => void;
@ -68,7 +70,7 @@ interface Props {
skipSegmentCoverage?: Record<string, string[]>;
}
export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, initialEpisodeTitle, currentEpisode, isTorrentStream = false, initialPosterUrl, initialLogoUrl, metaId, initialSubtitleUrl, initialStreamHeaders, streamRef, metaRef, playbackUrl, playbackError, subtitleWarning, onDismissSubtitleWarning, softwareVideoActive = false, bannerOffset = 0, prefs, onDispatch, skipSegmentCoverage }: Props) {
export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, initialEpisodeTitle, currentEpisode, isTorrentStream = false, initialPosterUrl, initialLogoUrl, metaId, initialSubtitleUrl, initialStreamHeaders, streamRef, metaRef, playbackUrl, torrentTelemetryContext, playbackError, subtitleWarning, onDismissSubtitleWarning, softwareVideoActive = false, bannerOffset = 0, prefs, onDispatch, skipSegmentCoverage }: Props) {
const playerTelemetry = usePlayerTelemetryState();
const { paused, muted, volumeLevel, isBuffering, bufferingProgress, hdrLabel, statsSnap, torrentStatsSnap, torrentSpeedHistory, setPaused, setMuted, setVolumeLevel, resetTorrentSpeedHistory } = playerTelemetry;
const [controlsVisible, setControlsVisible] = useState(true);
@ -205,7 +207,7 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
const { applyFills, onSeekMouseDown } = usePlayerSeekInteractions({ durRef, lastSeekAtRef, activeCastDeviceIdRef, seekbarRef, seekFillRef, seekBufferRef, seekDotRef, chapterSegmentsRef, segmentFillRefs: segFillRefs, segmentBufferRefs: segBufRefs, isDraggingRef, dragPosRef, startSeekOverlay, resetActivity });
usePlayerLiveTelemetry({
skipSegments, nextEpSubtitle, nextEpThreshold, nextEpDismissed, trackPopover, title, episodeTitle, initialPosterUrl, metaId, autoSkipSegments, isTorrentStream, playbackUrl, showStats, showTorrentPopover, controlsVisible, onFirstFrame, applyFills, flashFeedback,
skipSegments, nextEpSubtitle, nextEpThreshold, nextEpDismissed, trackPopover, title, episodeTitle, initialPosterUrl, metaId, autoSkipSegments, isTorrentStream, playbackUrl, torrentTelemetryContext, showStats, showTorrentPopover, controlsVisible, onFirstFrame, applyFills, flashFeedback,
telemetry: playerTelemetry, setShowSeekOverlay, setControlsVisible, setActiveSkip, setShowNextEpCard,
liveStatusRef, torrentStatsRef, prevPausedForCacheRef, stallCountRef, bufferHistoryRef, netSpeedHistoryRef, posRef, durRef, pausedRef, firstFrameFiredRef, hasAppliedInitialFillRef, currentTimeRef, durationRef, lastSeekAtRef, isDraggingRef, seekOverlayTimerRef, lastActivityRef, controlsVisibleRef, overlayRef, episodePanelOpenRef, isOverControlsRef, miniProgressRef, activeSkipKeyRef, autoSkippedKeysRef, skipFillRef, discordPresenceKeyRef, discordPresenceSentAtRef,
});

View file

@ -1,7 +1,7 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { useEffect, useRef, type Dispatch, type MutableRefObject, type RefObject, type SetStateAction } from 'react';
import { imdbButtonFor, updateDiscordPresence } from '../../core/discordPresence';
import { embeddedMpvSetCursorVisible, playerTorrentStats, playerTorrentTelemetry, type EmbeddedMpvStatus, type TorrentStats } from '../../core/mpvPlayer';
import { embeddedMpvSetCursorVisible, playerTorrentStats, playerTorrentTelemetry, type EmbeddedMpvStatus, type TorrentStats, type TorrentTelemetryContext } from '../../core/mpvPlayer';
import { setPlayerStatusPositionInterval, subscribePlayerStatus } from '../../core/playerStatusStore';
import { t } from '../../i18n';
import { addSparklineSample, fmtTime, sendCmd, skipLabelForType, type ActiveSkip, type FeedbackFlash, type SkipSegment } from './PlayerOverlayPrimitives';
@ -23,6 +23,7 @@ type Bindings = {
autoSkipSegments: boolean;
isTorrentStream: boolean;
playbackUrl?: string | null;
torrentTelemetryContext?: TorrentTelemetryContext | null;
showStats: boolean;
showTorrentPopover: boolean;
onFirstFrame?: () => void;
@ -78,7 +79,7 @@ export function usePlayerLiveTelemetry(options: Bindings) {
options.firstFrameFiredRef.current = false;
options.prevPausedForCacheRef.current = false;
options.stallCountRef.current = 0;
}, [options.playbackUrl]);
}, [options.playbackUrl, options.torrentTelemetryContext?.generation]);
useEffect(() => {
let lastBufferUpdate = 0;
@ -100,9 +101,9 @@ export function usePlayerLiveTelemetry(options: Bindings) {
if (pausedForCache && !prevPausedForCacheRef.current) {
stallCountRef.current++;
stallStartedAtRef.current = now;
if (isTorrentStream) void playerTorrentTelemetry('stallStarted', undefined, telemetrySessionIdRef.current);
if (isTorrentStream && optionsRef.current.torrentTelemetryContext) void playerTorrentTelemetry('stallStarted', undefined, telemetrySessionIdRef.current, optionsRef.current.torrentTelemetryContext);
} else if (!pausedForCache && prevPausedForCacheRef.current && stallStartedAtRef.current != null) {
if (isTorrentStream) void playerTorrentTelemetry('stallEnded', now - stallStartedAtRef.current, telemetrySessionIdRef.current);
if (isTorrentStream && optionsRef.current.torrentTelemetryContext) void playerTorrentTelemetry('stallEnded', now - stallStartedAtRef.current, telemetrySessionIdRef.current, optionsRef.current.torrentTelemetryContext);
stallStartedAtRef.current = null;
}
prevPausedForCacheRef.current = pausedForCache;
@ -144,7 +145,7 @@ export function usePlayerLiveTelemetry(options: Bindings) {
const activeAudioOnlyPlayback = status.loaded && status.trackListReady && !status.hasVideoTrack && status.pausedForCache !== 'yes' && pos > 0.05;
if (renderedVideo || activeAudioOnlyPlayback) {
firstFrameFiredRef.current = true;
if (isTorrentStream) void playerTorrentTelemetry('firstFrame', now - playbackStartedAtRef.current, telemetrySessionIdRef.current);
if (isTorrentStream && optionsRef.current.torrentTelemetryContext) void playerTorrentTelemetry('firstFrame', now - playbackStartedAtRef.current, telemetrySessionIdRef.current, optionsRef.current.torrentTelemetryContext);
onFirstFrame();
}
}

View file

@ -73,8 +73,8 @@ async function startTorrentFromEffect(payload: Record<string, unknown>): Promise
}
const title = typeof payload.title === 'string' ? payload.title : undefined;
const prefs = await loadPrefs();
const url = await startTorrentStream(JSON.stringify(stream), title, prefs);
return { url };
const started = await startTorrentStream(JSON.stringify(stream), title, prefs);
return { url: started.url };
}
async function executeYoutubeTrailerRequest(payload: Record<string, unknown>): Promise<unknown> {

View file

@ -1,12 +1,22 @@
import { invoke } from '@tauri-apps/api/core';
export type TorrentTelemetryContext = {
link: string;
generation: number;
};
export type TorrentStreamStart = {
url: string;
telemetryContext: TorrentTelemetryContext;
};
export async function startTorrentStream(
streamJson: string,
title?: string,
preferences?: Record<string, unknown>,
durationMs?: number,
): Promise<string> {
return invoke<string>('start_torrent_stream', { streamJson, title: title ?? null, preferences: preferences ?? null, durationMs: durationMs ?? null });
): Promise<TorrentStreamStart> {
return invoke<TorrentStreamStart>('start_torrent_stream', { streamJson, title: title ?? null, preferences: preferences ?? null, durationMs: durationMs ?? null });
}
export async function stopTorrentStream(): Promise<boolean> {
@ -102,8 +112,8 @@ export async function playerTorrentStats(): Promise<TorrentStats | null> {
return invoke<TorrentStats | null>('player_torrent_stats');
}
export async function playerTorrentTelemetry(event: 'firstFrame' | 'stallStarted' | 'stallEnded', elapsedMs: number | undefined, sessionId: string): Promise<boolean> {
return invoke<boolean>('player_torrent_telemetry', { event, elapsedMs: elapsedMs ?? null, sessionId }).catch(() => false);
export async function playerTorrentTelemetry(event: 'firstFrame' | 'stallStarted' | 'stallEnded', elapsedMs: number | undefined, sessionId: string, context: TorrentTelemetryContext): Promise<boolean> {
return invoke<boolean>('player_torrent_telemetry', { event, elapsedMs: elapsedMs ?? null, sessionId, context }).catch(() => false);
}
export async function initEmbeddedMpv(): Promise<void> {

View file

@ -87,6 +87,7 @@ interface UsePlayerOptions {
interface UsePlayerResult {
playerLoadingOverlay: PlayerLoadingOverlayState | null;
playerUrl: string | null;
playerTorrentTelemetryContext: import('../core/mpvPlayer').TorrentTelemetryContext | null;
playerTitle: string | undefined;
playerEpisodeTitle: string | undefined;
playerEpisode: Video | null;
@ -110,6 +111,7 @@ interface UsePlayerResult {
export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdated, onEpisodePlaybackFailed }: UsePlayerOptions): UsePlayerResult {
const [playerUrl, setPlayerUrl] = useState<string | null>(null);
const [playerTorrentTelemetryContext, setPlayerTorrentTelemetryContext] = useState<import('../core/mpvPlayer').TorrentTelemetryContext | null>(null);
const [playerTitle, setPlayerTitle] = useState<string | undefined>();
const [playerEpisodeTitle, setPlayerEpisodeTitle] = useState<string | undefined>();
const [playerEpisode, setPlayerEpisode] = useState<Video | null>(null);
@ -181,6 +183,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
playbackScopeRef.current.invalidate();
const shouldStopTorrent = playerUsesTorrentRef.current;
setPlayerUrl(null);
setPlayerTorrentTelemetryContext(null);
setPlayerSubtitleUrl(undefined);
setPlayerStreamHeaders(undefined);
setPlayerUsesTorrent(false);
@ -281,6 +284,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
const captureStream = playingStreamRef.current;
const shouldStopTorrent = playerUsesTorrentRef.current;
setPlayerUrl(null);
setPlayerTorrentTelemetryContext(null);
setPlayerTitle(undefined);
setPlayerEpisode(null);
setPlayerPosterUrl(undefined);
@ -452,5 +456,5 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
setPlayerSubtitleWarning(null);
}, []);
return { playerLoadingOverlay, playerUrl, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, playingStreamRef, playingMetaRef, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit: saveProgressTick, skipSegmentCoverage };
return { playerLoadingOverlay, playerUrl, playerTorrentTelemetryContext, playerPlaybackError, playerSubtitleWarning, dismissSubtitleWarning, playerTitle, playerEpisodeTitle, playerEpisode, playerUsesTorrent, playerPosterUrl, playerLogoUrl, playerMetaId, playerSubtitleUrl, playerStreamHeaders, playingStreamRef, playingMetaRef, handlePlay, closePlayer, notifyFirstFrame, flushProgressOnQuit: saveProgressTick, skipSegmentCoverage };
}

View file

@ -9,11 +9,11 @@ import { getLanguage, t } from '../i18n';
import { formatNextEpisodeSubtitle } from '../core/playerUtils';
import type { PlaybackPreparePlan } from '../core/playerUtils';
import { resolvePlaybackSubtitles, type ResolvedSubtitles } from '../core/subtitles';
import { embeddedMpvSetLoadingArtwork, embeddedMpvStatus, playerClearChapters, playerClearSkipInfo, playerSetEpisodes, playerSetSkipInfo, playerTorrentStats, startTorrentStream } from '../core/mpvPlayer';
import { embeddedMpvSetLoadingArtwork, embeddedMpvStatus, playerClearChapters, playerClearSkipInfo, playerSetEpisodes, playerSetSkipInfo, playerTorrentStats, startTorrentStream, type TorrentTelemetryContext } from '../core/mpvPlayer';
import type { AddonDescriptor, Meta, Stream, Video } from '../core/types';
export function usePlayerPlaybackStart(options: any) {
const { stateRef, onEpisodePlaybackFailed, playbackScope, scrobbleStartedRef, scrobbleStoppedRef, scrobbleWasPausedRef, setPlayerPlaybackError, setPlayerSubtitleWarning, openSourcePickerOnFailureRef, setPlayerUrl, playingSourceCandidatesRef, attemptedSourceKeysRef, setPlayerUsesTorrent, prefetchedNextEpRef, playingMetaRef, playingEpisodeRef, playingNextEpisodeRef, playingStreamRef, lastResumeAtSecondsRef, lastTotalDurationSecondsRef, setPlayerEpisode, playerDisplayTitle, playerArtwork, setPlayerPosterUrl, setPlayerLogoUrl, setPlayerMetaId, setPlayerStreamHeaders, artworkPrefetchRef, prefetchPlayerArtwork, showPlayerLoading, pendingArtworkRef, inNativePlayerRef, setPlayerLoadingOverlay, setLoadingStatus, playerLoadingOverlayRef, playInEmbeddedMpv, nextRetrySource, failPlayerLoading, debugLog, playbackErrorMessage, setSkipSegmentCoverage } = options;
const { stateRef, onEpisodePlaybackFailed, playbackScope, scrobbleStartedRef, scrobbleStoppedRef, scrobbleWasPausedRef, setPlayerPlaybackError, setPlayerSubtitleWarning, openSourcePickerOnFailureRef, setPlayerUrl, setPlayerTorrentTelemetryContext, playingSourceCandidatesRef, attemptedSourceKeysRef, setPlayerUsesTorrent, prefetchedNextEpRef, playingMetaRef, playingEpisodeRef, playingNextEpisodeRef, playingStreamRef, lastResumeAtSecondsRef, lastTotalDurationSecondsRef, setPlayerEpisode, playerDisplayTitle, playerArtwork, setPlayerPosterUrl, setPlayerLogoUrl, setPlayerMetaId, setPlayerStreamHeaders, artworkPrefetchRef, prefetchPlayerArtwork, showPlayerLoading, pendingArtworkRef, inNativePlayerRef, setPlayerLoadingOverlay, setLoadingStatus, playerLoadingOverlayRef, playInEmbeddedMpv, nextRetrySource, failPlayerLoading, debugLog, playbackErrorMessage, setSkipSegmentCoverage } = options;
const handlePlay = useCallback(async (
stream: Stream,
meta?: Meta,
@ -47,6 +47,7 @@ export function usePlayerPlaybackStart(options: any) {
openSourcePickerOnFailureRef.current = openSourcePickerOnFailure;
setPlayerUrl(null);
setPlayerTorrentTelemetryContext(null);
const streamPlan = await coreStreamShellPlan(stream);
const currentStreamKey = streamPlan?.identityKey ?? '';
const candidatePlans = await Promise.all(playingSourceCandidatesRef.current.map(coreStreamShellPlan));
@ -292,6 +293,7 @@ export function usePlayerPlaybackStart(options: any) {
};
try {
let localUrl: string | null = null;
let telemetryContext: TorrentTelemetryContext | null = null;
for (let retryIndex = 0; retryIndex <= MAX_PEER_RETRIES; retryIndex++) {
try {
debugLog(`handlePlay:starting torrent stream retryIndex=${retryIndex}`);
@ -304,7 +306,9 @@ export function usePlayerPlaybackStart(options: any) {
&& effectiveTotalDuration > 0
? Math.round(effectiveTotalDuration * 1000)
: undefined;
localUrl = await startTorrentStream(JSON.stringify(stream), title.contentTitle, appPrefs(stateRef.current), torrentDurationMs);
const started = await startTorrentStream(JSON.stringify(stream), title.contentTitle, appPrefs(stateRef.current), torrentDurationMs);
localUrl = started.url;
telemetryContext = started.telemetryContext;
debugLog(`handlePlay:torrent stream started localUrl=${localUrl?.slice(0, 80)}`);
if (isCancelled()) { statusPollActive = false; return; }
await waitForTorrentReady(retryIndex === 0 ? TORRENT_READY_FIRST_ATTEMPT_MS : TORRENT_READY_PER_RETRY_MS);
@ -319,6 +323,7 @@ export function usePlayerPlaybackStart(options: any) {
}
}
if (isCancelled() || !localUrl) return;
setPlayerTorrentTelemetryContext(telemetryContext);
setLoadingStatus(t('player.status_loading_stream'));
void pollMpvLoadingStatus();
await playInEmbeddedMpv(generation, localUrl, title, true, subtitlesPromise, loadingArtworkPromise, resumeAtSeconds, effectiveTotalDuration, undefined, animeDetection.isAnime);