// YT Player — Tauri (Windows / WebView2) backend. // // Mirrors the zero-native Zig bridge, but as Tauri commands. The same frontend // in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the // exact JSON shapes the UI expects: // yt_search { query } -> { ok, results:[…] } // yt_channel { channel } -> { ok, channel, channelUrl, results:[…] } // yt_streams { videoId } -> { ok, data:{ meta, audioUrl, qualities[] } } // store_load {} -> playlists / history / settings // store_save { data } -> { ok } // // Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer, // but we still return only the slim fields the UI needs. // // Threading: the yt-dlp calls and the offline downloads are blocking and can // take seconds. They are dispatched onto a background thread pool with // `tauri::async_runtime::spawn_blocking` so the WebView/main thread stays // responsive and multiple downloads can run concurrently. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use serde_json::{json, Value}; use std::collections::HashSet; use std::path::PathBuf; use std::process::Command; use tauri::Manager; const SEARCH_LIMIT: u32 = 25; const CHANNEL_LIMIT: u32 = 60; // yt-dlp.exe is baked straight into this binary at compile time, so the standalone // `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH // required. On first run we materialize it into the app cache dir and run from there. // (Windows only; other platforms fall back to a resource/dev/PATH copy.) // Disable with --no-default-features for faster debug builds. #[cfg(all(windows, feature = "embed-ytdlp"))] static YTDLP_EMBEDDED: &[u8] = include_bytes!("../../bin/yt-dlp.exe"); /// Write the embedded yt-dlp to the cache dir if it isn't already there (size-checked), /// and return its path. #[cfg(all(windows, feature = "embed-ytdlp"))] fn embedded_ytdlp(app: &tauri::AppHandle) -> Option { let dir = app.path().app_cache_dir().ok()?; std::fs::create_dir_all(&dir).ok()?; let dest = dir.join("yt-dlp.exe"); let fresh = std::fs::metadata(&dest) .map(|m| m.len() == YTDLP_EMBEDDED.len() as u64) .unwrap_or(false); if !fresh { std::fs::write(&dest, YTDLP_EMBEDDED).ok()?; } Some(dest) } /// Locate yt-dlp: prefer a bundled resource copy, then a dev ./bin, then the /// embedded self-contained copy (Windows), then finally PATH. fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf { let name = if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" }; if let Ok(res) = app.path().resource_dir() { let p = res.join("bin").join(name); if p.exists() { return p; } } let dev = PathBuf::from("bin").join(name); if dev.exists() { return dev; } #[cfg(all(windows, feature = "embed-ytdlp"))] { if let Some(p) = embedded_ytdlp(app) { return p; } } PathBuf::from(name) } fn run_ytdlp(app: &tauri::AppHandle, args: &[&str]) -> Result { let exe = ytdlp_path(app); let mut cmd = Command::new(&exe); cmd.args(args); // Don't flash a console window when spawning yt-dlp on Windows. #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; cmd.creation_flags(CREATE_NO_WINDOW); } let output = cmd .output() .map_err(|e| format!("failed to launch yt-dlp ({}): {e}", exe.display()))?; if !output.status.success() { let err = String::from_utf8_lossy(&output.stderr); return Err(if err.trim().is_empty() { "yt-dlp failed".to_string() } else { err.trim().to_string() }); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str { v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback) } /// Pick the channel display name from a yt-dlp record (channel, then uploader). fn pick_channel(v: &Value) -> &str { let c = str_or(v, "channel", ""); if c.is_empty() { str_or(v, "uploader", "") } else { c } } /// Pick a usable channel URL (channel_url, then uploader_url). fn pick_channel_url(v: &Value) -> &str { let u = str_or(v, "channel_url", ""); if u.is_empty() { str_or(v, "uploader_url", "") } else { u } } /// Pick a channel id (channel_id, then uploader_id). fn pick_channel_id(v: &Value) -> &str { let id = str_or(v, "channel_id", ""); if id.is_empty() { str_or(v, "uploader_id", "") } else { id } } /// Turn one yt-dlp flat-playlist record into the slim card the UI expects. fn slim_entry(j: &Value) -> Option { let id = str_or(j, "id", ""); if id.is_empty() { return None; } Some(json!({ "id": id, "title": str_or(j, "title", "(untitled)"), "channel": pick_channel(j), "channelId": pick_channel_id(j), "channelUrl": pick_channel_url(j), "duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0), "thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id), })) } // ============================================================================ // Search // ============================================================================ #[tauri::command] async fn yt_search(app: tauri::AppHandle, query: String) -> Value { let q = query.trim().to_string(); if q.is_empty() { return json!({ "ok": false, "error": "empty query" }); } tauri::async_runtime::spawn_blocking(move || yt_search_blocking(&app, &q)) .await .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) } fn yt_search_blocking(app: &tauri::AppHandle, q: &str) -> Value { let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q); let out = match run_ytdlp( app, &[ &search, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", ], ) { Ok(o) => o, Err(e) => return json!({ "ok": false, "error": e }), }; let mut results = Vec::new(); for line in out.lines() { let line = line.trim(); if line.is_empty() { continue; } let j: Value = match serde_json::from_str(line) { Ok(v) => v, Err(_) => continue, }; if let Some(card) = slim_entry(&j) { results.push(card); } } json!({ "ok": true, "results": results }) } // ============================================================================ // Channel — list a channel's recent uploads // ============================================================================ #[tauri::command] async fn yt_channel(app: tauri::AppHandle, channel: String) -> Value { let c = channel.trim().to_string(); if c.is_empty() { return json!({ "ok": false, "error": "missing channel" }); } tauri::async_runtime::spawn_blocking(move || yt_channel_blocking(&app, &c)) .await .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) } fn yt_channel_blocking(app: &tauri::AppHandle, channel: &str) -> Value { // Accept either a full channel/uploader URL or a bare channel id. Always // resolve to the "/videos" tab so we list uploads, not the channel home. let base = if channel.starts_with("http") { channel.trim_end_matches('/').to_string() } else if channel.starts_with('@') { format!("https://www.youtube.com/{}", channel) } else if channel.starts_with("UC") { format!("https://www.youtube.com/channel/{}", channel) } else { format!("https://www.youtube.com/@{}", channel) }; let url = if base.ends_with("/videos") { base } else { format!("{}/videos", base) }; let end = CHANNEL_LIMIT.to_string(); let out = match run_ytdlp( app, &[ &url, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", "--playlist-end", &end, ], ) { Ok(o) => o, Err(e) => return json!({ "ok": false, "error": e }), }; let mut results = Vec::new(); let mut name = String::new(); let mut chan_url = String::new(); for line in out.lines() { let line = line.trim(); if line.is_empty() { continue; } let j: Value = match serde_json::from_str(line) { Ok(v) => v, Err(_) => continue, }; if name.is_empty() { let n = pick_channel(&j); if !n.is_empty() { name = n.to_string(); } } if chan_url.is_empty() { let u = pick_channel_url(&j); if !u.is_empty() { chan_url = u.to_string(); } } if let Some(card) = slim_entry(&j) { results.push(card); } } json!({ "ok": true, "channel": name, "channelUrl": chan_url, "results": results, }) } // ============================================================================ // Streams // ============================================================================ #[tauri::command] async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value { let id = video_id.trim().to_string(); if id.is_empty() { return json!({ "ok": false, "error": "missing videoId" }); } tauri::async_runtime::spawn_blocking(move || yt_streams_blocking(&app, &id)) .await .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) } fn yt_streams_blocking(app: &tauri::AppHandle, video_id: &str) -> Value { let url = format!("https://www.youtube.com/watch?v={}", video_id); let out = match run_ytdlp(app, &["-J", "--no-warnings", &url]) { Ok(o) => o, Err(e) => return json!({ "ok": false, "error": e }), }; let info: Value = match serde_json::from_str(&out) { Ok(v) => v, Err(e) => return json!({ "ok": false, "error": format!("parse error: {e}") }), }; let empty: Vec = Vec::new(); let formats = info.get("formats").and_then(|f| f.as_array()).unwrap_or(&empty); // Best audio-only stream (prefer m4a/mp4a, then by bitrate). let mut best_audio_url = String::new(); let mut best_audio_score = -1.0_f64; for f in formats { let vcodec = str_or(f, "vcodec", "none"); let acodec = str_or(f, "acodec", "none"); let furl = str_or(f, "url", ""); if furl.is_empty() { continue; } let has_video = vcodec != "none" && !vcodec.is_empty(); let has_audio = acodec != "none" && !acodec.is_empty(); if !has_video && has_audio { let mut score = f.get("abr").and_then(|x| x.as_f64()).unwrap_or(0.0); if acodec.contains("mp4a") { score += 1000.0; } if score > best_audio_score { best_audio_score = score; best_audio_url = furl.to_string(); } } } // Quality list: progressive (single-file, has audio) first so it survives the // per-height dedupe — it's the most reliable fallback when the dual adaptive // path fails. Adaptive video-only fills in the heights progressive doesn't cover. let mut qualities = Vec::new(); let mut seen: HashSet = HashSet::new(); for want_progressive in [true, false] { for f in formats { let vcodec = str_or(f, "vcodec", "none"); let acodec = str_or(f, "acodec", "none"); let furl = str_or(f, "url", ""); if furl.is_empty() { continue; } let has_video = vcodec != "none" && !vcodec.is_empty(); let has_audio = acodec != "none" && !acodec.is_empty(); if !has_video { continue; } let is_progressive = has_audio; if is_progressive != want_progressive { continue; } let height = f.get("height").and_then(|x| x.as_i64()).unwrap_or(0); if height <= 0 || seen.contains(&height) { continue; } seen.insert(height); qualities.push(json!({ "label": format!("{}p", height), "height": height, "hasAudio": is_progressive, "url": furl, "ext": str_or(f, "ext", ""), })); } } qualities.sort_by(|a, b| { b["height"] .as_i64() .unwrap_or(0) .cmp(&a["height"].as_i64().unwrap_or(0)) }); json!({ "ok": true, "data": { "meta": { "id": video_id, "title": str_or(&info, "title", "(untitled)"), "channel": pick_channel(&info), "channelId": pick_channel_id(&info), "channelUrl": pick_channel_url(&info), "duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0), "thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id), }, "audioUrl": if best_audio_url.is_empty() { Value::Null } else { Value::String(best_audio_url) }, "qualities": qualities, } }) } fn data_file(app: &tauri::AppHandle) -> Result { let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; std::fs::create_dir_all(&dir).ok(); Ok(dir.join("ytplayer-data.json")) } // ============================================================================ // Offline cache — download a single self-contained file per video so playlist // items play instantly and work offline. Files live in /videos // and are named ".". They persist until explicitly deleted. // ============================================================================ fn cache_dir(app: &tauri::AppHandle) -> Result { let dir = app .path() .app_cache_dir() .map_err(|e| e.to_string())? .join("videos"); std::fs::create_dir_all(&dir).ok(); Ok(dir) } /// Find an already-cached file for a video id (any extension), if present. fn cached_file(dir: &PathBuf, video_id: &str) -> Option { let entries = std::fs::read_dir(dir).ok()?; for e in entries.flatten() { let p = e.path(); if p.file_stem().and_then(|s| s.to_str()) == Some(video_id) { // Ignore partial yt-dlp downloads. let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); if ext != "part" && ext != "ytdl" { return Some(p); } } } None } fn file_size(p: &PathBuf) -> u64 { std::fs::metadata(p).map(|m| m.len()).unwrap_or(0) } /// Download a video into the cache as a single progressive mp4 (audio+video in /// one file, so it needs no ffmpeg muxing and plays offline). Idempotent. /// /// Async + spawn_blocking so several saves can run in parallel without blocking /// the UI thread. #[tauri::command] async fn cache_download(app: tauri::AppHandle, video_id: String) -> Value { let id = video_id.trim().to_string(); if id.is_empty() { return json!({ "ok": false, "error": "missing videoId" }); } tauri::async_runtime::spawn_blocking(move || cache_download_blocking(&app, &id)) .await .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) } fn cache_download_blocking(app: &tauri::AppHandle, video_id: &str) -> Value { let dir = match cache_dir(app) { Ok(d) => d, Err(e) => return json!({ "ok": false, "error": e }), }; if let Some(p) = cached_file(&dir, video_id) { return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }); } let url = format!("https://www.youtube.com/watch?v={}", video_id); let out_tmpl = dir.join(format!("{}.%(ext)s", video_id)); let out_tmpl = out_tmpl.to_string_lossy().to_string(); // Prefer a progressive mp4 (single file with audio); fall back to best single // file. Avoids ffmpeg by not requesting separate streams that need merging. let res = run_ytdlp( app, &[ "--no-playlist", "--no-warnings", "-f", "best[ext=mp4][acodec!=none][vcodec!=none]/best[acodec!=none][vcodec!=none]/best", "-o", &out_tmpl, &url, ], ); if let Err(e) = res { return json!({ "ok": false, "error": e }); } match cached_file(&dir, video_id) { Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }), None => json!({ "ok": false, "error": "download finished but no file was produced" }), } } #[tauri::command] fn cache_status(app: tauri::AppHandle, video_id: String) -> Value { let dir = match cache_dir(&app) { Ok(d) => d, Err(e) => return json!({ "ok": false, "error": e }), }; match cached_file(&dir, &video_id) { Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }), None => json!({ "ok": true, "cached": false }), } } #[tauri::command] fn cache_list(app: tauri::AppHandle) -> Value { let dir = match cache_dir(&app) { Ok(d) => d, Err(e) => return json!({ "ok": false, "error": e }), }; let mut items = Vec::new(); let mut total: u64 = 0; if let Ok(entries) = std::fs::read_dir(&dir) { for e in entries.flatten() { let p = e.path(); if !p.is_file() { continue; } let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); if ext == "part" || ext == "ytdl" { continue; } if let Some(id) = p.file_stem().and_then(|s| s.to_str()) { let size = file_size(&p); total += size; items.push(json!({ "id": id, "size": size, "path": p.to_string_lossy() })); } } } json!({ "ok": true, "items": items, "total": total }) } #[tauri::command] fn cache_delete(app: tauri::AppHandle, video_id: String) -> Value { let dir = match cache_dir(&app) { Ok(d) => d, Err(e) => return json!({ "ok": false, "error": e }), }; if let Some(p) = cached_file(&dir, &video_id) { match std::fs::remove_file(&p) { Ok(_) => json!({ "ok": true }), Err(e) => json!({ "ok": false, "error": e.to_string() }), } } else { json!({ "ok": true }) } } #[tauri::command] fn cache_clear(app: tauri::AppHandle) -> Value { let dir = match cache_dir(&app) { Ok(d) => d, Err(e) => return json!({ "ok": false, "error": e }), }; let mut removed = 0u32; if let Ok(entries) = std::fs::read_dir(&dir) { for e in entries.flatten() { let p = e.path(); if p.is_file() && std::fs::remove_file(&p).is_ok() { removed += 1; } } } json!({ "ok": true, "removed": removed }) } #[tauri::command] fn store_load(app: tauri::AppHandle) -> Value { let default = json!({ "playlists": [], "history": [], "settings": { "quality": "auto", "volume": 1, "audioOnly": false } }); let path = match data_file(&app) { Ok(p) => p, Err(_) => return default, }; match std::fs::read_to_string(&path) { Ok(s) => serde_json::from_str::(&s).unwrap_or(default), Err(_) => default, } } #[tauri::command] fn store_save(app: tauri::AppHandle, data: String) -> Value { let path = match data_file(&app) { Ok(p) => p, Err(e) => return json!({ "ok": false, "error": e }), }; match std::fs::write(&path, data) { Ok(_) => json!({ "ok": true }), Err(e) => json!({ "ok": false, "error": e.to_string() }), } } fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ yt_search, yt_channel, yt_streams, store_load, store_save, cache_download, cache_status, cache_list, cache_delete, cache_clear ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }