diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml new file mode 100644 index 0000000..269345d --- /dev/null +++ b/.github/workflows/tauri-build.yml @@ -0,0 +1,60 @@ +name: "Build Tauri App" + +on: + push: + branches: + - main + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: "macos-latest" + args: "--target aarch64-apple-darwin" + - platform: "macos-latest" + args: "--target x86_64-apple-darwin" + - platform: "ubuntu-22.04" + args: "" + - platform: "windows-latest" + args: "" + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - name: install dependencies (ubuntu only) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: install npm dependencies + working-directory: ./apps/desktop + run: npm install + + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: ./apps/desktop + tagName: v__VERSION__ + releaseName: 'Firelink v__VERSION__' + releaseBody: 'See the assets to download this version and install.' + releaseDraft: true + prerelease: false + args: ${{ matrix.args }} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index babfa1d..fe32c12 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -80,6 +80,45 @@ async fn fetch_metadata(url: String, user_agent: Option) -> Result) -> Result { + println!("fetch_media_metadata called for: {}", url); + let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; + let ytdlp_path = resource_dir.join("binaries").join("yt-dlp"); + + let mut cmd = AsyncCommand::new(&ytdlp_path); + cmd.arg("-J") + .arg("--no-warnings") + .arg("--no-playlist") + .arg("--no-check-formats") + .arg("--socket-timeout").arg("20") + .arg("--retries").arg("3") + .arg("--extractor-retries").arg("3") + .arg("--compat-options").arg("no-youtube-unavailable-videos") + .arg("--js-runtimes").arg("deno,node"); + + if let Some(browser) = cookie_browser { + if !browser.is_empty() { + cmd.arg("--cookies-from-browser").arg(&browser); + } + } + + cmd.arg(&url); + + // We use tokio AsyncCommand so it doesn't block the async thread + let output = cmd.output() + .await + .map_err(|e| format!("Failed to execute yt-dlp: {}", e))?; + + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(text) + } else { + let err = String::from_utf8_lossy(&output.stderr); + Err(format!("yt-dlp error: {}", err)) + } +} + #[tauri::command] fn greet(name: &str) -> String { println!("greet called with name: {}", name); @@ -422,6 +461,137 @@ async fn start_download( Ok(()) } +#[tauri::command] +async fn start_media_download( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + id: String, + url: String, + destination: String, + filename: String, + format_selector: Option, +) -> Result<(), String> { + println!("start_media_download called for id: {}", id); + let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; + let ytdlp_path = resource_dir.join("binaries").join("yt-dlp"); + let ffmpeg_path = resource_dir.join("binaries").join("ffmpeg"); + + let mut resolved_dest = std::path::PathBuf::from(&destination); + if destination.starts_with("~/") { + if let Ok(home) = app_handle.path().home_dir() { + resolved_dest = home.join(&destination[2..]); + } + } else if destination == "~" { + if let Ok(home) = app_handle.path().home_dir() { + resolved_dest = home; + } + } + + if !resolved_dest.exists() { + let _ = std::fs::create_dir_all(&resolved_dest); + } + + let out_path = resolved_dest.join(&filename); + + let mut cmd = AsyncCommand::new(&ytdlp_path); + cmd.arg("--newline") + .arg("--ffmpeg-location") + .arg(&ffmpeg_path) + .arg("--no-check-formats") + .arg("--socket-timeout").arg("20") + .arg("--retries").arg("3") + .arg("--extractor-retries").arg("3") + .arg("-o").arg(out_path.to_string_lossy().to_string()); + + if let Some(format) = format_selector { + cmd.arg("-f").arg(format); + // If the filename implies an audio format, use it as audio output + if filename.ends_with(".mp3") { + cmd.arg("-x").arg("--audio-format").arg("mp3"); + } else if filename.ends_with(".m4a") { + cmd.arg("-x").arg("--audio-format").arg("m4a"); + } else if filename.ends_with(".opus") { + cmd.arg("-x").arg("--audio-format").arg("opus"); + } else { + // Otherwise attempt to merge into mp4 or mkv based on filename + if filename.ends_with(".mp4") { + cmd.arg("--merge-output-format").arg("mp4"); + } else if filename.ends_with(".webm") { + cmd.arg("--merge-output-format").arg("webm"); + } else { + cmd.arg("--merge-output-format").arg("mkv"); + } + } + } + + cmd.arg(&url); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting + + let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?; + let pid = child.id().unwrap_or(0); + state.tasks.lock().unwrap().insert(id.clone(), pid); + + let stdout = child.stdout.take().unwrap(); + let app_handle_clone = app_handle.clone(); + let id_clone = id.clone(); + + // yt-dlp parsing regex + let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap(); + let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap(); + let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap(); + + tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + loop { + tokio::select! { + line_result = reader.next_line() => { + match line_result { + Ok(Some(line)) => { + if line.contains("[download]") && line.contains("%") { + let fraction = pct_re.captures(&line) + .and_then(|cap| cap.get(1)) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0.0) / 100.0; + + let speed = spd_re.captures(&line) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "-".to_string()); + + let eta = eta_re.captures(&line) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "-".to_string()); + + let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { + id: id_clone.clone(), + fraction, + speed, + eta, + }); + } + } + _ => break, + } + } + status = child.wait() => { + if let Ok(exit_status) = status { + if exit_status.success() { + let _ = app_handle_clone.emit("download-complete", id_clone.clone()); + } else { + let _ = app_handle_clone.emit("download-failed", id_clone.clone()); + } + } + break; + } + } + } + }); + + Ok(()) +} + #[tauri::command] async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> { println!("pause_download called for id: {}", id); @@ -544,7 +714,7 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ greet, test_ytdlp, test_aria2c, test_ffmpeg, open_file, show_in_folder, - start_download, pause_download, fetch_metadata, update_dock_badge, set_prevent_sleep, get_free_space + start_download, start_media_download, pause_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src/components/AddDownloadsModal.tsx b/apps/desktop/src/components/AddDownloadsModal.tsx index 5445524..91f1b0c 100644 --- a/apps/desktop/src/components/AddDownloadsModal.tsx +++ b/apps/desktop/src/components/AddDownloadsModal.tsx @@ -1,16 +1,236 @@ import { useState, useEffect } from 'react'; import { useDownloadStore } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; -import { X, FolderPlus, Settings, Shield, Globe, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, CheckCircle2, Play, ChevronDown, ChevronRight } from 'lucide-react'; +import { X, FolderPlus, Settings, Shield, Globe, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, CheckCircle2, Play, ChevronDown, ChevronRight, Video } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; +interface RawMediaFormat { + format_id?: string; + ext?: string; + resolution?: string; + format_note?: string; + vcodec?: string; + acodec?: string; + height?: number; + filesize?: number; + filesize_approx?: number; +} + +const isVideo = (f: RawMediaFormat) => { + const vcodec = f.vcodec?.toLowerCase(); + return vcodec && vcodec !== 'none'; +}; + +const isAudio = (f: RawMediaFormat) => { + const acodec = f.acodec?.toLowerCase(); + const vcodec = f.vcodec?.toLowerCase(); + return acodec && acodec !== 'none' && (!vcodec || vcodec === 'none'); +}; + +const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0; + +const matchesHeight = (f: RawMediaFormat, height: number | null) => { + if (height === null) return true; + + const note = f.format_note || ""; + if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true; + if (height === 1440 && note.includes("1440p")) return true; + if (height === 1080 && note.includes("1080p")) return true; + if (height === 720 && note.includes("720p")) return true; + if (height === 480 && note.includes("480p")) return true; + if (height === 360 && note.includes("360p")) return true; + + if (f.resolution) { + const parts = f.resolution.split('x').map(n => parseInt(n, 10)); + if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + const maxDim = Math.max(parts[0], parts[1]); + switch (height) { + case 2160: if (maxDim >= 3800) return true; break; + case 1440: if (maxDim >= 2500 && maxDim < 3800) return true; break; + case 1080: if (maxDim >= 1900 && maxDim < 2500) return true; break; + case 720: if (maxDim >= 1200 && maxDim < 1900) return true; break; + case 480: if (maxDim >= 800 && maxDim < 1200) return true; break; + case 360: if (maxDim >= 600 && maxDim < 800) return true; break; + } + } + } + + const formatHeight = f.height; + if (!formatHeight) return false; + + let tolerance = 100; + if (height >= 2160) tolerance = 600; + else if (height >= 1440) tolerance = 400; + else if (height >= 1080) tolerance = 300; + else if (height >= 720) tolerance = 200; + + return formatHeight <= height && formatHeight >= height - tolerance; +}; + +const hasVideoFormat = (formats: RawMediaFormat[], height: number | null, container: string) => { + return formats.some(f => { + if (!isVideo(f) || !matchesHeight(f, height)) return false; + return container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase(); + }); +}; + +const hasAudioFormat = (formats: RawMediaFormat[], ext: string | null) => { + return formats.some(f => { + if (!isAudio(f)) return false; + if (!ext) return true; + return f.ext?.toLowerCase() === ext.toLowerCase(); + }); +}; + +const estimatedVideoBytes = (formats: RawMediaFormat[], height: number | null, container: string) => { + let maxVideo = 0; + for (const f of formats) { + if (isVideo(f) && matchesHeight(f, height) && (container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase())) { + const size = formatSize(f); + if (size > maxVideo) maxVideo = size; + } + } + if (maxVideo === 0) return null; + + let maxAudio = estimatedAudioBytes(formats, container === 'webm' ? 'webm' : 'm4a') || estimatedAudioBytes(formats, null) || 0; + return maxVideo + maxAudio; +}; + +const estimatedAudioBytes = (formats: RawMediaFormat[], ext: string | null): number | null => { + let maxPreferred = 0; + for (const f of formats) { + if (isAudio(f)) { + if (!ext || f.ext?.toLowerCase() === ext.toLowerCase()) { + const size = formatSize(f); + if (size > maxPreferred) maxPreferred = size; + } + } + } + if (maxPreferred > 0 || !ext) return maxPreferred > 0 ? maxPreferred : null; + return estimatedAudioBytes(formats, null); +}; + +const formatBytes = (bytes: number) => { + if (bytes === 0) return 'Unknown size'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +}; + +const parseMediaFormats = (jsonStr: string) => { + try { + const data = JSON.parse(jsonStr); + let title = data.title || 'Media'; + title = title.replace(/[\/\\?%*:|"<>]/g, '-'); + const rawFormats: RawMediaFormat[] = data.formats || []; + + const options = []; + + const standardResolutions = [ + { h: 2160, name: "4K" }, + { h: 1440, name: "1440p" }, + { h: 1080, name: "1080p" }, + { h: 720, name: "720p" }, + { h: 480, name: "480p" }, + { h: 360, name: "360p" } + ]; + + const availableResolutions = standardResolutions.filter(res => + rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h)) + ); + + const videoQualities: { h: number | null, name: string }[] = [{ h: null, name: "Best" }, ...availableResolutions]; + const videoContainers = [ + { ext: "mp4", name: "MP4" }, + { ext: "mkv", name: "MKV" }, + { ext: "webm", name: "WebM" } + ]; + + for (const q of videoQualities) { + for (const c of videoContainers) { + if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue; + const est = estimatedVideoBytes(rawFormats, q.h, c.ext); + const filter = q.h ? `[height<=${q.h}]` : ''; + + let selector = `bestvideo${filter}+bestaudio/best${filter}`; + if (c.ext === 'mp4') { + selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`; + } else if (c.ext === 'webm') { + selector = `bestvideo${filter}[ext=webm]+bestaudio[ext=webm]/best${filter}[ext=webm]/bestvideo${filter}+bestaudio/best${filter}`; + } + + options.push({ + name: `${q.name} ${c.name}`, + selector, + ext: c.ext, + detail: est ? `~${formatBytes(est)}` : '', + type: 'Video', + bytes: est || 0 + }); + } + } + + if (hasAudioFormat(rawFormats, null)) { + const est = estimatedAudioBytes(rawFormats, null); + options.push({ + name: "Audio MP3", + selector: "bestaudio/best", + ext: "mp3", + detail: est ? `~${formatBytes(est)}` : '', + type: 'Audio', + bytes: est || 0 + }); + } + + if (hasAudioFormat(rawFormats, "m4a")) { + const est = estimatedAudioBytes(rawFormats, "m4a"); + options.push({ + name: "Audio M4A", + selector: "bestaudio[ext=m4a]/bestaudio/best", + ext: "m4a", + detail: est ? `~${formatBytes(est)}` : '', + type: 'Audio', + bytes: est || 0 + }); + } + + if (hasAudioFormat(rawFormats, "webm") || hasAudioFormat(rawFormats, "opus")) { + const est = estimatedAudioBytes(rawFormats, "webm") || estimatedAudioBytes(rawFormats, "opus"); + options.push({ + name: "Audio Opus", + selector: "bestaudio[ext=webm]/bestaudio/best", + ext: "opus", + detail: est ? `~${formatBytes(est)}` : '', + type: 'Audio', + bytes: est || 0 + }); + } + + return { title, formats: options }; + } catch (e) { + return null; + } +}; + + export const AddDownloadsModal = () => { const { isAddModalOpen, toggleAddModal, addDownload } = useDownloadStore(); const { defaultDownloadPath } = useSettingsStore(); const [urls, setUrls] = useState(''); - const [parsedItems, setParsedItems] = useState<{url: string, file: string, size?: string, sizeBytes?: number, status?: string}[]>([]); + const [extractMedia, setExtractMedia] = useState(false); + const [parsedItems, setParsedItems] = useState<{ + url: string, + file: string, + size?: string, + sizeBytes?: number, + status?: string, + isMedia?: boolean, + formats?: { name: string, selector: string, ext: string, detail: string, type: string, bytes: number }[], + selectedFormat?: number + }[]>([]); // Right Form const [saveLocation, setSaveLocation] = useState(defaultDownloadPath); @@ -36,6 +256,7 @@ export const AddDownloadsModal = () => { setSaveLocation(defaultDownloadPath); setUrls(''); setParsedItems([]); + setExtractMedia(false); } }, [isAddModalOpen, defaultDownloadPath]); @@ -66,19 +287,40 @@ export const AddDownloadsModal = () => { const url = lines[i]; try { new URL(url); - const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url }); - updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' }; + if (extractMedia) { + const { mediaCookieSource } = useSettingsStore.getState(); + const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; + + const jsonStr = await invoke('fetch_media_metadata', { url, cookieBrowser: browserArg }); + const mediaData = parseMediaFormats(jsonStr); + if (mediaData && mediaData.formats.length > 0) { + updatedItems[i] = { + url, + file: `${mediaData.title}.${mediaData.formats[0].ext}`, + size: mediaData.formats[0].detail || 'Unknown (Media)', + sizeBytes: mediaData.formats[0].bytes, + status: 'Ready', + isMedia: true, + formats: mediaData.formats, + selectedFormat: 0 + }; + } else { + throw new Error("Invalid media metadata or no formats found"); + } + } else { + const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url }); + updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' }; + } } catch (e) { console.error("Meta fetch failed", e); updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' }; } - // Progressively update the UI as each fetch completes setParsedItems([...updatedItems]); } }, 400); return () => clearTimeout(timer); - }, [urls]); + }, [urls, extractMedia]); // Re-fetch if extractMedia toggles if (!isAddModalOpen) return null; @@ -120,12 +362,23 @@ export const AddDownloadsModal = () => { for (const item of parsedItems) { try { const id = crypto.randomUUID(); + let finalFile = item.file; + let formatSelector = undefined; + + if (item.isMedia && item.formats && item.selectedFormat !== undefined) { + const selectedFormat = item.formats[item.selectedFormat]; + formatSelector = selectedFormat.selector; + // Update extension if user selected a different format + const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; + finalFile = `${baseName}.${selectedFormat.ext}`; + } + addDownload({ id, url: item.url, - fileName: item.file, + fileName: finalFile, status: startImmediately ? 'queued' : 'paused', - category: 'Other', + category: item.isMedia ? 'Video' : 'Other', dateAdded: new Date().toISOString(), connections: Number(connections), speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, @@ -133,6 +386,8 @@ export const AddDownloadsModal = () => { password: useAuth ? password.trim() : undefined, headers: headers.trim() || undefined, destination: finalLocation, + isMedia: item.isMedia, + mediaFormatSelector: formatSelector }); } catch (e) { console.error("Invalid URL or failed to add:", e); @@ -170,9 +425,21 @@ export const AddDownloadsModal = () => {
-
- - Download Links +
+
+ + Download Links +
+