diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cb27d7c..8355725 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2087,6 +2087,7 @@ dependencies = [ "sha1", "sha2 0.11.0", "tokio", + "url", "web-time", ] diff --git a/src-tauri/src/net_guard.rs b/src-tauri/src/net_guard.rs index af5bff8..7706018 100644 --- a/src-tauri/src/net_guard.rs +++ b/src-tauri/src/net_guard.rs @@ -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 { + 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::*; diff --git a/src-tauri/src/plugin_executor.rs b/src-tauri/src/plugin_executor.rs index 207a488..abc8d95 100644 --- a/src-tauri/src/plugin_executor.rs +++ b/src-tauri/src/plugin_executor.rs @@ -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, + }; } }) } diff --git a/src-tauri/src/torrent_stream.rs b/src-tauri/src/torrent_stream.rs index 9d01df6..e1ae3fa 100644 --- a/src-tauri/src/torrent_stream.rs +++ b/src-tauri/src/torrent_stream.rs @@ -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 { FluxaCore::stream_magnet_link_json(&stream_json) } -async fn ensure_healthy_torrent_base_url( - state: &State<'_, DesktopState>, -) -> Option { +async fn ensure_healthy_torrent_base_url(state: &State<'_, DesktopState>) -> Option { 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, preferences: Option, duration_ms: Option, -) -> Result { +) -> Result { 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, session_id: String, + context: TorrentTelemetryContext, ) -> Result { - 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 diff --git a/src/App.tsx b/src/App.tsx index 2c49656..a0767d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} diff --git a/src/components/PlaybackHost.tsx b/src/components/PlaybackHost.tsx index 71fd558..1f027c1 100644 --- a/src/components/PlaybackHost.tsx +++ b/src/components/PlaybackHost.tsx @@ -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; metaRef: RefObject; playbackUrl: string | null; + torrentTelemetryContext: TorrentTelemetryContext | null; prefs: Record; 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} diff --git a/src/components/ReactPlayerOverlay.tsx b/src/components/ReactPlayerOverlay.tsx index 6f654a2..2442f15 100644 --- a/src/components/ReactPlayerOverlay.tsx +++ b/src/components/ReactPlayerOverlay.tsx @@ -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; metaRef?: RefObject; playbackUrl?: string | null; + torrentTelemetryContext?: TorrentTelemetryContext | null; playbackError?: string | null; subtitleWarning?: string[] | null; onDismissSubtitleWarning?: () => void; @@ -68,7 +70,7 @@ interface Props { skipSegmentCoverage?: Record; } -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, }); diff --git a/src/components/player/usePlayerLiveTelemetry.ts b/src/components/player/usePlayerLiveTelemetry.ts index 128a9dd..d9c26c8 100644 --- a/src/components/player/usePlayerLiveTelemetry.ts +++ b/src/components/player/usePlayerLiveTelemetry.ts @@ -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(); } } diff --git a/src/core/effectRunner.ts b/src/core/effectRunner.ts index 64ac2ae..59d5a7d 100644 --- a/src/core/effectRunner.ts +++ b/src/core/effectRunner.ts @@ -73,8 +73,8 @@ async function startTorrentFromEffect(payload: Record): 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): Promise { diff --git a/src/core/mpvPlayer.ts b/src/core/mpvPlayer.ts index c6fe1b0..9478bfe 100644 --- a/src/core/mpvPlayer.ts +++ b/src/core/mpvPlayer.ts @@ -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, durationMs?: number, -): Promise { - return invoke('start_torrent_stream', { streamJson, title: title ?? null, preferences: preferences ?? null, durationMs: durationMs ?? null }); +): Promise { + return invoke('start_torrent_stream', { streamJson, title: title ?? null, preferences: preferences ?? null, durationMs: durationMs ?? null }); } export async function stopTorrentStream(): Promise { @@ -102,8 +112,8 @@ export async function playerTorrentStats(): Promise { return invoke('player_torrent_stats'); } -export async function playerTorrentTelemetry(event: 'firstFrame' | 'stallStarted' | 'stallEnded', elapsedMs: number | undefined, sessionId: string): Promise { - return invoke('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 { + return invoke('player_torrent_telemetry', { event, elapsedMs: elapsedMs ?? null, sessionId, context }).catch(() => false); } export async function initEmbeddedMpv(): Promise { diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index 939be06..ed22426 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -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(null); + const [playerTorrentTelemetryContext, setPlayerTorrentTelemetryContext] = useState(null); const [playerTitle, setPlayerTitle] = useState(); const [playerEpisodeTitle, setPlayerEpisodeTitle] = useState(); const [playerEpisode, setPlayerEpisode] = useState