feat: add background download manager with progress reporting
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
use crate::models::{CookieSource, VideoMetadata};
|
use crate::models::{CookieSource, VideoMetadata};
|
||||||
use crate::services::video_resolver;
|
use crate::services::{download_manager, video_resolver};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn resolve_url(
|
pub async fn resolve_url(
|
||||||
@@ -10,3 +13,76 @@ pub async fn resolve_url(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task failed: {e}"))?
|
.map_err(|e| format!("Task failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
|
||||||
|
pub enum DownloadEvent {
|
||||||
|
Progress { percent: f64 },
|
||||||
|
FilePath { path: String },
|
||||||
|
Finished { success: bool, path: String },
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_download(
|
||||||
|
url: String,
|
||||||
|
cookie_source: CookieSource,
|
||||||
|
on_event: Channel<DownloadEvent>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let temp_dir = std::env::temp_dir()
|
||||||
|
.join("video-clipper")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
std::fs::create_dir_all(&temp_dir)
|
||||||
|
.map_err(|e| format!("Failed to create temp dir: {e}"))?;
|
||||||
|
|
||||||
|
let (mut child, _template) =
|
||||||
|
download_manager::start_download(&url, &cookie_source, &temp_dir)?;
|
||||||
|
|
||||||
|
let Some(stderr) = child.stderr.take() else {
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.map_err(|e| format!("Download failed: {e}"))?;
|
||||||
|
let _ = on_event.send(DownloadEvent::Finished {
|
||||||
|
success: status.success(),
|
||||||
|
path: String::new(),
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
let mut last_file_path: Option<String> = None;
|
||||||
|
|
||||||
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
|
if let Some(pct) = download_manager::parse_progress_line(&line) {
|
||||||
|
let _ = on_event.send(DownloadEvent::Progress { percent: pct });
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.contains("[download] Destination:")
|
||||||
|
|| line.contains("[Merger] Merging formats into")
|
||||||
|
{
|
||||||
|
if let Some(path) = line.split(": ").nth(1) {
|
||||||
|
let trimmed = path.trim().trim_matches('"').to_string();
|
||||||
|
let _ = on_event.send(DownloadEvent::FilePath {
|
||||||
|
path: trimmed.clone(),
|
||||||
|
});
|
||||||
|
last_file_path = Some(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.map_err(|e| format!("Download failed: {e}"))?;
|
||||||
|
let path = last_file_path.unwrap_or_default();
|
||||||
|
let _ = on_event.send(DownloadEvent::Finished {
|
||||||
|
success: status.success(),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Task failed: {e}"))?
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub fn run() {
|
|||||||
dependencies::check_dependencies,
|
dependencies::check_dependencies,
|
||||||
dependencies::install_dependency,
|
dependencies::install_dependency,
|
||||||
video::resolve_url,
|
video::resolve_url,
|
||||||
|
video::start_download,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1 +1,71 @@
|
|||||||
// Implemented in Task 5
|
use crate::models::CookieSource;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
|
||||||
|
pub fn parse_progress_line(line: &str) -> Option<f64> {
|
||||||
|
if !line.contains("[download]") || !line.contains('%') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pct_str = line.split_whitespace().find(|s| s.ends_with('%'))?;
|
||||||
|
pct_str
|
||||||
|
.trim_end_matches('%')
|
||||||
|
.parse::<f64>()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p / 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_download(
|
||||||
|
url: &str,
|
||||||
|
cookie_source: &CookieSource,
|
||||||
|
temp_dir: &str,
|
||||||
|
) -> Result<(Child, String), String> {
|
||||||
|
let output_template = format!("{}/%(title)s.%(ext)s", temp_dir);
|
||||||
|
|
||||||
|
let mut cmd = Command::new("yt-dlp");
|
||||||
|
cmd.arg("-o")
|
||||||
|
.arg(&output_template)
|
||||||
|
.arg("--newline")
|
||||||
|
.arg("--no-part")
|
||||||
|
.arg("-c");
|
||||||
|
|
||||||
|
for arg in cookie_source.to_ytdlp_args() {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg(url)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to start yt-dlp: {e}"))?;
|
||||||
|
|
||||||
|
Ok((child, output_template))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_progress_percentage() {
|
||||||
|
let line = "[download] 45.2% of ~100.00MiB at 5.00MiB/s ETA 00:10";
|
||||||
|
assert!((parse_progress_line(line).unwrap() - 0.452).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_progress_100_percent() {
|
||||||
|
let line = "[download] 100% of 50.00MiB in 00:05";
|
||||||
|
assert!((parse_progress_line(line).unwrap() - 1.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_progress_non_progress_line() {
|
||||||
|
let line = "[youtube] Extracting URL: https://www.youtube.com/watch?v=abc";
|
||||||
|
assert!(parse_progress_line(line).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_progress_empty_line() {
|
||||||
|
assert!(parse_progress_line("").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke, Channel } from '@tauri-apps/api/core';
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { preferences } from '$lib/stores/preferences.svelte';
|
||||||
|
|
||||||
export interface VideoMetadata {
|
export interface VideoMetadata {
|
||||||
@@ -10,9 +10,50 @@ export interface VideoMetadata {
|
|||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DownloadEvent =
|
||||||
|
| { event: 'progress'; data: { percent: number } }
|
||||||
|
| { event: 'filePath'; data: { path: string } }
|
||||||
|
| { event: 'finished'; data: { success: boolean; path: string } }
|
||||||
|
| { event: 'error'; data: { message: string } };
|
||||||
|
|
||||||
export async function resolveUrl(url: string): Promise<VideoMetadata> {
|
export async function resolveUrl(url: string): Promise<VideoMetadata> {
|
||||||
return invoke<VideoMetadata>('resolve_url', {
|
return invoke<VideoMetadata>('resolve_url', {
|
||||||
url,
|
url,
|
||||||
cookieSource: preferences.cookieSource,
|
cookieSource: preferences.cookieSource,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startDownload(
|
||||||
|
url: string,
|
||||||
|
onProgress: (percent: number) => void,
|
||||||
|
onFilePath: (path: string) => void,
|
||||||
|
onFinished: (success: boolean, path: string) => void,
|
||||||
|
onError: (message: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const onEvent = new Channel<DownloadEvent>();
|
||||||
|
onEvent.onmessage = (message) => {
|
||||||
|
switch (message.event) {
|
||||||
|
case 'progress':
|
||||||
|
onProgress(message.data.percent);
|
||||||
|
break;
|
||||||
|
case 'filePath':
|
||||||
|
onFilePath(message.data.path);
|
||||||
|
break;
|
||||||
|
case 'finished':
|
||||||
|
onFinished(message.data.success, message.data.path);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
onError(message.data.message);
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = message;
|
||||||
|
throw new Error(`Unhandled download event: ${(_exhaustive as DownloadEvent).event}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await invoke('start_download', {
|
||||||
|
url,
|
||||||
|
cookieSource: preferences.cookieSource,
|
||||||
|
onEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { session } from '$lib/stores/videoSession.svelte';
|
||||||
|
|
||||||
|
let progressPercent = $derived(Math.round(session.downloadProgress * 100));
|
||||||
|
let isDownloading = $derived(
|
||||||
|
session.status === 'ready' &&
|
||||||
|
session.downloadStatus === 'downloading' &&
|
||||||
|
session.downloadProgress > 0 &&
|
||||||
|
session.downloadProgress < 1
|
||||||
|
);
|
||||||
|
let isDownloaded = $derived(
|
||||||
|
session.localFilePath !== null &&
|
||||||
|
(session.downloadStatus === 'complete' || session.downloadProgress >= 1)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<span class="status-text">Ready</span>
|
{#if isDownloading}
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text">Downloading: {progressPercent}%</span>
|
||||||
|
{:else if isDownloaded}
|
||||||
|
<span class="status-text success">✓ Downloaded: {session.title}</span>
|
||||||
|
{:else if session.status === 'resolving'}
|
||||||
|
<span class="status-text">Resolving URL…</span>
|
||||||
|
{:else if session.status === 'ready' && session.downloadStatus === 'downloading'}
|
||||||
|
<span class="status-text">Starting download…</span>
|
||||||
|
{:else if session.downloadStatus === 'failed'}
|
||||||
|
<span class="status-text">Download failed</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status-text">Ready</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -12,5 +43,25 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--success);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolveUrl } from '$lib/bindings/video';
|
import { resolveUrl } from '$lib/bindings/video';
|
||||||
import { session, setMetadata, setError, setResolving } from '$lib/stores/videoSession.svelte';
|
import {
|
||||||
|
session,
|
||||||
|
setMetadata,
|
||||||
|
setError,
|
||||||
|
setResolving,
|
||||||
|
beginDownload,
|
||||||
|
} from '$lib/stores/videoSession.svelte';
|
||||||
|
|
||||||
let inputValue = $state('');
|
let inputValue = $state('');
|
||||||
|
|
||||||
@@ -12,6 +18,7 @@
|
|||||||
try {
|
try {
|
||||||
const meta = await resolveUrl(trimmed);
|
const meta = await resolveUrl(trimmed);
|
||||||
setMetadata(meta);
|
setMetadata(meta);
|
||||||
|
void beginDownload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { VideoMetadata } from '$lib/bindings/video';
|
import type { VideoMetadata } from '$lib/bindings/video';
|
||||||
|
import { startDownload } from '$lib/bindings/video';
|
||||||
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
|
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
|
||||||
|
|
||||||
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
|
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
|
||||||
|
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
|
||||||
|
|
||||||
export const session = $state({
|
export const session = $state({
|
||||||
status: 'idle' as SessionStatus,
|
status: 'idle' as SessionStatus,
|
||||||
@@ -16,6 +18,7 @@ export const session = $state({
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
localFilePath: null as string | null,
|
localFilePath: null as string | null,
|
||||||
downloadProgress: 0,
|
downloadProgress: 0,
|
||||||
|
downloadStatus: 'idle' as DownloadStatus,
|
||||||
keyframePositions: [] as number[],
|
keyframePositions: [] as number[],
|
||||||
waveformPeaks: [] as number[],
|
waveformPeaks: [] as number[],
|
||||||
thumbnailSpritesheets: [] as ThumbnailSpritesheet[],
|
thumbnailSpritesheets: [] as ThumbnailSpritesheet[],
|
||||||
@@ -33,6 +36,7 @@ export function setMetadata(meta: VideoMetadata) {
|
|||||||
|
|
||||||
session.localFilePath = null;
|
session.localFilePath = null;
|
||||||
session.downloadProgress = 0;
|
session.downloadProgress = 0;
|
||||||
|
session.downloadStatus = 'idle';
|
||||||
session.keyframePositions = [];
|
session.keyframePositions = [];
|
||||||
session.waveformPeaks = [];
|
session.waveformPeaks = [];
|
||||||
session.thumbnailSpritesheets = [];
|
session.thumbnailSpritesheets = [];
|
||||||
@@ -49,6 +53,7 @@ function clearMediaFields() {
|
|||||||
session.isPlaying = false;
|
session.isPlaying = false;
|
||||||
session.localFilePath = null;
|
session.localFilePath = null;
|
||||||
session.downloadProgress = 0;
|
session.downloadProgress = 0;
|
||||||
|
session.downloadStatus = 'idle';
|
||||||
session.keyframePositions = [];
|
session.keyframePositions = [];
|
||||||
session.waveformPeaks = [];
|
session.waveformPeaks = [];
|
||||||
session.thumbnailSpritesheets = [];
|
session.thumbnailSpritesheets = [];
|
||||||
@@ -71,3 +76,39 @@ export function reset() {
|
|||||||
session.status = 'idle';
|
session.status = 'idle';
|
||||||
session.error = null;
|
session.error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function beginDownload() {
|
||||||
|
if (!session.url) return;
|
||||||
|
|
||||||
|
session.downloadStatus = 'downloading';
|
||||||
|
session.downloadProgress = 0;
|
||||||
|
session.localFilePath = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startDownload(
|
||||||
|
session.url,
|
||||||
|
(percent) => {
|
||||||
|
session.downloadProgress = percent;
|
||||||
|
},
|
||||||
|
(path) => {
|
||||||
|
session.localFilePath = path;
|
||||||
|
},
|
||||||
|
(success, path) => {
|
||||||
|
if (success) {
|
||||||
|
session.downloadProgress = 1.0;
|
||||||
|
session.localFilePath = path;
|
||||||
|
session.downloadStatus = 'complete';
|
||||||
|
} else {
|
||||||
|
session.downloadStatus = 'failed';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(message) => {
|
||||||
|
session.downloadStatus = 'failed';
|
||||||
|
console.error('Download error:', message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
session.downloadStatus = 'failed';
|
||||||
|
console.error('Download failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user