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