//! Tauri command surface. Delegates to the modules; holds no parsing logic. use crate::db::Db; use crate::downloader::{self, Progress}; use crate::feed; use crate::models::{ Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs, Video, }; use crate::net; use crate::playlist_server::PlaylistServer; use crate::resolve; use crate::takeout; use crate::thumbs; use futures::stream::StreamExt; use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::{Mutex, Semaphore}; /// How many channel feeds we fetch at once. YouTube serves these fine in /// parallel; 8 keeps a 300-channel refresh brisk without hammering them. const FEED_CONCURRENCY: usize = 8; /// Video downloads are bandwidth-bound, so more than a couple at once just /// makes every one of them slower. const DOWNLOAD_CONCURRENCY: usize = 2; /// Cap thumbnail work per refresh so a first-run import doesn't stall for ages. const THUMB_BATCH: i64 = 600; pub struct AppState { pub db: Arc>, pub http: reqwest::Client, pub library: Arc>, pub app_data: PathBuf, pub children: Arc>>, pub download_slots: Arc, pub playlists: PlaylistServer, /// yt-dlp's PyInstaller bundle costs several seconds to start, so its /// version is read once and kept. pub prereqs: Arc>>, /// Resolved stream URLs, keyed by video and quality cap. YouTube's signed /// URLs last hours, so replaying a video should not pay for yt-dlp again. pub streams: Arc), (String, std::time::Instant)>>>, /// argv prefix that runs yt-dlp: either a system binary, or the bundled /// Python interpreter followed by the zipapp. Mutable so an in-app update /// takes effect without a restart. pub yt_dlp_argv: Arc>>, /// Quality and subtitle language the window is set to, so the menu bar /// can start the same work without the front end being open. pub download_defaults: Arc>, /// Value for yt-dlp's --cookies-from-browser, when signed in. pub cookies_from: Arc>>, } impl AppState { /// A ready-to-configure yt-dlp process, carrying cookies when configured. async fn yt_dlp(&self) -> tokio::process::Command { let argv = self.yt_dlp_argv.lock().await.clone(); let mut cmd = tokio::process::Command::new(&argv[0]); cmd.args(&argv[1..]); if let Some(from) = self.cookies_from.lock().await.clone() { cmd.arg("--cookies-from-browser").arg(from); } cmd } } /// Browsers yt-dlp can read cookies from, as (id, label, --cookies-from-browser /// value). Arc is Chromium underneath but is not one of yt-dlp's known names, /// so it is addressed by its profile directory. fn browser_options() -> Vec<(String, String, String)> { let home = std::env::var("HOME").unwrap_or_default(); vec![ ("safari", "Safari", "/Applications/Safari.app", "safari".to_string()), ("arc", "Arc", "/Applications/Arc.app", format!("chrome:{home}/Library/Application Support/Arc/User Data")), ("chrome", "Google Chrome", "/Applications/Google Chrome.app", "chrome".to_string()), ("firefox", "Firefox", "/Applications/Firefox.app", "firefox".to_string()), ("brave", "Brave", "/Applications/Brave Browser.app", "brave".to_string()), ("edge", "Microsoft Edge", "/Applications/Microsoft Edge.app", "edge".to_string()), ("vivaldi", "Vivaldi", "/Applications/Vivaldi.app", "vivaldi".to_string()), ] .into_iter() .filter(|(_, _, app, _)| std::path::Path::new(app).exists()) .map(|(id, label, _, value)| (id.to_string(), label.to_string(), value)) .collect() } /// The browsers actually installed, for the Settings picker. #[tauri::command] pub async fn list_browsers() -> Result, String> { Ok(browser_options() .into_iter() .map(|(id, label, _)| (id, label)) .collect()) } /// Chooses which browser's cookies yt-dlp should use. An empty id signs out. #[tauri::command] pub async fn set_cookie_source( browser: String, state: State<'_, AppState>, ) -> Result<(), String> { let value = browser_options() .into_iter() .find(|(id, _, _)| *id == browser) .map(|(_, _, value)| value); *state.cookies_from.lock().await = value; Ok(()) } /// Turns yt-dlp's stderr into something worth showing. /// /// The bot challenge is the one users hit most, and its stock message points at /// command-line flags they have no way to type, so it is replaced with the /// setting that actually fixes it. fn explain_yt_dlp_error(stderr: &str, signed_in: bool) -> String { if stderr.contains("Sign in to confirm") || stderr.contains("not a bot") { return if signed_in { "YouTube is still refusing this machine even with browser cookies. The sign-in may have expired — reopen YouTube in that browser, or wait a while before trying again." .into() } else { "YouTube is asking this machine to prove it is not a bot. Open Settings and pick a browser under Sign in to YouTube; the app will use that browser's session." .into() }; } if stderr.contains("429") || stderr.contains("Too Many Requests") { return "YouTube is rate-limiting this machine. Wait a few minutes, or sign in \ under Settings → Sign in to YouTube, which raises the limit." .into(); } if stderr.contains("Operation not permitted") && stderr.contains("Safari") { return "macOS blocked access to Safari's cookies. Give FlightTube Full Disk Access in System Settings → Privacy & Security, or pick a different browser." .into(); } if stderr.contains("could not find") && stderr.contains("cookies database") { return "That browser has no cookie store on this Mac. Pick another under Settings → Sign in to YouTube." .into(); } stderr .lines() .rev() .find(|l| l.contains("ERROR")) .unwrap_or("yt-dlp failed") .to_string() } /// Tries a real extraction so Settings can report whether YouTube is reachable. #[tauri::command] pub async fn test_youtube(state: State<'_, AppState>) -> Result { let signed_in = state.cookies_from.lock().await.is_some(); // Test against the newest video in the feed. A hardcoded id is no good — // the one this used at first had been taken down, so the check reported a // dead video rather than the connection. let target = state .db .lock() .await .list_feed(&FeedFilter { limit: Some(1), ..Default::default() })? .first() .map(|v| v.id.clone()) .ok_or("Import your subscriptions first — there is nothing to test with.")?; let mut cmd = state.yt_dlp().await; cmd.args([ "--no-playlist", "--simulate", "--print", "%(id)s", &format!("https://www.youtube.com/watch?v={target}"), ]); let out = cmd .output() .await .map_err(|e| format!("Could not run yt-dlp: {e}"))?; if out.status.success() { return Ok(if signed_in { "YouTube is reachable, using your browser sign-in.".into() } else { "YouTube is reachable.".into() }); } Err(explain_yt_dlp_error( &String::from_utf8_lossy(&out.stderr), signed_in, )) } /// Comfortably inside the ~6h lifetime of YouTube's signed URLs. const STREAM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3 * 3600); #[derive(Clone, Serialize)] struct RefreshProgress { done: usize, total: usize, channel: String, } #[derive(Clone, Serialize)] struct DownloadProgressEvent { video_id: String, pct: Option, bytes_done: u64, bytes_total: Option, speed: Option, eta: Option, } #[derive(Clone, Serialize)] struct DownloadStateEvent { video_id: String, state: DownloadState, error: Option, path: Option, } #[derive(Serialize)] pub struct RefreshSummary { pub channels: usize, pub new_videos: usize, pub failures: Vec, } /// Resolves a helper binary. /// /// The app ships `yt-dlp`, `ffmpeg` and `ffprobe` as sidecars, so a fresh Mac /// needs nothing installed. Tauri places them beside the executable inside /// `Contents/MacOS/`, which is checked first. A copy on PATH still wins nothing /// — but the Homebrew fallbacks remain for `cargo run` during development, /// where there is no bundle. /// Unix seconds. The database has its own copy; this is for rows built here. fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } fn bin(name: &str) -> String { if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { let bundled = dir.join(name); if bundled.exists() { return bundled.to_string_lossy().to_string(); } } } // GUI apps launched from Finder don't inherit a login shell PATH, so // Homebrew's bin dir is invisible to them; name the paths explicitly. for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { let candidate = format!("{prefix}/{name}"); if std::path::Path::new(&candidate).exists() { return candidate; } } name.to_string() } /// Works out how to run yt-dlp. /// /// A system install wins when present — it is a plain Python script, starts in /// milliseconds, and is easier to keep current than a bundled copy. Otherwise /// the app runs its own interpreter against the yt-dlp zipapp. The 3MB zipapp /// plus a portable Python starts in about half a second; the official /// PyInstaller binary took eight, because it unpacks 37MB on every call. fn resolve_yt_dlp(app: &AppHandle, app_data: &std::path::Path) -> Vec { for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { let candidate = format!("{prefix}/yt-dlp"); if std::path::Path::new(&candidate).exists() { return vec![candidate]; } } if let Ok(res) = app.path().resource_dir() { // python3 and python are symlinks; name the real file so the bundle // does not depend on symlinks surviving the copy. let python = res.join("python/bin/python3.12"); // An in-app update lands in app data; the bundle is read-only. let updated = updated_yt_dlp(app_data); let zipapp = if updated.exists() { updated } else { res.join("yt-dlp.pyz") }; if python.exists() && zipapp.exists() { return vec![ python.to_string_lossy().to_string(), zipapp.to_string_lossy().to_string(), ]; } } vec!["yt-dlp".to_string()] } /// True when the binary we resolved is the one inside the app bundle. fn is_bundled(name: &str) -> bool { std::env::current_exe() .ok() .and_then(|e| e.parent().map(|d| d.join(name).exists())) .unwrap_or(false) } /// Whether yt-dlp is the app's own copy rather than one found on the system. fn yt_dlp_is_bundled(argv: &[String]) -> bool { argv.len() > 1 } /// `flag` differs per tool: yt-dlp takes `--version`, ffmpeg only accepts /// `-version` (it exits non-zero on `--version` and writes to stderr), so the /// flag is passed in and both streams are consulted. async fn version_of(argv: &[String], flag: &str) -> Option { let mut cmd = tokio::process::Command::new(&argv[0]); cmd.args(&argv[1..]); let out = cmd.arg(flag).output().await.ok()?; if !out.status.success() { return None; } let text = if out.stdout.is_empty() { String::from_utf8_lossy(&out.stderr).to_string() } else { String::from_utf8_lossy(&out.stdout).to_string() }; let first = text.lines().next().unwrap_or("").trim().to_string(); (!first.is_empty()).then_some(first) } fn label(version: &str, name: &str) -> String { if is_bundled(name) { format!("{version} (bundled)") } else { format!("{version} (system)") } } #[tauri::command] pub async fn check_prereqs(state: State<'_, AppState>) -> Result { // The library path can change, so only the tool versions are cached. let library = state.library.lock().await.clone(); if let Some(cached) = state.prereqs.lock().await.clone() { return Ok(Prereqs { library_path: library.to_string_lossy().to_string(), ..cached }); } let fresh = Prereqs { yt_dlp: { let argv = state.yt_dlp_argv.lock().await.clone(); version_of(&argv, "--version").await.map(|v| { let origin = if !yt_dlp_is_bundled(&argv) { "system" } else if argv.last().map(|p| p.contains("/bin/")).unwrap_or(false) { "updated" } else { "bundled" }; format!("{v} ({origin})") }) }, ffmpeg: version_of(&[bin("ffmpeg")], "-version").await.map(|v| { // ffmpeg's first line is long; keep the useful head of it. let head = v.split_whitespace().take(3).collect::>().join(" "); label(&head, "ffmpeg") }), library_path: library.to_string_lossy().to_string(), }; *state.prereqs.lock().await = Some(fresh.clone()); Ok(fresh) } async fn read_channels(path: &str) -> Result, String> { let raw = tokio::fs::read(path) .await .map_err(|e| format!("Cannot read {path}: {e}"))?; // Takeout exports are UTF-8, sometimes with a BOM. let text = String::from_utf8_lossy(&raw); let text = text.strip_prefix('\u{feff}').unwrap_or(&text); let channels = takeout::parse_csv(text)?; if channels.is_empty() { return Err("No channels found in that file.".into()); } Ok(channels) } /// Reports what a replacing import would add and destroy, so the UI can name /// the consequences before the user commits to them. #[tauri::command] pub async fn preview_takeout_import( path: String, state: State<'_, AppState>, ) -> Result { let channels = read_channels(&path).await?; state.db.lock().await.preview_replace(&channels) } /// The imported CSV becomes the entire subscription list. Channels that are no /// longer in it are removed along with their videos, download records, and the /// downloaded files themselves — leaving those on disk would orphan gigabytes /// the app can no longer show or delete. #[tauri::command] pub async fn import_takeout_csv( path: String, state: State<'_, AppState>, ) -> Result { let channels = read_channels(&path).await?; let doomed = state .db .lock() .await .paths_dropped_by_replace(&channels)?; for p in doomed { let _ = tokio::fs::remove_file(&p).await; } let mut db = state.db.lock().await; db.replace_channels(&channels) } /// Resolves a directly playable URL for a video we have NOT downloaded, so it /// can stream inside the app's own player. /// /// YouTube's iframe embed is not an option here: it rejects a Tauri window with /// "Error 153" because the page origin is `tauri://localhost` rather than an /// http(s) origin it will accept. Instead we ask yt-dlp for YouTube's HLS master /// playlist, which lists H.264 + AAC variants up to 1080p with separate audio /// tracks — exactly the shape AVFoundation plays natively in WKWebView, with /// adaptive bitrate for free. #[derive(Serialize)] pub struct Stream { /// Direct URL to hand the player, when no filtering was needed. pub url: Option, /// A rewritten HLS master playlist, when a quality cap was applied. The /// frontend turns this into a Blob URL — every URL inside is absolute, so /// the playlist works from anywhere. pub playlist: Option, } #[tauri::command] pub async fn resolve_stream( video_id: String, max_height: Option, state: State<'_, AppState>, ) -> Result { let key = (video_id.clone(), max_height); { let mut cache = state.streams.lock().await; cache.retain(|_, (_, at)| at.elapsed() < STREAM_CACHE_TTL); if let Some((cached, _)) = cache.get(&key) { return Ok(Stream { url: Some(cached.clone()), playlist: None }); } } let url = format!("https://www.youtube.com/watch?v={video_id}"); // One call gives both the HLS master playlist and the video's own language. // Every m3u8 format shares the same manifest_url, so any one yields the master. let lines = yt_dlp_lines( &state, &[ "-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", "--print", "%(language)s", &url, ], ) .await; if let Some(master) = lines.iter().find(|l| l.starts_with("http")).cloned() { let lang = lines.iter().find(|l| !l.starts_with("http")).cloned(); // Always serve our own copy, even with no height cap: YouTube marks an // AI-dubbed track as the manifest default on some videos, and that has // to be corrected whether or not the quality is capped. match state.http.get(&master).send().await { Ok(resp) => match resp.text().await { Ok(body) => { if let Some(rewritten) = rewrite_master(&body, max_height, lang.as_deref()) { let served = state.playlists.publish(rewritten).await; remember(&state, key, &served).await; return Ok(Stream { url: Some(served), playlist: None }); } } Err(_) => {} }, Err(_) => {} } // Anything unexpected about the manifest: fall back to YouTube's own. remember(&state, key, &master).await; return Ok(Stream { url: Some(master), playlist: None }); } // Rare fallback: an old-style progressive muxed MP4. if let Some(u) = yt_dlp_print(&state, &[ "-f", "b[ext=mp4][acodec!=none][vcodec!=none]", "--print", "%(url)s", &url, ]) .await { return Ok(Stream { url: Some(u), playlist: None }); } // Distinguish "YouTube is refusing us" from "this video has no stream". let signed_in = state.cookies_from.lock().await.is_some(); let mut probe = state.yt_dlp().await; probe.args(["--no-playlist", "--simulate", "--print", "%(id)s", &url]); if let Ok(out) = probe.output().await { if !out.status.success() { return Err(explain_yt_dlp_error( &String::from_utf8_lossy(&out.stderr), signed_in, )); } } Err("Could not find a playable stream for this video.".into()) } async fn remember( state: &State<'_, AppState>, key: (String, Option), url: &str, ) { state .streams .lock() .await .insert(key, (url.to_string(), std::time::Instant::now())); } /// Rewrites YouTube's HLS master playlist. /// /// Two jobs. Optionally caps the video height. Always makes sure the *original* /// audio is the default: YouTube marks an AI-dubbed track `DEFAULT=YES` on some /// videos, and AVFoundation obeys the manifest, so you get a synthetic voice /// over the original. Every audio group is still listed, so a player — or our /// own track picker — can switch. /// /// Returns `None` only when a height cap matched nothing, so the caller can /// fall back to YouTube's own manifest rather than serve an empty playlist. pub fn rewrite_master( body: &str, max_height: Option, original_lang: Option<&str>, ) -> Option { let lines: Vec<&str> = body.lines().collect(); let mut audio: Vec<&str> = Vec::new(); let mut other_media: Vec<&str> = Vec::new(); // (height, stream-inf line, url line) let mut variants: Vec<(u32, &str, &str)> = Vec::new(); for (i, line) in lines.iter().enumerate() { if line.starts_with("#EXT-X-MEDIA:") { if attr(line, "TYPE").as_deref() == Some("AUDIO") { audio.push(line); } else { other_media.push(line); } } else if line.starts_with("#EXT-X-STREAM-INF:") { let Some(url) = lines.get(i + 1) else { continue }; if url.starts_with('#') || url.trim().is_empty() { continue; } if let Some(h) = resolution_height(line) { variants.push((h, *line, *url)); } } } let chosen: Vec<(u32, &str, &str)> = match max_height { Some(cap) => { let best = variants.iter().filter(|(h, _, _)| *h <= cap).max_by_key(|(h, _, _)| *h)?; vec![*best] } // No cap: keep every variant so the player can still adapt. None => variants, }; if chosen.is_empty() { return None; } let preferred = preferred_audio(&audio, original_lang); let mut out = String::from("#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n"); for (i, line) in audio.iter().enumerate() { out.push_str(&set_default(line, Some(i) == preferred)); out.push('\n'); } for m in other_media { out.push_str(m); out.push('\n'); } for (_, inf, url) in chosen { out.push_str(inf); out.push('\n'); out.push_str(url); out.push('\n'); } Some(out) } /// Index of the audio group that should be the default. /// /// The video's own language wins. Failing that, anything not describing itself /// as dubbed. A manifest with one audio group needs no opinion. fn preferred_audio(audio: &[&str], original_lang: Option<&str>) -> Option { // With one track there is nothing to choose; it stays the default. if audio.len() < 2 { return (!audio.is_empty()).then_some(0); } if let Some(lang) = original_lang.filter(|l| !l.is_empty() && *l != "NA") { let base = lang.split('-').next().unwrap_or(lang).to_ascii_lowercase(); if let Some(i) = audio.iter().position(|l| { attr(l, "LANGUAGE") .map(|v| v.to_ascii_lowercase().starts_with(&base)) .unwrap_or(false) }) { return Some(i); } } audio .iter() .position(|l| { let name = attr(l, "NAME").unwrap_or_default().to_ascii_lowercase(); !name.contains("dub") && !name.contains("auto") }) .or(Some(0)) } /// Rewrites the DEFAULT/AUTOSELECT flags on one EXT-X-MEDIA line. fn set_default(line: &str, is_default: bool) -> String { let want = if is_default { "YES" } else { "NO" }; let mut out = String::with_capacity(line.len() + 32); let mut rest = line; // Attributes are comma separated but URIs contain commas inside quotes, so // only rewrite the two flags by name and leave the rest of the line intact. for key in ["DEFAULT", "AUTOSELECT"] { let needle = format!(",{key}="); if let Some(at) = rest.find(&needle) { let value_start = at + needle.len(); let value_end = rest[value_start..] .find(',') .map(|o| value_start + o) .unwrap_or(rest.len()); out.clear(); out.push_str(&rest[..value_start]); out.push_str(want); out.push_str(&rest[value_end..]); rest = Box::leak(out.clone().into_boxed_str()); } } let mut s = rest.trim_end().to_string(); for key in ["DEFAULT", "AUTOSELECT"] { if !s.contains(&format!(",{key}=")) { s.push_str(&format!(",{key}={want}")); } } s } /// Reads one attribute from an EXT-X tag line. fn attr(line: &str, key: &str) -> Option { let needle = format!("{key}="); let mut from = 0; while let Some(at) = line[from..].find(&needle) { let abs = from + at; // Must be preceded by ':' or ',' so LANGUAGE does not match INDEX-LANGUAGE. let ok = abs == 0 || matches!(line.as_bytes()[abs - 1], b',' | b':'); let start = abs + needle.len(); if ok { let bytes = line.as_bytes(); if bytes.get(start) == Some(&b'"') { let end = line[start + 1..].find('"')? + start + 1; return Some(line[start + 1..end].to_string()); } let end = line[start..] .find(',') .map(|o| start + o) .unwrap_or(line.len()); return Some(line[start..end].trim().to_string()); } from = start; } None } /// Pulls the vertical size out of a `RESOLUTION=1920x1080` attribute. fn resolution_height(stream_inf: &str) -> Option { let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len(); let rest = &stream_inf[at..]; let value = rest.split(&[',', ' '][..]).next()?; value.split(&['x', 'X'][..]).nth(1)?.parse().ok() } /// Runs yt-dlp and returns every non-empty stdout line. async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec { let mut cmd = state.yt_dlp().await; cmd.args(["--no-warnings", "--no-playlist", "--simulate"]); cmd.args(args); let Ok(out) = cmd.output().await else { return Vec::new() }; if !out.status.success() { return Vec::new(); } String::from_utf8_lossy(&out.stdout) .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect() } /// Runs yt-dlp and returns its first non-empty stdout line, or None. async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option { let mut cmd = state.yt_dlp().await; cmd.args(["--no-warnings", "--no-playlist", "--simulate"]); cmd.args(args); let out = cmd.output().await.ok()?; if !out.status.success() { return None; } String::from_utf8_lossy(&out.stdout) .lines() .map(str::trim) .find(|l| l.starts_with("http")) .map(str::to_string) } /// Called periodically while a video plays, and once when the player closes. #[tauri::command] pub async fn save_playback( video_id: String, position: f64, duration: f64, state: State<'_, AppState>, ) -> Result<(), String> { state.db.lock().await.save_playback(&video_id, position, duration) } /// How much of a watch page to read. `lengthSeconds` sits in the player /// response near the top, so there is no need to pull a megabyte of HTML. const DURATION_PROBE_BYTES: &str = "bytes=0-262143"; /// Deliberately small. An earlier version fetched 24 pages every 12 seconds at /// six concurrent — about two requests a second, sustained — and YouTube /// answered with "Sign in to confirm you're not a bot" for the whole IP, /// breaking playback and downloads as well. Filling every length matters far /// less than staying under the radar, so this trickles. const DURATION_BATCH: usize = 4; const DURATION_CONCURRENCY: usize = 1; /// Fills in video lengths, which the Atom feed does not carry. /// /// yt-dlp would cost seconds per video; a ranged GET of the watch page costs /// about one, and only ever runs for videos whose length is still unknown. /// /// `visible` is what the user is actually looking at. Filling globally by date /// instead left most of a channel's videos blank forever, because the newest /// few across all subscriptions always won the queue. #[tauri::command] pub async fn fetch_durations( visible: Vec, state: State<'_, AppState>, ) -> Result { let ids = { let db = state.db.lock().await; if visible.is_empty() { db.videos_missing_duration(DURATION_BATCH as i64)? } else { db.filter_missing_duration(&visible, DURATION_BATCH as i64)? } }; if ids.is_empty() { return Ok(0); } let http = state.http.clone(); let found = futures::stream::iter(ids.into_iter().map(|id| { let http = http.clone(); async move { let secs = probe_duration(&http, &id).await; (id, secs) } })) .buffer_unordered(DURATION_CONCURRENCY) .collect::>() .await; let attempted = found.len(); let db = state.db.lock().await; let mut n = 0; for (id, secs) in found { if let Some(secs) = secs { db.set_duration(&id, secs)?; n += 1; } } drop(db); // Every probe failing means YouTube is refusing us, not that these videos // have no length. Stop asking rather than hammering a closed door. if n == 0 && attempted > 0 { return Err("Duration lookup is being refused; backing off.".into()); } Ok(n) } async fn probe_duration(http: &reqwest::Client, video_id: &str) -> Option { let body = http .get(format!("https://www.youtube.com/watch?v={video_id}")) .header(reqwest::header::RANGE, DURATION_PROBE_BYTES) .send() .await .ok()? .text() .await .ok()?; parse_length_seconds(&body) } /// Pulls `"lengthSeconds":"1315"` out of the watch page. pub fn parse_length_seconds(body: &str) -> Option { let needle = "\"lengthSeconds\":\""; let at = body.find(needle)? + needle.len(); let rest = &body[at..]; let end = rest.find('"')?; rest[..end].parse().ok().filter(|n| *n > 0) } /// Where an updated yt-dlp is kept. The bundle is read-only, so a newer copy /// lives in app data and takes precedence over the shipped one. fn updated_yt_dlp(app_data: &std::path::Path) -> PathBuf { app_data.join("bin").join("yt-dlp.pyz") } #[derive(Serialize)] pub struct UpdateStatus { pub current: Option, pub latest: Option, pub up_to_date: bool, } /// Asks GitHub what the newest yt-dlp release is. /// /// Only yt-dlp is checked. It breaks whenever YouTube changes something, so /// staying current matters; ffmpeg is stable and ships with the app. #[tauri::command] pub async fn check_yt_dlp_update(state: State<'_, AppState>) -> Result { let current = version_of(&state.yt_dlp_argv.lock().await.clone(), "--version").await; let latest = state .http .get("https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest") .header(reqwest::header::ACCEPT, "application/vnd.github+json") .send() .await .map_err(|e| format!("Could not reach GitHub: {e}"))? .text() .await .map_err(|e| format!("Unexpected reply from GitHub: {e}"))?; let latest = serde_json::from_str::(&latest) .ok() .and_then(|v| v.get("tag_name").and_then(|t| t.as_str()).map(str::to_string)); let up_to_date = match (¤t, &latest) { (Some(c), Some(l)) => c.trim() == l.trim(), _ => false, }; Ok(UpdateStatus { current, latest, up_to_date }) } /// Downloads the newest yt-dlp zipapp into app data and switches to it. #[tauri::command] pub async fn update_yt_dlp(state: State<'_, AppState>) -> Result { let dest = updated_yt_dlp(&state.app_data); if let Some(dir) = dest.parent() { tokio::fs::create_dir_all(dir) .await .map_err(|e| format!("Cannot create {}: {e}", dir.display()))?; } let bytes = state .http .get("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp") .send() .await .map_err(|e| format!("Download failed: {e}"))? .bytes() .await .map_err(|e| format!("Download failed: {e}"))?; if bytes.len() < 1_000_000 { return Err("That download does not look like yt-dlp; leaving the current one in place.".into()); } // Write beside the target then rename, so a failure never leaves a // half-written interpreter in place of a working one. let tmp = dest.with_extension("pyz.part"); tokio::fs::write(&tmp, &bytes) .await .map_err(|e| format!("Cannot write update: {e}"))?; tokio::fs::rename(&tmp, &dest) .await .map_err(|e| format!("Cannot install update: {e}"))?; // Point at the new copy without a restart. let mut argv = state.yt_dlp_argv.lock().await; if argv.len() > 1 { let last = argv.len() - 1; argv[last] = dest.to_string_lossy().to_string(); } let probe = argv.clone(); drop(argv); *state.prereqs.lock().await = None; version_of(&probe, "--version") .await .ok_or_else(|| "The update was installed but will not run.".to_string()) } #[tauri::command] pub async fn list_channels(state: State<'_, AppState>) -> Result, String> { state.db.lock().await.list_channels() } #[tauri::command] pub async fn list_feed( filter: FeedFilter, state: State<'_, AppState>, ) -> Result, String> { state.db.lock().await.list_feed(&filter) } #[tauri::command] pub async fn get_connectivity(state: State<'_, AppState>) -> Result { Ok(net::is_online(&state.http).await) } #[tauri::command] pub async fn refresh_feeds( app: AppHandle, state: State<'_, AppState>, ) -> Result { let channel_ids = state.db.lock().await.channel_ids()?; if channel_ids.is_empty() { return Err("No subscriptions imported yet. Import your Takeout CSV first.".into()); } let total = channel_ids.len(); let http = state.http.clone(); // Fetch with bounded concurrency. One channel failing must not abort the run. let results = futures::stream::iter(channel_ids.into_iter().map(|cid| { let http = http.clone(); async move { let res = feed::fetch_channel(&http, &cid).await; (cid, res) } })) .buffer_unordered(FEED_CONCURRENCY) .collect::>() .await; let mut new_videos = 0usize; let mut failures = Vec::new(); let mut done = 0usize; for (cid, res) in results { done += 1; match res { Ok(videos) => { let mut db = state.db.lock().await; if !videos.is_empty() { new_videos += db.upsert_videos(&videos)?; } // A channel that recovers should stop being flagged. db.set_channel_result(&cid, None)?; } Err(e) => { state.db.lock().await.set_channel_result(&cid, Some(&e))?; failures.push(format!("{cid}: {e}")); } } let _ = app.emit( "refresh:progress", RefreshProgress { done, total, channel: cid, }, ); } cache_thumbnails(&state).await; Ok(RefreshSummary { channels: total, new_videos, failures, }) } /// Fetches one channel's feed and stores it. Used when a channel is added by /// hand, so it is populated before the user sees it. async fn refresh_one( app: &AppHandle, state: &State<'_, AppState>, channel_id: &str, ) -> Result { let res = feed::fetch_channel(&state.http, channel_id).await; let mut db = state.db.lock().await; match res { Ok(videos) => { let n = if videos.is_empty() { 0 } else { db.upsert_videos(&videos)? }; db.set_channel_result(channel_id, None)?; drop(db); cache_thumbnails(state).await; let _ = app.emit("feed:changed", ()); Ok(n) } Err(e) => { db.set_channel_result(channel_id, Some(&e))?; Err(e) } } } /// Mirrors thumbnails to disk so the feed still renders with no network. async fn cache_thumbnails(state: &State<'_, AppState>) { let pending = match state.db.lock().await.videos_missing_thumbs(THUMB_BATCH) { Ok(p) => p, Err(_) => return, }; if pending.is_empty() { return; } let dir = thumbs::cache_dir(&state.app_data); let http = state.http.clone(); let cached = futures::stream::iter(pending.into_iter().map(|(id, url)| { let http = http.clone(); let dir = dir.clone(); async move { let res = thumbs::cache_one(&http, &id, &url, &dir).await; (id, res) } })) .buffer_unordered(FEED_CONCURRENCY) .collect::>() .await; let db = state.db.lock().await; for (id, res) in cached { if let Ok(path) = res { let _ = db.set_thumb_path(&id, &path.to_string_lossy()); } } } #[tauri::command] pub async fn download_video( video_id: String, quality: String, sub_lang: String, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let library = state.library.lock().await.clone(); tokio::fs::create_dir_all(&library) .await .map_err(|e| format!("Cannot create library dir: {e}"))?; { let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Queued, None)?; db.set_download_request(&video_id, &quality, &sub_lang)?; } let _ = app.emit( "download:state", DownloadStateEvent { video_id: video_id.clone(), state: DownloadState::Queued, error: None, path: None, }, ); let permit = state .download_slots .clone() .acquire_owned() .await .map_err(|e| format!("Download queue closed: {e}"))?; // A whole channel can be enqueued at once, so the wait for a slot can be // long. Cancelling during that wait has to actually stop the download // rather than have it start later anyway. { let db = state.db.lock().await; if db.download_state(&video_id)?.as_deref() != Some(DownloadState::Queued.as_str()) { return Ok(()); } } let out_template = library .join(downloader::OUTPUT_TEMPLATE) .to_string_lossy() .to_string(); let sub_langs = downloader::embed_sub_langs_for(&sub_lang); let mut args = downloader::build_args(&video_id, &out_template, &quality, &sub_langs); // Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no // reason to have. Merging video and audio would fail on a clean machine. args.push("--ffmpeg-location".into()); args.push(bin("ffmpeg")); let mut child = state .yt_dlp() .await .args(&args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| format!("Could not start yt-dlp: {e}"))?; let stdout = child.stdout.take().ok_or("yt-dlp produced no stdout")?; let stderr = child.stderr.take().ok_or("yt-dlp produced no stderr")?; state .children .lock() .await .insert(video_id.clone(), child); { let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Running, None)?; } let _ = app.emit( "download:state", DownloadStateEvent { video_id: video_id.clone(), state: DownloadState::Running, error: None, path: None, }, ); // Collect stderr concurrently so a failure has a real message, and so a // full stderr pipe can't deadlock the child. let stderr_task = tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); let mut buf = Vec::new(); while let Ok(Some(l)) = lines.next_line().await { buf.push(l); } buf }); let mut final_path: Option = None; let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { if let Some(p) = downloader::parse_final_path(&line) { final_path = Some(p); continue; } if let Some(Progress { downloaded, total, speed, eta, }) = downloader::parse_progress_line(&line) { let pct = Progress { downloaded, total, speed, eta, } .pct(); { let db = state.db.lock().await; let _ = db.set_download_progress(&video_id, downloaded, total, pct); } let _ = app.emit( "download:progress", DownloadProgressEvent { video_id: video_id.clone(), pct, bytes_done: downloaded, bytes_total: total, speed, eta, }, ); } } // Gone from the map means it was cancelled: the state is already recorded // and the event already sent. Stopping something is a normal outcome, not // a failure to report — a rejection here would raise a dialog per download // the moment you press Stop all. let Some(mut child) = state.children.lock().await.remove(&video_id) else { return Ok(()); }; let status = child .wait() .await .map_err(|e| format!("yt-dlp did not exit cleanly: {e}"))?; let stderr_lines = stderr_task.await.unwrap_or_default(); drop(permit); // yt-dlp exits non-zero if *anything* failed, including a subtitle it could // not fetch. If the video itself landed, the download succeeded — throwing // away a finished file over a missing caption would be absurd. let landed = match final_path { Some(p) if tokio::fs::metadata(&p).await.is_ok() => Some(p), _ => find_by_video_id(&library, &video_id).await, }; if let Some(path) = landed { // YouTube's captions arrive pinned to the left edge and full of // karaoke timing tags; clean them before they reach the player. tidy_subtitles(&library, &video_id).await; let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Done, None)?; db.set_download_path(&video_id, &path)?; drop(db); let _ = app.emit( "download:state", DownloadStateEvent { video_id, state: DownloadState::Done, error: None, path: Some(path), }, ); Ok(()) } else { let signed_in = state.cookies_from.lock().await.is_some(); let joined = stderr_lines.join("\n"); let msg = if joined.trim().is_empty() { format!("yt-dlp exited with {status}") } else { explain_yt_dlp_error(&joined, signed_in) }; let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Failed, Some(&msg))?; drop(db); let _ = app.emit( "download:state", DownloadStateEvent { video_id, state: DownloadState::Failed, error: Some(msg.clone()), path: None, }, ); Err(msg) } } #[tauri::command] pub async fn cancel_download( video_id: String, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { if let Some(mut child) = state.children.lock().await.remove(&video_id) { let _ = child.kill().await; } { let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Cancelled, None)?; } cleanup_partials(state.library.lock().await.clone(), &video_id).await; let _ = app.emit( "download:state", DownloadStateEvent { video_id, state: DownloadState::Cancelled, error: None, path: None, }, ); Ok(()) } /// Rewrites downloaded WebVTT so it renders as ordinary subtitles. /// /// YouTube's auto-captions carry `align:start position:0%` on every cue, which /// pins them to the left edge where long lines are clipped, plus inline /// `<00:00:12.480>word` timing tags that render as half-grey karaoke /// text. Both are stripped; the timings themselves are untouched. async fn tidy_subtitles(library: &std::path::Path, video_id: &str) { let Ok(mut entries) = tokio::fs::read_dir(library).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); if !name.contains(video_id) || !name.ends_with(".vtt") { continue; } let path = entry.path(); if let Ok(text) = tokio::fs::read_to_string(&path).await { let _ = tokio::fs::write(&path, tidy_vtt(&text)).await; } } } pub fn tidy_vtt(input: &str) -> String { let mut out = String::with_capacity(input.len()); for line in input.lines() { if line.contains("-->") { // Keep the timing, drop every cue setting after it. let end = line.find("-->").map(|i| i + 3).unwrap_or(0); let rest = &line[end..]; let stamp = rest.split_whitespace().next().unwrap_or(""); out.push_str(&line[..end]); out.push(' '); out.push_str(stamp); } else { out.push_str(&strip_cue_tags(line)); } out.push('\n'); } out } /// Removes `<...>` spans — both timestamps and `` wrappers. fn strip_cue_tags(line: &str) -> String { let mut out = String::with_capacity(line.len()); let mut depth = 0usize; for ch in line.chars() { match ch { '<' => depth += 1, '>' => depth = depth.saturating_sub(1), _ if depth == 0 => out.push(ch), _ => {} } } out } /// Locates a finished download by the `[]` tag in its filename. async fn find_by_video_id(library: &std::path::Path, video_id: &str) -> Option { let mut entries = tokio::fs::read_dir(library).await.ok()?; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); if name.contains(video_id) && !name.contains(".part") && !name.ends_with(".ytdl") { return Some(entry.path().to_string_lossy().to_string()); } } None } /// yt-dlp leaves `.part`, `.ytdl` and format-specific fragments behind when /// killed; without this the library slowly fills with dead bytes. async fn cleanup_partials(library: PathBuf, video_id: &str) { let Ok(mut entries) = tokio::fs::read_dir(&library).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); if name.contains(video_id) && (name.contains(".part") || name.ends_with(".ytdl")) { let _ = tokio::fs::remove_file(entry.path()).await; } } } /// WebVTT files yt-dlp wrote beside a download, as (language, path) pairs. #[tauri::command] pub async fn list_subtitles( video_id: String, state: State<'_, AppState>, ) -> Result, String> { let library = state.library.lock().await.clone(); let Ok(mut entries) = tokio::fs::read_dir(&library).await else { return Ok(Vec::new()); }; let mut out = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); if !name.contains(&video_id) || !name.ends_with(".vtt") { continue; } // Downloads made before the cleanup existed still carry YouTube's // edge-pinned cues, so tidy them the first time they are listed. let path = entry.path(); if let Ok(text) = tokio::fs::read_to_string(&path).await { if text.contains("align:") || text.contains("position:") || text.contains("") { let _ = tokio::fs::write(&path, tidy_vtt(&text)).await; } } // yt-dlp names them "..vtt". let lang = name .trim_end_matches(".vtt") .rsplit('.') .next() .unwrap_or("") .to_string(); let Ok(text) = tokio::fs::read_to_string(&path).await else { continue; }; out.push((lang, tidy_vtt(&text))); } out.sort(); Ok(out) } /// Reads the WebVTT files in a directory as (language, text), newest naming /// convention `..vtt`. Identical texts are collapsed: YouTube serves /// the same auto-generated captions under both `en` and `en-orig`, and offering /// the same track twice is just noise in the menu. async fn read_vtt_dir(dir: &Path) -> Vec<(String, String)> { let Ok(mut entries) = tokio::fs::read_dir(dir).await else { return Vec::new(); }; let mut out: Vec<(String, String)> = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); let Some(stem) = name.strip_suffix(".vtt") else { continue }; let Some((_, lang)) = stem.rsplit_once('.') else { continue }; let Ok(text) = tokio::fs::read_to_string(entry.path()).await else { continue; }; out.push((lang.to_string(), tidy_vtt(&text))); } // Sorting first makes the survivor of a duplicate the plainer code: "en" // rather than "en-orig". out.sort(); let mut seen: Vec = Vec::new(); out.retain(|(_, text)| { if seen.iter().any(|t| t == text) { false } else { seen.push(text.clone()); true } }); out } /// Subtitles muxed into a downloaded file, as (language, WebVTT text). /// /// The file keeps them, but the player does not use the element's own in-band /// rendering: WebKit hands those to the media pipeline, which places them /// wherever the container's text box says — bottom right, in practice — and no /// CSS can reach them. Read out as WebVTT they are ordinary cues, centred and /// styleable like any other. Reading one costs about 40ms and no re-encoding. #[tauri::command] pub async fn embedded_subtitles( video_id: String, state: State<'_, AppState>, ) -> Result, String> { let Some(path) = state.db.lock().await.get_download_path(&video_id)? else { return Ok(Vec::new()); }; if tokio::fs::metadata(&path).await.is_err() { return Ok(Vec::new()); } let probe = tokio::process::Command::new(bin("ffprobe")) .args([ "-v", "error", "-select_streams", "s", "-show_entries", "stream=index:stream_tags=language", "-of", "csv=p=0", &path, ]) .output() .await .map_err(|e| format!("Could not read the file: {e}"))?; let mut out: Vec<(String, String)> = Vec::new(); // ffprobe prints "," per subtitle stream, and the index is // the absolute stream index — the mapping below counts subtitle streams, so // position in this list is what matters, not the number printed. for (nth, line) in String::from_utf8_lossy(&probe.stdout).lines().enumerate() { let lang = line .split(',') .nth(1) .map(|l| l.trim()) .filter(|l| !l.is_empty() && *l != "und") .unwrap_or("en") .to_string(); let dump = tokio::process::Command::new(bin("ffmpeg")) .args([ "-v", "error", "-i", &path, "-map", &format!("0:s:{nth}"), "-f", "webvtt", "-", ]) .output() .await; let Ok(dump) = dump else { continue }; let text = String::from_utf8_lossy(&dump.stdout).to_string(); if text.contains("-->") { // ISO 639-2 is what a container stores; the player labels by the // two-letter code everything else uses. let short = match lang.as_str() { "eng" => "en", "nld" | "dut" => "nl", "deu" | "ger" => "de", "fra" | "fre" => "fr", "spa" => "es", "ita" => "it", "por" => "pt", other => other, }; out.push((short.to_string(), tidy_vtt(&text))); } } Ok(out) } /// Subtitles for a video, as (language, WebVTT text). /// /// Streaming has no other source: YouTube's HLS manifest lists a dozen audio /// renditions and no subtitles at all. A download saved before subtitles were /// switched on has none beside it either, and this fills those in without /// fetching the video again. Results are cached per video and language, so /// replaying something costs nothing and works offline. #[tauri::command] pub async fn fetch_subtitles( video_id: String, lang: String, state: State<'_, AppState>, ) -> Result, String> { let sub_langs = downloader::sub_langs_for(&lang); if sub_langs.is_empty() { return Ok(Vec::new()); } let dir = state.app_data.join("subs").join(&video_id).join(&lang); let cached = read_vtt_dir(&dir).await; if !cached.is_empty() { return Ok(cached); } tokio::fs::create_dir_all(&dir) .await .map_err(|e| format!("Cannot create subtitle cache: {e}"))?; let template = dir.join("%(id)s.%(ext)s").to_string_lossy().to_string(); let mut args = downloader::subs_only_args(&video_id, &template, &sub_langs); args.push("--ffmpeg-location".into()); args.push(bin("ffmpeg")); // A video with no captions in this language is an ordinary outcome, not a // failure worth reporting, so the exit status is not consulted: whatever // landed on disk is the answer. let _ = state.yt_dlp().await.args(&args).output().await; Ok(read_vtt_dir(&dir).await) } /// Records one video from its URL and returns its id and title, ready to /// download. Its channel is stored as a parent row only — saving a video is not /// subscribing to the channel, which is a separate choice. pub async fn save_video(state: &AppState, url: &str) -> Result<(String, String), String> { let Some(video_id) = resolve::video_id_from_url(url) else { return Err("That link is not a video.".into()); }; let html = fetch_page(state, url).await?; let Some((channel_id, channel_title)) = resolve::parse_channel(&html) else { return Err("Could not read that video's page.".into()); }; let meta = resolve::parse_video(&html); let channel = Channel { id: channel_id.clone(), title: channel_title, url: resolve::channel_url(&channel_id), }; let video = Video { id: video_id.clone(), channel_id, title: meta.title.clone(), description: String::new(), published: meta .published .as_deref() .and_then(resolve::iso_date_to_unix) .unwrap_or_else(now_secs), thumb_url: format!("https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"), views: 0, is_short: url.contains("/shorts/"), }; { let mut db = state.db.lock().await; db.ensure_channel(&channel)?; db.upsert_videos(&[video])?; } Ok((video_id, meta.title)) } /// The quality and subtitle language the app is set to, so work started from /// the menu bar matches work started from the window. #[tauri::command] pub async fn set_download_defaults( quality: String, sub_lang: String, state: State<'_, AppState>, ) -> Result<(), String> { *state.download_defaults.lock().await = (quality, sub_lang); Ok(()) } /// What deleting one subscription would take with it. #[derive(Serialize)] pub struct RemovalPreview { pub title: String, pub videos: i64, pub downloaded: usize, } #[tauri::command] pub async fn preview_delete_channel( channel_id: String, state: State<'_, AppState>, ) -> Result { let db = state.db.lock().await; let (videos, paths) = db.channel_removal(&channel_id)?; let title = db .list_channels()? .into_iter() .find(|c| c.id == channel_id) .map(|c| c.title) .unwrap_or_else(|| "this channel".to_string()); Ok(RemovalPreview { title, videos, downloaded: paths.len() }) } /// Unsubscribes in the app only. Nothing is touched on YouTube. #[tauri::command] pub async fn delete_channel( channel_id: String, state: State<'_, AppState>, ) -> Result<(), String> { let paths = { let db = state.db.lock().await; db.channel_removal(&channel_id)?.1 }; for p in &paths { let _ = tokio::fs::remove_file(p).await; } state.db.lock().await.delete_channel(&channel_id) } /// Fetches a YouTube page as a browser would, so the embedded player data is /// there to read. async fn fetch_page(state: &AppState, url: &str) -> Result { let resp = state .http .get(url) .header("Accept-Language", "en-US,en;q=0.9") // Without a consent cookie some regions get an interstitial instead of // the page, and none of the player data is in that. .header("Cookie", "CONSENT=YES+1") .send() .await .map_err(|e| format!("Could not reach YouTube: {e}"))?; if !resp.status().is_success() { return Err(format!("YouTube returned {}", resp.status())); } resp.text() .await .map_err(|e| format!("Could not read the page: {e}")) } /// Adds one channel from any YouTube URL — the channel's own page, a handle, or /// a video of theirs. Returns the channel's title. #[tauri::command] pub async fn add_channel( url: String, app: AppHandle, state: State<'_, AppState>, ) -> Result { let url = url.trim().to_string(); if url.is_empty() { return Err("Paste a YouTube channel or video link first.".into()); } let url = if url.starts_with("http") { url } else { format!("https://{url}") }; if !resolve::is_youtube_url(&url) { return Err("That is not a YouTube link.".into()); } let html = fetch_page(&state, &url).await?; let Some((id, title)) = resolve::parse_channel(&html) else { return Err("No channel found at that link. A channel page or one of its videos works best.".into()); }; if state.db.lock().await.is_subscribed(&id)? { return Err(format!("{title} is already in your subscriptions.")); } let channel = Channel { id: id.clone(), title: title.clone(), url: resolve::channel_url(&id) }; state.db.lock().await.upsert_channels(&[channel])?; // Fill the new channel in straight away, or it sits there empty. let _ = refresh_one(&app, &state, &id).await; Ok(title) } /// Downloads that were still going when the app last closed. /// /// Killing the app kills yt-dlp with it, leaving rows queued or running that no /// process backs. The front end hands these straight back to `download_video`, /// so they rejoin the same queue rather than needing a second code path. #[tauri::command] pub async fn interrupted_downloads( state: State<'_, AppState>, ) -> Result, String> { state.db.lock().await.interrupted_downloads() } /// Stops everything downloading or waiting to download. /// /// Kills the running processes, then marks every row the database still calls /// queued or running as cancelled — which covers downloads parked on a slot, /// and any left behind by a crash that no process backs any more. #[tauri::command] pub async fn cancel_all_downloads( app: AppHandle, state: State<'_, AppState>, ) -> Result { // Drain first and kill after, so no child is killed while the map is held. let children: Vec<(String, tokio::process::Child)> = state.children.lock().await.drain().collect(); for (_, mut child) in children { let _ = child.kill().await; } let ids = state.db.lock().await.active_downloads()?; let library = state.library.lock().await.clone(); for id in &ids { state .db .lock() .await .set_download_state(id, DownloadState::Cancelled, None)?; cleanup_partials(library.clone(), id).await; let _ = app.emit( "download:state", DownloadStateEvent { video_id: id.clone(), state: DownloadState::Cancelled, error: None, path: None, }, ); } Ok(ids.len()) } /// Removes every download and the files behind them, including subtitles. #[tauri::command] pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result { let paths = state.db.lock().await.all_download_paths()?; for p in &paths { let _ = tokio::fs::remove_file(p).await; } // Subtitle sidecars are not tracked in the database, so sweep them here. let library = state.library.lock().await.clone(); if let Ok(mut entries) = tokio::fs::read_dir(&library).await { while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); if name.ends_with(".vtt") || name.contains(".part") || name.ends_with(".ytdl") { let _ = tokio::fs::remove_file(entry.path()).await; } } } // Fetched captions live in the cache rather than beside the video, so they // would otherwise survive a wipe. let _ = tokio::fs::remove_dir_all(state.app_data.join("subs")).await; state.db.lock().await.clear_all_downloads() } #[tauri::command] pub async fn delete_download( video_id: String, state: State<'_, AppState>, ) -> Result<(), String> { let path = state.db.lock().await.get_download_path(&video_id)?; if let Some(p) = path { let _ = tokio::fs::remove_file(&p).await; } let library = state.library.lock().await.clone(); cleanup_partials(library.clone(), &video_id).await; // The .vtt sidecars belong to the video, so they go with it. if let Ok(mut entries) = tokio::fs::read_dir(&library).await { while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); if name.contains(&video_id) && name.ends_with(".vtt") { let _ = tokio::fs::remove_file(entry.path()).await; } } } state.db.lock().await.clear_download(&video_id) } #[tauri::command] pub async fn set_library_path( path: String, state: State<'_, AppState>, ) -> Result { let p = PathBuf::from(&path); tokio::fs::create_dir_all(&p) .await .map_err(|e| format!("Cannot use that folder: {e}"))?; *state.library.lock().await = p.clone(); Ok(p.to_string_lossy().to_string()) } #[tauri::command] pub async fn open_external(app: AppHandle, url: String) -> Result<(), String> { use tauri_plugin_opener::OpenerExt; app.opener() .open_url(url, None::<&str>) .map_err(|e| format!("Could not open link: {e}")) } pub fn default_library() -> PathBuf { dirs_home() .map(|h| h.join("Movies").join("FlightTube")) .unwrap_or_else(|| PathBuf::from("FlightTube")) } fn dirs_home() -> Option { std::env::var_os("HOME").map(PathBuf::from) } pub fn build_state(app: &AppHandle) -> Result { let app_data = app .path() .app_data_dir() .map_err(|e| format!("No app data dir: {e}"))?; std::fs::create_dir_all(&app_data).map_err(|e| format!("Cannot create app data dir: {e}"))?; let db = Db::open(&app_data.join("flighttube.db"))?; let library = default_library(); let _ = std::fs::create_dir_all(&library); let http = reqwest::Client::builder() .user_agent("FlightTube/0.1 (+desktop)") .timeout(std::time::Duration::from_secs(20)) .build() .map_err(|e| format!("Cannot build HTTP client: {e}"))?; let yt_dlp_argv = resolve_yt_dlp(app, &app_data); Ok(AppState { db: Arc::new(Mutex::new(db)), http, library: Arc::new(Mutex::new(library)), app_data, children: Arc::new(Mutex::new(HashMap::new())), download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)), playlists: PlaylistServer::start()?, prereqs: Arc::new(Mutex::new(None)), streams: Arc::new(Mutex::new(HashMap::new())), yt_dlp_argv: Arc::new(Mutex::new(yt_dlp_argv)), download_defaults: Arc::new(Mutex::new(("best".into(), "en".into()))), cookies_from: Arc::new(Mutex::new(None)), }) } #[cfg(test)] mod tests { use super::rewrite_master; /// Two audio languages, with the dub marked default — the case that puts a /// synthetic voice over the original. const DUBBED: &str = concat!( "#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n", "#EXT-X-MEDIA:URI=\"https://a/en.m3u8\",TYPE=AUDIO,GROUP-ID=\"en\",LANGUAGE=\"en\",NAME=\"English original\",DEFAULT=NO,AUTOSELECT=NO\n", "#EXT-X-MEDIA:URI=\"https://a/nl.m3u8\",TYPE=AUDIO,GROUP-ID=\"nl\",LANGUAGE=\"nl\",NAME=\"Nederlands (dubbed)\",DEFAULT=YES,AUTOSELECT=YES\n", "#EXT-X-MEDIA:URI=\"https://a/sub.m3u8\",TYPE=SUBTITLES,GROUP-ID=\"vtt\",LANGUAGE=\"en\",NAME=\"English\",DEFAULT=NO,AUTOSELECT=YES\n", "#EXT-X-STREAM-INF:BANDWIDTH=756324,RESOLUTION=640x360,AUDIO=\"en\"\n", "https://a/360.m3u8\n", "#EXT-X-STREAM-INF:BANDWIDTH=3878958,RESOLUTION=1280x720,AUDIO=\"en\"\n", "https://a/720.m3u8\n", "#EXT-X-STREAM-INF:BANDWIDTH=6039686,RESOLUTION=1920x1080,AUDIO=\"en\"\n", "https://a/1080.m3u8\n", ); fn audio_line(out: &str, lang: &str) -> String { out.lines() .find(|l| l.contains("TYPE=AUDIO") && l.contains(&format!("LANGUAGE=\"{lang}\""))) .unwrap_or("") .to_string() } use super::{parse_length_seconds, tidy_vtt}; /// Exactly the shape yt-dlp writes for YouTube auto-captions. const RAW_VTT: &str = concat!( "WEBVTT\nKind: captions\nLanguage: en\n\n", "00:00:12.400 --> 00:00:26.950 align:start position:0%\n", "Heat<00:00:12.480> up<00:00:12.480> here.\n", ); #[test] fn cue_settings_that_pin_subtitles_to_the_edge_are_removed() { let out = tidy_vtt(RAW_VTT); assert!(!out.contains("align:start")); assert!(!out.contains("position:0%")); // The timing itself must survive intact. assert!(out.contains("00:00:12.400 --> 00:00:26.950")); } #[test] fn karaoke_timing_tags_are_stripped_leaving_plain_text() { let out = tidy_vtt(RAW_VTT); assert!(out.contains("Heat up here.")); assert!(!out.contains("")); assert!(!out.contains("00:00:12.480>")); } #[test] fn the_header_survives_or_the_file_stops_being_webvtt() { assert!(tidy_vtt(RAW_VTT).starts_with("WEBVTT")); } #[test] fn reads_the_length_out_of_a_watch_page() { assert_eq!( parse_length_seconds(r#"...,"lengthSeconds":"1315","isLive"..."#), Some(1315) ); assert_eq!(parse_length_seconds("nothing here"), None); // A zero length is meaningless and must not be stored. assert_eq!(parse_length_seconds(r#""lengthSeconds":"0""#), None); } #[test] fn the_original_language_becomes_the_default_track() { let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); assert!(audio_line(&out, "en").contains("DEFAULT=YES")); assert!(audio_line(&out, "en").contains("AUTOSELECT=YES")); assert!(audio_line(&out, "nl").contains("DEFAULT=NO")); assert!(audio_line(&out, "nl").contains("AUTOSELECT=NO")); } #[test] fn every_audio_track_is_still_offered() { // Demoting a dub must not remove it; the player still has to be able // to switch to it. let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); assert!(out.contains("https://a/en.m3u8")); assert!(out.contains("https://a/nl.m3u8")); } #[test] fn subtitles_survive_untouched() { let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); assert!(out.contains("TYPE=SUBTITLES")); assert!(out.contains("https://a/sub.m3u8")); } #[test] fn without_a_language_it_avoids_anything_calling_itself_dubbed() { let out = rewrite_master(DUBBED, None, None).unwrap(); assert!(audio_line(&out, "en").contains("DEFAULT=YES")); assert!(audio_line(&out, "nl").contains("DEFAULT=NO")); } #[test] fn an_unknown_language_still_picks_a_sane_default() { // A language we cannot match must not leave every track demoted. let out = rewrite_master(DUBBED, None, Some("zz")).unwrap(); let defaults = out .lines() .filter(|l| l.contains("TYPE=AUDIO") && l.contains("DEFAULT=YES")) .count(); assert_eq!(defaults, 1); } #[test] fn no_cap_keeps_every_variant_so_the_player_can_adapt() { let out = rewrite_master(DUBBED, None, Some("en")).unwrap(); for u in ["https://a/360.m3u8", "https://a/720.m3u8", "https://a/1080.m3u8"] { assert!(out.contains(u), "missing {u}"); } } #[test] fn a_cap_keeps_the_best_variant_at_or_below_it() { let out = rewrite_master(DUBBED, Some(720), Some("en")).unwrap(); assert!(out.contains("https://a/720.m3u8")); assert!(!out.contains("https://a/1080.m3u8")); assert!(!out.contains("https://a/360.m3u8")); // The audio fix still applies when capping. assert!(audio_line(&out, "en").contains("DEFAULT=YES")); } #[test] fn nothing_below_the_cap_yields_none_so_the_caller_can_fall_back() { assert!(rewrite_master(DUBBED, Some(144), Some("en")).is_none()); assert!(rewrite_master("#EXTM3U\n", Some(1080), None).is_none()); assert!(rewrite_master("", None, None).is_none()); } #[test] fn a_single_audio_track_is_left_as_the_default() { let single = concat!( "#EXTM3U\n", "#EXT-X-MEDIA:URI=\"https://a/a.m3u8\",TYPE=AUDIO,GROUP-ID=\"1\",NAME=\"Default\",DEFAULT=YES,AUTOSELECT=YES\n", "#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=640x360,AUDIO=\"1\"\n", "https://a/360.m3u8\n", ); let out = rewrite_master(single, None, None).unwrap(); assert!(out.contains("TYPE=AUDIO")); assert!(out.contains("DEFAULT=YES")); } }