mirror of
https://github.com/FluxaMedia/fluxa-desktop.git
synced 2026-08-06 16:59:20 +00:00
Bind telemetry to playback sessions
This commit is contained in:
parent
f74be653a7
commit
5e7df0002c
12 changed files with 177 additions and 69 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -2087,6 +2087,7 @@ dependencies = [
|
|||
"sha1",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"url",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue