Tested end to end from the menu bar: a video playing in Arc was saved, its channel subscribed to, and the subscription removed again. Three things were wrong. One script named all seven browsers. AppleScript resolves an application's terminology when it compiles, so naming a browser that is not installed is a compile error — which no `try` can catch, and which kills the whole script before a line of it runs. On a Mac without Brave the lookup failed outright and Arc, first in the list and working, was never asked. Each browser now gets its own script. The tray icon was dropped as soon as it was built. TrayIcon is reference-counted and "the icon is removed when the last instance is dropped", so it was created and destroyed in the same breath. It is held for the life of the app. Subscribing to a channel already present as a bare row — the parent of a video saved from the menu bar — was refused as a duplicate, and the upsert would not have promoted it anyway. Saving a video from a channel therefore made it impossible to subscribe to. The check asks whether it is a subscription, not whether the row exists, and subscribing promotes. Feedback no longer depends on one channel that can fail silently: a notification from the app, the AppleScript one behind it, and the menu's own first line, which reports the last result and cannot be suppressed. A log beside the database records each step, which is how all three of these were found rather than guessed at.
2023 lines
70 KiB
Rust
2023 lines
70 KiB
Rust
//! 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<Mutex<Db>>,
|
|
pub http: reqwest::Client,
|
|
pub library: Arc<Mutex<PathBuf>>,
|
|
pub app_data: PathBuf,
|
|
pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
|
|
pub download_slots: Arc<Semaphore>,
|
|
pub playlists: PlaylistServer,
|
|
/// yt-dlp's PyInstaller bundle costs several seconds to start, so its
|
|
/// version is read once and kept.
|
|
pub prereqs: Arc<Mutex<Option<Prereqs>>>,
|
|
/// 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<Mutex<HashMap<(String, Option<u32>), (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<Mutex<Vec<String>>>,
|
|
/// 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<Mutex<(String, String)>>,
|
|
/// Value for yt-dlp's --cookies-from-browser, when signed in.
|
|
pub cookies_from: Arc<Mutex<Option<String>>>,
|
|
}
|
|
|
|
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<Vec<(String, String)>, 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<String, String> {
|
|
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<f64>,
|
|
bytes_done: u64,
|
|
bytes_total: Option<u64>,
|
|
speed: Option<f64>,
|
|
eta: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
struct DownloadStateEvent {
|
|
video_id: String,
|
|
state: DownloadState,
|
|
error: Option<String>,
|
|
path: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RefreshSummary {
|
|
pub channels: usize,
|
|
pub new_videos: usize,
|
|
pub failures: Vec<String>,
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<String> {
|
|
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<Prereqs, String> {
|
|
// 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::<Vec<_>>().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<Vec<Channel>, 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<ImportPreview, String> {
|
|
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<usize, String> {
|
|
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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn resolve_stream(
|
|
video_id: String,
|
|
max_height: Option<u32>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Stream, String> {
|
|
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<u32>),
|
|
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<u32>,
|
|
original_lang: Option<&str>,
|
|
) -> Option<String> {
|
|
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<usize> {
|
|
// 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<String> {
|
|
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<u32> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<usize, String> {
|
|
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::<Vec<_>>()
|
|
.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<i64> {
|
|
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<i64> {
|
|
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<String>,
|
|
pub latest: Option<String>,
|
|
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<UpdateStatus, String> {
|
|
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::<serde_json::Value>(&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<String, String> {
|
|
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<Vec<ChannelWithCount>, String> {
|
|
state.db.lock().await.list_channels()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_feed(
|
|
filter: FeedFilter,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Vec<FeedItem>, String> {
|
|
state.db.lock().await.list_feed(&filter)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_connectivity(state: State<'_, AppState>) -> Result<bool, String> {
|
|
Ok(net::is_online(&state.http).await)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn refresh_feeds(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<RefreshSummary, String> {
|
|
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::<Vec<_>>()
|
|
.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<usize, String> {
|
|
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::<Vec<_>>()
|
|
.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<String> = 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><c>word</c>` 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 `<c>` 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 `[<id>]` tag in its filename.
|
|
async fn find_by_video_id(library: &std::path::Path, video_id: &str) -> Option<String> {
|
|
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<Vec<(String, String)>, 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("<c>") {
|
|
let _ = tokio::fs::write(&path, tidy_vtt(&text)).await;
|
|
}
|
|
}
|
|
|
|
// yt-dlp names them "<base>.<lang>.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 `<id>.<lang>.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<String> = 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<Vec<(String, String)>, 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 "<index>,<language>" 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<Vec<(String, String)>, 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<RemovalPreview, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<Vec<(String, String, String)>, 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<usize, String> {
|
|
// 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<usize, String> {
|
|
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<String, String> {
|
|
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<PathBuf> {
|
|
std::env::var_os("HOME").map(PathBuf::from)
|
|
}
|
|
|
|
pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
|
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><c> up</c><00:00:12.480><c> here.</c>\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("<c>"));
|
|
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"));
|
|
}
|
|
}
|