diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 075c710..67b7134 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -369,6 +369,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1939,6 +1945,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2371,6 +2390,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -3004,6 +3033,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.41.0" @@ -4038,6 +4073,7 @@ dependencies = [ "heck 0.5.0", "http", "http-range", + "image", "jni 0.21.1", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2e34398..c3995e1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..347c3fb --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,10 @@ + + + + + + NSAppleEventsUsageDescription + FlightTube reads the address of the page your browser is showing, so the menu bar can save that video or add its channel. + + diff --git a/src-tauri/icons/tray.png b/src-tauri/icons/tray.png new file mode 100644 index 0000000..a7ac3e5 Binary files /dev/null and b/src-tauri/icons/tray.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ec6defc..8f28474 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,9 +5,11 @@ 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; @@ -47,6 +49,9 @@ pub struct AppState { /// 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>>, } @@ -227,6 +232,14 @@ pub struct RefreshSummary { /// `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() { @@ -989,6 +1002,31 @@ pub async fn refresh_feeds( }) } +/// 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) { @@ -1522,6 +1560,154 @@ pub async fn fetch_subtitles( 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.has_channel(&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 @@ -1680,6 +1866,7 @@ pub fn build_state(app: &AppHandle) -> Result { 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)), }) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b06011b..b5591be 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -95,6 +95,12 @@ impl Db { // What a download was asked for, so it can be resumed as requested. let _ = conn.execute("ALTER TABLE downloads ADD COLUMN quality TEXT", []); let _ = conn.execute("ALTER TABLE downloads ADD COLUMN sub_lang TEXT", []); + // A channel row that is not a subscription: the parent of a one-off + // video saved from the menu bar. Existing rows are all subscriptions. + let _ = conn.execute( + "ALTER TABLE channels ADD COLUMN subscribed INTEGER NOT NULL DEFAULT 1", + [], + ); Ok(Db { conn }) } @@ -183,6 +189,7 @@ impl Db { "SELECT v.channel_id, COUNT(*), SUM(CASE WHEN d.state = 'done' THEN 1 ELSE 0 END) FROM videos v + JOIN channels c ON c.id = v.channel_id AND c.subscribed = 1 LEFT JOIN downloads d ON d.video_id = v.id GROUP BY v.channel_id", ) @@ -225,6 +232,7 @@ impl Db { .prepare( "SELECT v.channel_id, d.path FROM downloads d JOIN videos v ON v.id = d.video_id + JOIN channels c ON c.id = v.channel_id AND c.subscribed = 1 WHERE d.path IS NOT NULL", ) .map_err(|e| e.to_string())?; @@ -265,12 +273,16 @@ impl Db { } tx.execute_batch( - "DELETE FROM downloads WHERE video_id IN ( - SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); + "CREATE TEMP VIEW IF NOT EXISTS dropped AS + SELECT id FROM channels + WHERE subscribed = 1 AND id NOT IN (SELECT id FROM keep_ids); + DELETE FROM downloads WHERE video_id IN ( + SELECT id FROM videos WHERE channel_id IN (SELECT id FROM dropped)); DELETE FROM playback WHERE video_id IN ( - SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); - DELETE FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids); - DELETE FROM channels WHERE id NOT IN (SELECT id FROM keep_ids);", + SELECT id FROM videos WHERE channel_id IN (SELECT id FROM dropped)); + DELETE FROM videos WHERE channel_id IN (SELECT id FROM dropped); + DELETE FROM channels WHERE id IN (SELECT id FROM dropped); + DROP VIEW dropped;", ) .map_err(|e| e.to_string())?; @@ -290,6 +302,65 @@ impl Db { Ok(channels.len()) } + /// What removing one subscription would take with it: its videos, and + /// which of those are downloaded (with the files to delete). + pub fn channel_removal(&self, id: &str) -> Result<(i64, Vec), String> { + let videos: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM videos WHERE channel_id = ?1", + params![id], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + let mut stmt = self + .conn + .prepare( + "SELECT d.path FROM downloads d + JOIN videos v ON v.id = d.video_id + WHERE v.channel_id = ?1 AND d.path IS NOT NULL", + ) + .map_err(|e| e.to_string())?; + let paths = stmt + .query_map(params![id], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + Ok((videos, paths)) + } + + /// Removes one subscription and everything hanging off it. + pub fn delete_channel(&mut self, id: &str) -> Result<(), String> { + let tx = self.conn.transaction().map_err(|e| e.to_string())?; + tx.execute( + "DELETE FROM downloads WHERE video_id IN (SELECT id FROM videos WHERE channel_id = ?1)", + params![id], + ) + .map_err(|e| e.to_string())?; + tx.execute( + "DELETE FROM playback WHERE video_id IN (SELECT id FROM videos WHERE channel_id = ?1)", + params![id], + ) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM videos WHERE channel_id = ?1", params![id]) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM channels WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string()) + } + + /// True when a channel is already subscribed. + pub fn has_channel(&self, id: &str) -> Result { + self.conn + .query_row("SELECT 1 FROM channels WHERE id = ?1", params![id], |_| Ok(())) + .map(|_| true) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(false), + other => Err(other), + }) + .map_err(|e| e.to_string()) + } + pub fn upsert_channels(&mut self, channels: &[Channel]) -> Result { let tx = self.conn.transaction().map_err(|e| e.to_string())?; { @@ -320,6 +391,7 @@ impl Db { WHERE v.channel_id = c.id AND d.state = 'done'), c.last_error FROM channels c + WHERE c.subscribed = 1 ORDER BY c.title COLLATE NOCASE ASC", ) .map_err(|e| e.to_string())?; @@ -344,7 +416,7 @@ impl Db { pub fn channel_ids(&self) -> Result, String> { let mut stmt = self .conn - .prepare("SELECT id FROM channels") + .prepare("SELECT id FROM channels WHERE subscribed = 1") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| r.get::<_, String>(0)) @@ -356,6 +428,20 @@ impl Db { /// Upserts video metadata only. Deliberately never touches the `downloads` /// table, so a refresh cannot lose the record of what has been downloaded. /// `thumb_path` is preserved via COALESCE for the same reason. + /// Records a channel without subscribing to it, for a video saved on its + /// own. An existing subscription is left exactly as it is. + pub fn ensure_channel(&self, c: &Channel) -> Result<(), String> { + self.conn + .execute( + "INSERT INTO channels (id, title, url, added_at, subscribed) + VALUES (?1, ?2, ?3, ?4, 0) + ON CONFLICT(id) DO UPDATE SET title = excluded.title", + params![c.id, c.title, c.url, now()], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + pub fn upsert_videos(&mut self, videos: &[Video]) -> Result { let tx = self.conn.transaction().map_err(|e| e.to_string())?; { @@ -1070,6 +1156,45 @@ mod tests { ); } + #[test] + fn a_bare_channel_row_is_not_a_subscription() { + let mut db = seeded(); + let one_off = Channel { + id: "UCX".into(), + title: "Someone Else".into(), + url: "https://youtube.com/channel/UCX".into(), + }; + db.ensure_channel(&one_off).unwrap(); + // It exists as a parent for the video, but it is not in the sidebar and + // refreshing does not go looking for it. + assert!(db.has_channel("UCX").unwrap()); + assert!(!db.list_channels().unwrap().iter().any(|c| c.id == "UCX")); + assert!(!db.channel_ids().unwrap().contains(&"UCX".to_string())); + + // And importing a CSV, which replaces subscriptions, leaves it alone. + let incoming = vec![Channel { + id: "UC1".into(), + title: "Alpha".into(), + url: "https://youtube.com/channel/UC1".into(), + }]; + db.replace_channels(&incoming).unwrap(); + assert!(db.has_channel("UCX").unwrap()); + } + + #[test] + fn deleting_a_subscription_takes_its_videos_with_it() { + let mut db = seeded(); + db.set_download_state("a", DownloadState::Done, None).unwrap(); + db.set_download_path("a", "/tmp/a.mp4").unwrap(); + let (videos, paths) = db.channel_removal("UC1").unwrap(); + assert_eq!(videos, 2); + assert_eq!(paths, vec!["/tmp/a.mp4".to_string()]); + + db.delete_channel("UC1").unwrap(); + assert!(!db.has_channel("UC1").unwrap()); + assert!(db.list_feed(&FeedFilter::default()).unwrap().iter().all(|f| f.id != "a")); + } + #[test] fn active_downloads_are_the_queued_and_running_ones() { let db = seeded(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index acb1307..1e9d64c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,8 +5,10 @@ pub mod feed; pub mod models; pub mod net; pub mod playlist_server; +pub mod resolve; pub mod takeout; pub mod thumbs; +pub mod tray; use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}; use tauri::Manager; @@ -82,6 +84,7 @@ pub fn run() { .menu(build_menu) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .on_menu_event(tray::on_menu_event) .setup(|app| { let state = commands::build_state(&app.handle().clone())?; app.manage(state); @@ -91,55 +94,14 @@ pub fn run() { enable_element_fullscreen(&window); } + // The menu bar item, so a video playing in a browser can be saved + // without coming back to the window. + tray::build(&app.handle().clone())?; + // The bundled yt-dlp takes seconds to start, so pay that once in // the background rather than the first time Settings is opened. let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { - use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}; -use tauri::Manager; - -/// The macOS menu bar, minus Edit and Help. -/// -/// Tauri's default adds both; neither has anything to offer here. Edit is kept -/// as a hidden-in-spirit necessity though — its Cut/Copy/Paste items are what -/// make those shortcuts work in the search field, so they live under the app -/// menu instead of their own top-level entry. -fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { - let app_menu = Submenu::with_items( - app, - "FlightTube", - true, - &[ - &PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::hide(app, None)?, - &PredefinedMenuItem::hide_others(app, None)?, - &PredefinedMenuItem::show_all(app, None)?, - &PredefinedMenuItem::separator(app)?, - // Keeps ⌘X/⌘C/⌘V working in text fields without an Edit menu. - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::quit(app, None)?, - ], - )?; - - let window_menu = Submenu::with_items( - app, - "Window", - true, - &[ - &PredefinedMenuItem::minimize(app, None)?, - &PredefinedMenuItem::maximize(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::close_window(app, None)?, - ], - )?; - - Menu::with_items(app, &[&app_menu, &window_menu]) -} let state = handle.state::(); let _ = commands::check_prereqs(state).await; }); @@ -173,6 +135,10 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { commands::get_connectivity, commands::set_library_path, commands::open_external, + commands::add_channel, + commands::delete_channel, + commands::preview_delete_channel, + commands::set_download_defaults, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/resolve.rs b/src-tauri/src/resolve.rs new file mode 100644 index 0000000..063fa16 --- /dev/null +++ b/src-tauri/src/resolve.rs @@ -0,0 +1,272 @@ +//! Turning a pasted or browsed YouTube URL into something the app can store. +//! +//! The app's subscriptions come from a Takeout CSV, which gives a channel id, a +//! title and a URL. A channel added by hand — or picked up from whatever is +//! playing in a browser — has to arrive at the same three things, and a video +//! picked up that way needs enough to sit in the feed like any other. +//! +//! Everything here works on strings so it can be tested without the network. + +/// The video id in a YouTube URL, in any of the shapes YouTube uses. +pub fn video_id_from_url(url: &str) -> Option { + let valid = |s: &str| s.len() == 11 && s.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-'); + + // watch?v=ID, with the parameter anywhere in the query. + if let Some(q) = url.split('?').nth(1) { + for pair in q.split('&') { + if let Some(v) = pair.strip_prefix("v=") { + let v = v.split('#').next().unwrap_or(v); + if valid(v) { + return Some(v.to_string()); + } + } + } + } + + // youtu.be/ID, /shorts/ID, /live/ID, /embed/ID. + let path = url.split('?').next().unwrap_or(url).trim_end_matches('/'); + for marker in ["youtu.be/", "/shorts/", "/live/", "/embed/", "/v/"] { + if let Some(rest) = path.split(marker).nth(1) { + let id = rest.split('/').next().unwrap_or(rest); + if valid(id) { + return Some(id.to_string()); + } + } + } + None +} + +/// True for any URL on YouTube, which is all the tray needs to decide whether +/// the browser is showing something it can act on. +pub fn is_youtube_url(url: &str) -> bool { + let u = url.to_ascii_lowercase(); + u.contains("youtube.com/") || u.contains("youtu.be/") +} + +/// Undoes the escaping YouTube's embedded JSON uses, so a title reads as it +/// looks on the page rather than as `Bob & Alice`. +pub fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('u') => { + let hex: String = chars.by_ref().take(4).collect(); + match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + Some(ch) => out.push(ch), + None => out.push_str(&hex), + } + } + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('/') => out.push('/'), + Some(other) => out.push(other), + None => break, + } + } + out +} + +/// The first value of `"":""` in a page's embedded JSON. +fn json_string(html: &str, key: &str) -> Option { + let needle = format!("\"{key}\":\""); + let start = html.find(&needle)? + needle.len(); + let rest = &html[start..]; + // Stop at the first quote that is not escaped. + let mut end = 0; + let bytes = rest.as_bytes(); + while end < bytes.len() { + if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') { + break; + } + end += 1; + } + if end == 0 || end >= rest.len() { + return None; + } + Some(unescape(&rest[..end])) +} + +/// The content of ``, +/// whichever order the attributes happen to be in. +fn meta(html: &str, key: &str) -> Option { + for tag in html.split("').unwrap_or(tag.len())]; + let names = [ + format!("property=\"{key}\""), + format!("name=\"{key}\""), + format!("itemprop=\"{key}\""), + ]; + if !names.iter().any(|n| tag.contains(n.as_str())) { + continue; + } + if let Some(rest) = tag.split("content=\"").nth(1) { + let value = &rest[..rest.find('"').unwrap_or(rest.len())]; + if !value.is_empty() { + return Some(decode_entities(value)); + } + } + } + None +} + +/// The handful of XML entities YouTube puts in meta attributes. +fn decode_entities(s: &str) -> String { + s.replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") +} + +/// The channel a page belongs to, whether that page is the channel itself or +/// one of its videos. +pub fn parse_channel(html: &str) -> Option<(String, String)> { + let id = json_string(html, "channelId") + .or_else(|| json_string(html, "externalId")) + .or_else(|| meta(html, "channelId")) + .filter(|id| id.starts_with("UC") && id.len() == 24)?; + + // On a video page og:title is the video, so the channel's own name has to + // come from the player data. + let title = json_string(html, "ownerChannelName") + .or_else(|| json_string(html, "author")) + .or_else(|| meta(html, "og:title")) + .unwrap_or_else(|| "Unknown channel".to_string()); + + Some((id, title.trim().to_string())) +} + +/// What a video page says about itself, beyond its channel. +#[derive(Debug, Clone, PartialEq)] +pub struct VideoMeta { + pub title: String, + /// ISO date, `YYYY-MM-DD`, as YouTube writes it. + pub published: Option, +} + +pub fn parse_video(html: &str) -> VideoMeta { + VideoMeta { + title: meta(html, "og:title") + .or_else(|| json_string(html, "title")) + .unwrap_or_else(|| "Untitled".to_string()), + published: meta(html, "datePublished") + .or_else(|| json_string(html, "publishDate")) + .map(|d| d.chars().take(10).collect()), + } +} + +/// `YYYY-MM-DD` as unix seconds, so a hand-added video sorts into the feed +/// alongside everything that came from an Atom feed. +pub fn iso_date_to_unix(date: &str) -> Option { + let mut parts = date.split('-'); + let y: i32 = parts.next()?.parse().ok()?; + let m: u32 = parts.next()?.parse().ok()?; + let d: u32 = parts.next()?.parse().ok()?; + chrono::NaiveDate::from_ymd_opt(y, m, d)? + .and_hms_opt(12, 0, 0) + .map(|dt| dt.and_utc().timestamp()) +} + +/// The canonical page for a channel id. +pub fn channel_url(id: &str) -> String { + format!("https://www.youtube.com/channel/{id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn video_ids_come_out_of_every_url_shape() { + assert_eq!( + video_id_from_url("https://www.youtube.com/watch?v=Bb48k48myRI").as_deref(), + Some("Bb48k48myRI") + ); + // Real browser URLs carry a pile of other parameters. + assert_eq!( + video_id_from_url("https://www.youtube.com/watch?app=desktop&v=Bb48k48myRI&t=42s") + .as_deref(), + Some("Bb48k48myRI") + ); + assert_eq!( + video_id_from_url("https://youtu.be/Bb48k48myRI?si=xyz").as_deref(), + Some("Bb48k48myRI") + ); + assert_eq!( + video_id_from_url("https://www.youtube.com/shorts/Bb48k48myRI").as_deref(), + Some("Bb48k48myRI") + ); + // A channel page is not a video. + assert_eq!(video_id_from_url("https://www.youtube.com/@mkbhd"), None); + assert_eq!(video_id_from_url("https://example.com/watch?v=short"), None); + } + + #[test] + fn youtube_urls_are_recognised() { + assert!(is_youtube_url("https://www.youtube.com/watch?v=abc")); + assert!(is_youtube_url("https://youtu.be/abc")); + assert!(!is_youtube_url("https://vimeo.com/12345")); + } + + #[test] + fn channel_comes_from_a_video_page_with_the_channels_own_name() { + let html = r#" + {"channelId":"UCBJycsmduvYEL83R_U4JriQ","ownerChannelName":"Marques Brownlee"}"#; + let (id, title) = parse_channel(html).unwrap(); + assert_eq!(id, "UCBJycsmduvYEL83R_U4JriQ"); + // Not the video's title, which og:title would have given. + assert_eq!(title, "Marques Brownlee"); + } + + #[test] + fn channel_comes_from_a_channel_page_by_its_og_title() { + let html = r#" + "#; + let (id, title) = parse_channel(html).unwrap(); + assert_eq!(id, "UCBJycsmduvYEL83R_U4JriQ"); + assert_eq!(title, "Lukis3D Studio"); + } + + #[test] + fn a_page_without_a_channel_id_resolves_to_nothing() { + assert_eq!(parse_channel("nothing here"), None); + // A plausible-looking id that is not one must not be accepted. + assert_eq!(parse_channel(r#"{"channelId":"UCshort"}"#), None); + } + + #[test] + fn escaped_titles_are_read_back_as_written() { + let html = r#"{"channelId":"UCBJycsmduvYEL83R_U4JriQ","ownerChannelName":"Bob & Alice \"Live\""}"#; + assert_eq!(parse_channel(html).unwrap().1, "Bob & Alice \"Live\""); + } + + #[test] + fn video_pages_give_a_title_and_a_date() { + let html = r#" + "#; + let m = parse_video(html); + assert_eq!(m.title, "Six Months in Thailand"); + assert_eq!(m.published.as_deref(), Some("2026-08-29")); + } + + #[test] + fn meta_entities_are_decoded() { + let html = r#""#; + assert_eq!(parse_video(html).title, "Tools & Toys"); + } + + #[test] + fn iso_dates_become_timestamps_that_sort() { + let older = iso_date_to_unix("2026-08-28").unwrap(); + let newer = iso_date_to_unix("2026-08-29").unwrap(); + assert!(newer > older); + assert_eq!(iso_date_to_unix("nonsense"), None); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..0ef4a82 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,230 @@ +//! The menu bar item. +//! +//! Its point is to act on what a browser is showing without leaving it: while a +//! video plays in Safari or Chrome, one click saves that video or subscribes to +//! its channel, and playback carries on undisturbed. +//! +//! Reading the browser's address needs Apple events, which macOS gates behind a +//! permission the user grants once. Without it the menu still opens and says +//! plainly what is missing. + +use crate::commands::{self, AppState}; +use crate::resolve; +use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; +use tauri::tray::TrayIconBuilder; +use tauri::{AppHandle, Emitter, Manager}; + +pub const SAVE_VIDEO: &str = "tray.save_video"; +pub const ADD_CHANNEL: &str = "tray.add_channel"; +pub const SHOW: &str = "tray.show"; + +/// Browsers worth asking, in the order they are asked. All but Safari answer +/// the same Chromium-flavoured AppleScript. +const CHROMIUM: [&str; 6] = [ + "Arc", + "Google Chrome", + "Brave Browser", + "Microsoft Edge", + "Vivaldi", + "Chromium", +]; + +/// The YouTube address showing in whichever browser has one. +/// +/// Only browsers that are already running are asked, so nothing is launched to +/// answer the question, and every lookup is wrapped in `try` — a browser with +/// no window open must not turn into an error. +fn browser_url_script() -> String { + let mut s = String::from( + r#"set found to "" +set apps to {} +try + tell application "System Events" to set apps to name of every process +end try +"#, + ); + for b in CHROMIUM { + s.push_str(&format!( + r#"if found is "" and apps contains "{b}" then + try + tell application "{b}" to set u to URL of active tab of front window + if u contains "youtube.com" or u contains "youtu.be" then set found to u + end try +end if +"# + )); + } + s.push_str( + r#"if found is "" and apps contains "Safari" then + try + tell application "Safari" to set u to URL of front document + if u contains "youtube.com" or u contains "youtu.be" then set found to u + end try +end if +return found +"#, + ); + s +} + +async fn browser_youtube_url() -> Result { + let out = tokio::process::Command::new("/usr/bin/osascript") + .arg("-e") + .arg(browser_url_script()) + .output() + .await + .map_err(|e| format!("Could not ask the browser: {e}"))?; + + let stderr = String::from_utf8_lossy(&out.stderr); + // -1743 is macOS refusing the Apple event because the permission has not + // been granted. Saying so beats "nothing found". + if stderr.contains("-1743") || stderr.contains("Not authorized") { + return Err( + "FlightTube needs permission to read your browser's address. \ + Allow it under System Settings › Privacy & Security › Automation." + .into(), + ); + } + let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if url.is_empty() { + return Err("No YouTube page open in a browser.".into()); + } + Ok(url) +} + +/// A notification, so the answer arrives without the window coming forward. +async fn notify(text: &str) { + let script = format!( + "display notification {} with title \"FlightTube\"", + applescript_string(text) + ); + let _ = tokio::process::Command::new("/usr/bin/osascript") + .arg("-e") + .arg(script) + .output() + .await; +} + +/// Quotes a string for AppleScript. Backslashes first, or the escaping of the +/// quotes gets undone. +fn applescript_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +async fn handle_save_video(app: AppHandle) { + let url = match browser_youtube_url().await { + Ok(u) => u, + Err(e) => return notify(&e).await, + }; + if resolve::video_id_from_url(&url).is_none() { + return notify("That page is not a video.").await; + } + + let state = app.state::(); + let (video_id, title) = match commands::save_video(&state, &url).await { + Ok(v) => v, + Err(e) => return notify(&e).await, + }; + let (quality, sub_lang) = state.download_defaults.lock().await.clone(); + let _ = app.emit("feed:changed", ()); + notify(&format!("Downloading {title}")).await; + + // The download outlives this handler; its progress shows in the window. + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + let state = handle.state::(); + match commands::download_video(video_id, quality, sub_lang, handle.clone(), state).await { + Ok(()) => notify(&format!("Saved {title}")).await, + Err(e) => notify(&format!("{title} failed: {e}")).await, + } + }); +} + +async fn handle_add_channel(app: AppHandle) { + let url = match browser_youtube_url().await { + Ok(u) => u, + Err(e) => return notify(&e).await, + }; + let state = app.state::(); + match commands::add_channel(url, app.clone(), state).await { + Ok(title) => { + let _ = app.emit("feed:changed", ()); + notify(&format!("Subscribed to {title}")).await; + } + Err(e) => notify(&e).await, + } +} + +fn show_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } +} + +pub fn on_menu_event(app: &AppHandle, event: MenuEvent) { + let app = app.clone(); + match event.id().as_ref() { + SAVE_VIDEO => { + tauri::async_runtime::spawn(handle_save_video(app)); + } + ADD_CHANNEL => { + tauri::async_runtime::spawn(handle_add_channel(app)); + } + SHOW => show_window(&app), + _ => {} + } +} + +pub fn build(app: &AppHandle) -> tauri::Result<()> { + let menu = Menu::with_items( + app, + &[ + &MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?, + &MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?, + &PredefinedMenuItem::separator(app)?, + &MenuItem::with_id(app, SHOW, "Open FlightTube", true, None::<&str>)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, Some("Quit FlightTube"))?, + ], + )?; + + let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; + + TrayIconBuilder::with_id("flighttube") + .icon(icon) + // A template image takes the menu bar's own colour, light or dark. + .icon_as_template(true) + .tooltip("FlightTube") + .menu(&menu) + // The menu is the whole point; a left click should open it too. + .show_menu_on_left_click(true) + .build(app)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_script_asks_every_browser_and_launches_none() { + let s = browser_url_script(); + for b in CHROMIUM { + assert!(s.contains(&format!("apps contains \"{b}\"")), "missing {b}"); + } + assert!(s.contains("Safari")); + // Guarded by the running-process list, so asking cannot start a browser. + assert_eq!(s.matches("apps contains").count(), CHROMIUM.len() + 1); + // Every lookup is inside a try, so a browser with no windows is not an + // error: one per browser, one for Safari, one for the process list. + assert_eq!(s.matches("end try").count(), CHROMIUM.len() + 2); + } + + #[test] + fn notification_text_survives_quotes_and_backslashes() { + assert_eq!(applescript_string(r#"a "b" c"#), r#""a \"b\" c""#); + assert_eq!(applescript_string(r"back\slash"), r#""back\\slash""#); + } +} diff --git a/src/App.tsx b/src/App.tsx index 2ca2569..9031569 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; import { - cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo, - interruptedDownloads, + cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel, + deleteDownload, downloadVideo, interruptedDownloads, previewDeleteChannel, + setDownloadDefaults, type RemovalPreview, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, } from "./api"; +import AddChannel from "./components/AddChannel"; import Player from "./components/Player"; import Settings from "./components/Settings"; import Sidebar from "./components/Sidebar"; @@ -56,6 +59,10 @@ export default function App() { } }); const [showSettings, setShowSettings] = useState(false); + // Which subscription is being removed, with what it would take with it. + const [removing, setRemoving] = useState<(RemovalPreview & { id: string }) | null>(null); + const [addingChannel, setAddingChannel] = useState(false); + // Index into the current feed, so the player can step through it. const [playingIndex, setPlayingIndex] = useState(null); const [browser, setBrowser] = useState(() => { @@ -278,6 +285,41 @@ export default function App() { return () => clearInterval(id); }, [online, refreshing, playingIndex, doRefresh]); + // The menu bar downloads without the front end being involved, so the + // backend needs the settings that live in this window. + useEffect(() => { + setDownloadDefaults(quality, embedLang).catch(() => { + /* falls back to what it was built with */ + }); + }, [quality, embedLang]); + + // Anything saved from the menu bar arrives behind the app's back. + useEffect(() => { + const un = listen("feed:changed", () => reload()); + return () => { + void un.then((f) => f()); + }; + }, [reload]); + + const askRemoveChannel = useCallback((id: string) => { + previewDeleteChannel(id) + .then((p) => setRemoving({ ...p, id })) + .catch((e) => setFailure(String(e))); + }, []); + + const removeChannel = useCallback(() => { + if (!removing) return; + const { id, title } = removing; + setRemoving(null); + deleteChannel(id) + .then(() => { + if (channelId === id) setChannelId(null); + reload(); + say(`Removed ${title}`); + }) + .catch((e) => setFailure(String(e))); + }, [removing, channelId, reload, say]); + // Anything in flight, whether or not it is currently listed — a download // started on one channel keeps running while you look at another. const activeDownloads = useMemo(() => { @@ -461,6 +503,8 @@ export default function App() { activeChannel={channelId} onSelect={(id) => { setChannelId(id); setSidebarPeek(false); }} onOpenSettings={() => setShowSettings(true)} + onAddChannel={() => setAddingChannel(true)} + onDeleteChannel={askRemoveChannel} totalVideos={totals.videos} totalDownloaded={totals.downloaded} onHide={() => { setSidebarHidden(true); setSidebarPeek(false); }} @@ -475,6 +519,8 @@ export default function App() { activeChannel={channelId} onSelect={setChannelId} onOpenSettings={() => setShowSettings(true)} + onAddChannel={() => setAddingChannel(true)} + onDeleteChannel={askRemoveChannel} totalVideos={totals.videos} totalDownloaded={totals.downloaded} onHide={() => setSidebarHidden(true)} @@ -663,6 +709,39 @@ export default function App() { )} + {removing && ( + setRemoving(null)} + onConfirm={removeChannel} + confirmLabel="Remove" + destructive + > +

+ It leaves FlightTube along with its {removing.videos} video + {removing.videos === 1 ? "" : "s"} + {removing.downloaded > 0 && ( + <> + , {removing.downloaded} of them downloaded — those files are deleted + from disk + + )} + . Your YouTube subscription is not touched; only this app forgets the channel. +

+
+ )} + + {addingChannel && ( + setAddingChannel(false)} + onAdded={(title) => { + setAddingChannel(false); + reload(); + say(`Added ${title}`); + }} + /> + )} + {failure && ( setFailure(null)}>

{failure}

diff --git a/src/api.ts b/src/api.ts index f87acb8..b889383 100644 --- a/src/api.ts +++ b/src/api.ts @@ -58,6 +58,26 @@ export const checkYtDlpUpdate = () => invoke("check_yt_dlp_update" export const updateYtDlp = () => invoke("update_yt_dlp"); /** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */ +/** Adds one channel from any YouTube link. Returns its title. */ +export const addChannel = (url: string) => invoke("add_channel", { url }); + +export interface RemovalPreview { + title: string; + videos: number; + downloaded: number; +} + +export const previewDeleteChannel = (channelId: string) => + invoke("preview_delete_channel", { channelId }); + +/** Unsubscribes in the app only — nothing changes on YouTube. */ +export const deleteChannel = (channelId: string) => + invoke("delete_channel", { channelId }); + +/** Keeps the menu bar's downloads matching the window's settings. */ +export const setDownloadDefaults = (quality: string, subLang: string) => + invoke("set_download_defaults", { quality, subLang }); + export const listSubtitles = (videoId: string) => invoke>("list_subtitles", { videoId }); diff --git a/src/components/AddChannel.tsx b/src/components/AddChannel.tsx new file mode 100644 index 0000000..b93643f --- /dev/null +++ b/src/components/AddChannel.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from "react"; + +import { addChannel } from "../api"; +import { BTN, BTN_PRIMARY, Dialog, HELP, INPUT, Spinner } from "./ui"; + +interface Props { + onClose: () => void; + onAdded: (title: string) => void; +} + +/** + * Adds one channel from a link, for the ones a Takeout export does not have — + * something found after the last import, or a channel followed nowhere but + * here. Any YouTube link belonging to the channel works, including a video of + * theirs, since the channel is what gets read off the page. + */ +export default function AddChannel({ onClose, onAdded }: Props) { + const [url, setUrl] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const field = useRef(null); + + useEffect(() => field.current?.focus(), []); + + const submit = () => { + if (busy || !url.trim()) return; + setBusy(true); + setError(null); + addChannel(url.trim()) + .then(onAdded) + .catch((e) => setError(String(e))) + .finally(() => setBusy(false)); + }; + + const footer = ( +
+ + +
+ ); + + return ( + +

+ Paste any link belonging to the channel — its own page, its @handle, or + one of its videos. FlightTube reads the channel from the page and starts + following it here. Nothing changes on YouTube. +

+ + setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + }} + placeholder="https://www.youtube.com/@channel" + spellCheck={false} + className={`${INPUT} mt-3 w-full`} + /> + + {error && ( +

{error}

+ )} + +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 58cba92..1db0f40 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -8,6 +8,8 @@ interface Props { activeChannel: string | null; onSelect: (id: string | null) => void; onOpenSettings: () => void; + onAddChannel: () => void; + onDeleteChannel: (id: string) => void; totalVideos: number; totalDownloaded: number; onHide: () => void; @@ -18,7 +20,8 @@ interface Props { } export default function Sidebar({ - channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded, + channels, activeChannel, onSelect, onOpenSettings, onAddChannel, onDeleteChannel, + totalVideos, totalDownloaded, onHide, floating, titleBarInset, }: Props) { const [onlyFailing, setOnlyFailing] = useState(false); @@ -64,6 +67,17 @@ export default function Sidebar({ FlightTube
+ + {/* Sits over the count, which is the least useful thing on the + row at the moment you are reaching for this. */} + ))} diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 07e3fc6..d63ca10 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -186,6 +186,7 @@ export function Dialog({ onConfirm, destructive, wide, + footer, }: { title: string; children: ReactNode; @@ -194,6 +195,8 @@ export function Dialog({ onConfirm?: () => void; destructive?: boolean; wide?: boolean; + /** Replaces Cancel/Confirm, for a dialog whose buttons have their own state. */ + footer?: ReactNode; }) { return (
{children}
-
- - {onConfirm && ( - - )} -
+ {onConfirm && ( + + )} +
+ )} );