feat: manage subscriptions in the app, and act from the menu bar
Takeout is still the way a subscription list arrives. These are the things it cannot do. Hovering a channel in the sidebar reveals a delete, sitting where the count is — the least useful thing on that row at the moment you reach for it. It asks first, and says what goes: the channel, its videos, and how many downloaded files will be deleted. It also says plainly that YouTube is not touched; only this app forgets the channel. A + beside the cog adds one channel from any link — its page, its @handle, or one of its videos. The channel id and name are read off the page, which is the same pair the CSV carries, and the feed is fetched straight away so it is not sitting there empty. A menu bar item acts on whatever a browser is showing without leaving it: save that video, or subscribe to its channel, while it plays on. The address comes from whichever running browser has a YouTube page — asked with AppleScript, guarded so no browser is launched to answer, and reported honestly when macOS has not granted the permission yet. Answers come back as notifications rather than by raising the window. Saving a video is not subscribing to its channel, so a channel can now exist as a parent row without being a subscription: out of the sidebar, skipped by refreshes, and left alone when a CSV import replaces the subscription list. Also removes a stale duplicate of the file header that had been sitting inside run()'s setup block. Items are legal inside a block, so it compiled and nobody noticed.
This commit is contained in:
Generated
+36
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The menu bar item asks the frontmost browser what page it is showing, so
|
||||
a video can be saved without leaving it. macOS gates that behind this. -->
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>FlightTube reads the address of the page your browser is showing, so the menu bar can save that video or add its channel.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 304 B |
@@ -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<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>>>,
|
||||
}
|
||||
@@ -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<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) {
|
||||
@@ -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<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.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<AppState, String> {
|
||||
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)),
|
||||
})
|
||||
}
|
||||
|
||||
+131
-6
@@ -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>), 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::<Result<Vec<_>, _>>()
|
||||
.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<bool, String> {
|
||||
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<usize, String> {
|
||||
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<Vec<String>, 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<usize, String> {
|
||||
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();
|
||||
|
||||
+11
-45
@@ -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<Menu<tauri::Wry>> {
|
||||
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::<commands::AppState>();
|
||||
let _ = commands::check_prereqs(state).await;
|
||||
});
|
||||
@@ -173,6 +135,10 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
||||
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");
|
||||
|
||||
@@ -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<String> {
|
||||
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 `"<key>":"<value>"` in a page's embedded JSON.
|
||||
fn json_string(html: &str, key: &str) -> Option<String> {
|
||||
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 `<meta ... (property|name|itemprop)="<key>" ... content="...">`,
|
||||
/// whichever order the attributes happen to be in.
|
||||
fn meta(html: &str, key: &str) -> Option<String> {
|
||||
for tag in html.split("<meta").skip(1) {
|
||||
let tag = &tag[..tag.find('>').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<String>,
|
||||
}
|
||||
|
||||
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<i64> {
|
||||
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#"<meta property="og:title" content="A video title">
|
||||
{"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#"<meta property="og:title" content="Lukis3D Studio">
|
||||
<script>var x = {"externalId":"UCBJycsmduvYEL83R_U4JriQ"};</script>"#;
|
||||
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("<html>nothing here</html>"), 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#"<meta property="og:title" content="Six Months in Thailand">
|
||||
<meta itemprop="datePublished" content="2026-08-29T10:00:00-07:00">"#;
|
||||
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#"<meta property="og:title" content="Tools & Toys">"#;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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::<AppState>();
|
||||
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::<AppState>();
|
||||
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::<AppState>();
|
||||
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""#);
|
||||
}
|
||||
}
|
||||
+81
-2
@@ -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<number | null>(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() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{removing && (
|
||||
<Dialog
|
||||
title={`Remove ${removing.title}?`}
|
||||
onCancel={() => setRemoving(null)}
|
||||
onConfirm={removeChannel}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
>
|
||||
<p>
|
||||
It leaves FlightTube along with its <b>{removing.videos}</b> video
|
||||
{removing.videos === 1 ? "" : "s"}
|
||||
{removing.downloaded > 0 && (
|
||||
<>
|
||||
, <b>{removing.downloaded}</b> of them downloaded — those files are deleted
|
||||
from disk
|
||||
</>
|
||||
)}
|
||||
. Your YouTube subscription is not touched; only this app forgets the channel.
|
||||
</p>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{addingChannel && (
|
||||
<AddChannel
|
||||
onClose={() => setAddingChannel(false)}
|
||||
onAdded={(title) => {
|
||||
setAddingChannel(false);
|
||||
reload();
|
||||
say(`Added ${title}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{failure && (
|
||||
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
|
||||
<p className="break-words">{failure}</p>
|
||||
|
||||
+20
@@ -58,6 +58,26 @@ export const checkYtDlpUpdate = () => invoke<UpdateStatus>("check_yt_dlp_update"
|
||||
export const updateYtDlp = () => invoke<string>("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<string>("add_channel", { url });
|
||||
|
||||
export interface RemovalPreview {
|
||||
title: string;
|
||||
videos: number;
|
||||
downloaded: number;
|
||||
}
|
||||
|
||||
export const previewDeleteChannel = (channelId: string) =>
|
||||
invoke<RemovalPreview>("preview_delete_channel", { channelId });
|
||||
|
||||
/** Unsubscribes in the app only — nothing changes on YouTube. */
|
||||
export const deleteChannel = (channelId: string) =>
|
||||
invoke<void>("delete_channel", { channelId });
|
||||
|
||||
/** Keeps the menu bar's downloads matching the window's settings. */
|
||||
export const setDownloadDefaults = (quality: string, subLang: string) =>
|
||||
invoke<void>("set_download_defaults", { quality, subLang });
|
||||
|
||||
export const listSubtitles = (videoId: string) =>
|
||||
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const field = useRef<HTMLInputElement>(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 = (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className={`${BTN} cursor-pointer`}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !url.trim()}
|
||||
className={`${BTN_PRIMARY} cursor-pointer disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
{busy ? <Spinner /> : "Add channel"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog title="Add a channel" onCancel={onClose} footer={footer}>
|
||||
<p className={HELP}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={field}
|
||||
value={url}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="mt-2 text-[12px] leading-snug text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={onAddChannel}
|
||||
title="Add a channel by link"
|
||||
aria-label="Add a channel by link"
|
||||
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
@@ -127,7 +141,7 @@ export default function Sidebar({
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
{shown.map((c) => (
|
||||
<li key={c.id}>
|
||||
<li key={c.id} className="group/row relative">
|
||||
<button
|
||||
onClick={() => onSelect(c.id)}
|
||||
title={c.title}
|
||||
@@ -142,11 +156,31 @@ export default function Sidebar({
|
||||
)}
|
||||
<span className="truncate">{c.title}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
||||
<span
|
||||
className="shrink-0 font-mono text-[11px] tabular-nums opacity-70
|
||||
group-hover/row:invisible"
|
||||
>
|
||||
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
||||
{c.video_count}
|
||||
</span>
|
||||
</button>
|
||||
{/* Sits over the count, which is the least useful thing on the
|
||||
row at the moment you are reaching for this. */}
|
||||
<button
|
||||
onClick={() => onDeleteChannel(c.id)}
|
||||
title={`Remove ${c.title} from FlightTube`}
|
||||
aria-label={`Remove ${c.title}`}
|
||||
className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 cursor-pointer
|
||||
rounded p-1 text-slate-400 hover:text-red-500
|
||||
group-hover/row:block"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
|
||||
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+17
-12
@@ -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 (
|
||||
<div
|
||||
@@ -212,19 +215,21 @@ export function Dialog({
|
||||
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onCancel} className={`${BTN} cursor-pointer`}>
|
||||
{onConfirm ? "Cancel" : "Close"}
|
||||
</button>
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
|
||||
>
|
||||
{confirmLabel ?? "Continue"}
|
||||
{footer ?? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onCancel} className={`${BTN} cursor-pointer`}>
|
||||
{onConfirm ? "Cancel" : "Close"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
|
||||
>
|
||||
{confirmLabel ?? "Continue"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user