diff --git a/src-tauri/src/commands/video.rs b/src-tauri/src/commands/video.rs index 697c882..a511930 100644 --- a/src-tauri/src/commands/video.rs +++ b/src-tauri/src/commands/video.rs @@ -1 +1,12 @@ -// Video resolve and download commands — implemented in Task 4/5 +use crate::models::{CookieSource, VideoMetadata}; +use crate::services::video_resolver; + +#[tauri::command] +pub async fn resolve_url( + url: String, + cookie_source: CookieSource, +) -> Result { + tokio::task::spawn_blocking(move || video_resolver::resolve(&url, &cookie_source)) + .await + .map_err(|e| format!("Task failed: {e}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6de1fb..8fe41b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ mod commands; mod models; mod services; -use commands::dependencies; +use commands::{dependencies, video}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -14,6 +14,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ dependencies::check_dependencies, dependencies::install_dependency, + video::resolve_url, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/services/video_resolver.rs b/src-tauri/src/services/video_resolver.rs index b21aa50..20ff3fb 100644 --- a/src-tauri/src/services/video_resolver.rs +++ b/src-tauri/src/services/video_resolver.rs @@ -1 +1,105 @@ -// Implemented in Task 4 +use crate::models::{CookieSource, VideoMetadata}; +use serde::Deserialize; +use std::process::Command; + +#[derive(Debug, Deserialize)] +pub struct YtDlpJson { + pub title: String, + pub duration: Option, + pub fps: Option, + pub thumbnail: Option, +} + +pub fn parse_ytdlp_json(json_str: &str) -> Result { + serde_json::from_str::(json_str) + .map_err(|e| format!("Failed to parse yt-dlp JSON: {e}")) +} + +pub fn resolve(url: &str, cookie_source: &CookieSource) -> Result { + let cookie_args = cookie_source.to_ytdlp_args(); + + let mut meta_cmd = Command::new("yt-dlp"); + meta_cmd.arg("--dump-json").arg("--no-download"); + for arg in &cookie_args { + meta_cmd.arg(arg); + } + meta_cmd.arg(url); + + let meta_output = meta_cmd + .output() + .map_err(|e| format!("Failed to run yt-dlp: {e}"))?; + + if !meta_output.status.success() { + let stderr = String::from_utf8_lossy(&meta_output.stderr); + return Err(format!("yt-dlp metadata failed: {stderr}")); + } + + let json_str = String::from_utf8_lossy(&meta_output.stdout); + let meta = parse_ytdlp_json(&json_str)?; + + let mut stream_cmd = Command::new("yt-dlp"); + stream_cmd.arg("-g").arg("--no-download"); + for arg in &cookie_args { + stream_cmd.arg(arg); + } + stream_cmd.arg(url); + + let stream_output = stream_cmd + .output() + .map_err(|e| format!("Failed to get stream URL: {e}"))?; + + if !stream_output.status.success() { + let stderr = String::from_utf8_lossy(&stream_output.stderr); + return Err(format!("yt-dlp stream URL failed: {stderr}")); + } + + let stream_url = String::from_utf8_lossy(&stream_output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + + Ok(VideoMetadata { + url: url.to_string(), + title: meta.title, + duration: meta.duration.unwrap_or(0.0), + fps: meta.fps.unwrap_or(30.0), + thumbnail_url: meta.thumbnail, + stream_url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ytdlp_json_valid() { + let json = r#"{"title": "Test Video", "duration": 125.5, "fps": 29.97, "thumbnail": "https://i.ytimg.com/vi/abc/hqdefault.jpg"}"#; + let result = parse_ytdlp_json(json).unwrap(); + assert_eq!(result.title, "Test Video"); + assert!((result.duration.unwrap() - 125.5).abs() < 0.001); + assert!((result.fps.unwrap() - 29.97).abs() < 0.001); + assert_eq!( + result.thumbnail.unwrap(), + "https://i.ytimg.com/vi/abc/hqdefault.jpg" + ); + } + + #[test] + fn test_parse_ytdlp_json_missing_optional_fields() { + let json = r#"{"title": "Minimal Video"}"#; + let result = parse_ytdlp_json(json).unwrap(); + assert_eq!(result.title, "Minimal Video"); + assert!(result.duration.is_none()); + assert!(result.fps.is_none()); + assert!(result.thumbnail.is_none()); + } + + #[test] + fn test_parse_ytdlp_json_invalid() { + let result = parse_ytdlp_json("not json"); + assert!(result.is_err()); + } +} diff --git a/src/lib/bindings/mediaAnalysis.ts b/src/lib/bindings/mediaAnalysis.ts new file mode 100644 index 0000000..8c21ef5 --- /dev/null +++ b/src/lib/bindings/mediaAnalysis.ts @@ -0,0 +1,9 @@ +export interface ThumbnailSpritesheet { + filePath: string; + startIndex: number; + count: number; + thumbWidth: number; + thumbHeight: number; + columns: number; + intervalSeconds: number; +} diff --git a/src/lib/bindings/video.ts b/src/lib/bindings/video.ts new file mode 100644 index 0000000..83a6c72 --- /dev/null +++ b/src/lib/bindings/video.ts @@ -0,0 +1,18 @@ +import { invoke } from '@tauri-apps/api/core'; +import { preferences } from '$lib/stores/preferences.svelte'; + +export interface VideoMetadata { + url: string; + title: string; + duration: number; + fps: number; + thumbnailUrl: string | null; + streamUrl: string; +} + +export async function resolveUrl(url: string): Promise { + return invoke('resolve_url', { + url, + cookieSource: preferences.cookieSource, + }); +} diff --git a/src/lib/components/UrlInput.svelte b/src/lib/components/UrlInput.svelte index 5638bfa..ca2af36 100644 --- a/src/lib/components/UrlInput.svelte +++ b/src/lib/components/UrlInput.svelte @@ -1,12 +1,78 @@ + +
- + + {#if session.status === 'resolving'} + ⏳ + {/if} + {#if session.error} + ⚠ + {/if}
diff --git a/src/lib/components/VideoPlayer.svelte b/src/lib/components/VideoPlayer.svelte index fa9d751..968f741 100644 --- a/src/lib/components/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer.svelte @@ -1,5 +1,70 @@ + +
-
Paste a URL above to load a video
+ {#if session.status === 'ready' && session.streamUrl} + + {:else if session.status === 'resolving'} +
Loading video…
+ {:else if session.status === 'error'} +
Failed to load video. Check the URL and try again.
+ {:else} +
Paste a URL above to load a video
+ {/if}
diff --git a/src/lib/stores/videoSession.svelte.ts b/src/lib/stores/videoSession.svelte.ts new file mode 100644 index 0000000..fbc048b --- /dev/null +++ b/src/lib/stores/videoSession.svelte.ts @@ -0,0 +1,67 @@ +import type { VideoMetadata } from '$lib/bindings/video'; +import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis'; + +export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error'; + +export const session = $state({ + status: 'idle' as SessionStatus, + error: null as string | null, + url: '', + title: '', + duration: 0, + fps: 30, + thumbnailUrl: null as string | null, + streamUrl: '', + currentTime: 0, + isPlaying: false, + localFilePath: null as string | null, + downloadProgress: 0, + keyframePositions: [] as number[], + waveformPeaks: [] as number[], + thumbnailSpritesheets: [] as ThumbnailSpritesheet[], +}); + +export function setMetadata(meta: VideoMetadata) { + session.url = meta.url; + session.title = meta.title; + session.duration = meta.duration; + session.fps = meta.fps; + session.thumbnailUrl = meta.thumbnailUrl; + session.streamUrl = meta.streamUrl; + session.status = 'ready'; + session.error = null; + + session.localFilePath = null; + session.downloadProgress = 0; + session.keyframePositions = []; + session.waveformPeaks = []; + session.thumbnailSpritesheets = []; +} + +export function setError(msg: string) { + session.status = 'error'; + session.error = msg; +} + +export function setResolving() { + session.status = 'resolving'; + session.error = null; +} + +export function reset() { + session.status = 'idle'; + session.error = null; + session.url = ''; + session.title = ''; + session.duration = 0; + session.fps = 30; + session.thumbnailUrl = null; + session.streamUrl = ''; + session.currentTime = 0; + session.isPlaying = false; + session.localFilePath = null; + session.downloadProgress = 0; + session.keyframePositions = []; + session.waveformPeaks = []; + session.thumbnailSpritesheets = []; +}