feat: subtitles, delete-all, auto refresh, and menu cleanup
Subtitles: a language preference in Settings drives what is shown and what is downloaded. yt-dlp saves WebVTT sidecars next to each download, including YouTube's auto-generated track, and the player attaches them as <track> elements so they work offline. Sidecars are removed with their video, and by Delete all. WebVTT rather than muxed subtitle streams because WebKit reads a <track> reliably and largely ignores subtitle tracks inside an MP4. Downloads in progress now appear under the downloaded-only filter, so a download you just started does not vanish from the list you are watching it in. That view also gains a Delete all, behind a confirmation naming what goes. The feed refreshes on launch and whenever the player closes, so the Refresh button is only for staleness. Player: Delete moved to the footer and shortened, Open on YouTube is now an external-link icon. Removes the Edit and Help menus. Cut/Copy/Paste move to the app menu, without which their shortcuts would stop working in the search field. The webview context menu is suppressed outside text fields — its Reload and Back items act on a page the app does not present as one. Settings now warns that 4K AV1 plays back with artefacts: the files decode cleanly in ffmpeg, so it is the built-in decoder, not the download.
This commit is contained in:
@@ -690,6 +690,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) {
|
||||
pub async fn download_video(
|
||||
video_id: String,
|
||||
quality: String,
|
||||
sub_langs: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -723,7 +724,7 @@ pub async fn download_video(
|
||||
.join(downloader::OUTPUT_TEMPLATE)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let mut args = downloader::build_args(&video_id, &out_template, &quality);
|
||||
let mut args = downloader::build_args(&video_id, &out_template, &quality, &sub_langs);
|
||||
// Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no
|
||||
// reason to have. Merging video and audio would fail on a clean machine.
|
||||
args.push("--ffmpeg-location".into());
|
||||
@@ -922,6 +923,55 @@ async fn cleanup_partials(library: PathBuf, video_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebVTT files yt-dlp wrote beside a download, as (language, path) pairs.
|
||||
#[tauri::command]
|
||||
pub async fn list_subtitles(
|
||||
video_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
let library = state.library.lock().await.clone();
|
||||
let Ok(mut entries) = tokio::fs::read_dir(&library).await else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains(&video_id) || !name.ends_with(".vtt") {
|
||||
continue;
|
||||
}
|
||||
// yt-dlp names them "<base>.<lang>.vtt".
|
||||
let lang = name
|
||||
.trim_end_matches(".vtt")
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
out.push((lang, entry.path().to_string_lossy().to_string()));
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Removes every download and the files behind them, including subtitles.
|
||||
#[tauri::command]
|
||||
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
let paths = state.db.lock().await.all_download_paths()?;
|
||||
for p in &paths {
|
||||
let _ = tokio::fs::remove_file(p).await;
|
||||
}
|
||||
// Subtitle sidecars are not tracked in the database, so sweep them here.
|
||||
let library = state.library.lock().await.clone();
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&library).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.ends_with(".vtt") || name.contains(".part") || name.ends_with(".ytdl") {
|
||||
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.db.lock().await.clear_all_downloads()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_download(
|
||||
video_id: String,
|
||||
@@ -931,7 +981,17 @@ pub async fn delete_download(
|
||||
if let Some(p) = path {
|
||||
let _ = tokio::fs::remove_file(&p).await;
|
||||
}
|
||||
cleanup_partials(state.library.lock().await.clone(), &video_id).await;
|
||||
let library = state.library.lock().await.clone();
|
||||
cleanup_partials(library.clone(), &video_id).await;
|
||||
// The .vtt sidecars belong to the video, so they go with it.
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&library).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.contains(&video_id) && name.ends_with(".vtt") {
|
||||
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.db.lock().await.clear_download(&video_id)
|
||||
}
|
||||
|
||||
|
||||
+53
-1
@@ -369,7 +369,9 @@ impl Db {
|
||||
args.push(Box::new(pat));
|
||||
}
|
||||
if f.downloaded_only {
|
||||
sql.push_str(" AND d.state = 'done'");
|
||||
// Queued and running count: a download you started should not
|
||||
// vanish from the very list you are watching it in.
|
||||
sql.push_str(" AND d.state IN ('done','queued','running')");
|
||||
}
|
||||
if f.hide_shorts {
|
||||
sql.push_str(" AND v.is_short = 0");
|
||||
@@ -498,6 +500,26 @@ impl Db {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Paths of every completed download, for deleting them all at once.
|
||||
pub fn all_download_paths(&self) -> Result<Vec<String>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT path FROM downloads WHERE path IS NOT NULL")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn clear_all_downloads(&self) -> Result<usize, String> {
|
||||
let n = self
|
||||
.conn
|
||||
.execute("DELETE FROM downloads", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub fn clear_download(&self, video_id: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("DELETE FROM downloads WHERE video_id = ?1", params![video_id])
|
||||
@@ -737,6 +759,36 @@ mod tests {
|
||||
assert_eq!(feed[0].id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_in_progress_still_show_in_the_downloaded_filter() {
|
||||
let db = seeded();
|
||||
db.set_download_state("a", DownloadState::Running, None).unwrap();
|
||||
db.set_download_state("c", DownloadState::Queued, None).unwrap();
|
||||
db.set_download_state("b", DownloadState::Failed, Some("x")).unwrap();
|
||||
|
||||
let feed = db
|
||||
.list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
|
||||
.unwrap();
|
||||
let ids: Vec<&str> = feed.iter().map(|f| f.id.as_str()).collect();
|
||||
assert!(ids.contains(&"a"), "running should be listed");
|
||||
assert!(ids.contains(&"c"), "queued should be listed");
|
||||
assert!(!ids.contains(&"b"), "failed should not be");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_all_downloads_empties_the_filter() {
|
||||
let db = seeded();
|
||||
db.set_download_state("a", DownloadState::Done, None).unwrap();
|
||||
db.set_download_path("a", "/movies/a.mp4").unwrap();
|
||||
db.set_download_state("b", DownloadState::Done, None).unwrap();
|
||||
assert_eq!(db.all_download_paths().unwrap(), vec!["/movies/a.mp4".to_string()]);
|
||||
db.clear_all_downloads().unwrap();
|
||||
assert!(db
|
||||
.list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_shorts_filter_excludes_shorts() {
|
||||
let db = seeded();
|
||||
|
||||
@@ -79,7 +79,12 @@ pub fn parse_progress_line(line: &str) -> Option<Progress> {
|
||||
|
||||
/// Arguments for downloading one video. Kept separate from process spawning so
|
||||
/// the argument construction is assertable in tests.
|
||||
pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<String> {
|
||||
pub fn build_args(
|
||||
video_id: &str,
|
||||
out_template: &str,
|
||||
quality: &str,
|
||||
sub_langs: &str,
|
||||
) -> Vec<String> {
|
||||
vec![
|
||||
"-f".into(),
|
||||
format_selector(quality),
|
||||
@@ -94,6 +99,18 @@ pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<Stri
|
||||
"180".into(),
|
||||
"--progress-template".into(),
|
||||
PROGRESS_TEMPLATE.into(),
|
||||
// Subtitles come along for offline use, including YouTube's
|
||||
// auto-generated ones. They are written beside the video as WebVTT
|
||||
// rather than muxed in: WebKit reads a <track> reliably, whereas
|
||||
// subtitle streams inside an MP4 it largely ignores.
|
||||
"--write-subs".into(),
|
||||
"--write-auto-subs".into(),
|
||||
"--sub-format".into(),
|
||||
"vtt".into(),
|
||||
"--convert-subs".into(),
|
||||
"vtt".into(),
|
||||
"--sub-langs".into(),
|
||||
sub_langs.into(),
|
||||
"--print".into(),
|
||||
"after_move:FTPATH %(filepath)s".into(),
|
||||
"-o".into(),
|
||||
@@ -175,9 +192,20 @@ mod tests {
|
||||
assert_eq!(p.pct(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitles_are_requested_including_auto_generated() {
|
||||
let args = build_args("abc", "/tmp/o.%(ext)s", "best", "en.*,nl.*");
|
||||
assert!(args.contains(&"--write-subs".to_string()));
|
||||
// The auto-generated track is the only one many videos have.
|
||||
assert!(args.contains(&"--write-auto-subs".to_string()));
|
||||
assert!(args.contains(&"en.*,nl.*".to_string()));
|
||||
// WebVTT, because that is what a <track> element can load.
|
||||
assert!(args.contains(&"vtt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_quality_takes_the_highest_available() {
|
||||
let args = build_args("abc123", "/tmp/out.%(ext)s", "best");
|
||||
let args = build_args("abc123", "/tmp/out.%(ext)s", "best", "en.*");
|
||||
assert!(args.contains(&FORMAT_BEST.to_string()));
|
||||
assert!(args.contains(&"mp4".to_string()));
|
||||
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
|
||||
@@ -197,7 +225,7 @@ mod tests {
|
||||
let sel = format_selector("1080");
|
||||
assert!(sel.contains("height<=1080"));
|
||||
assert!(!sel.contains("height<=2160"));
|
||||
assert!(build_args("x", "o", "1080").contains(&sel));
|
||||
assert!(build_args("x", "o", "1080", "en.*").contains(&sel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+92
-1
@@ -8,8 +8,52 @@ pub mod playlist_server;
|
||||
pub mod takeout;
|
||||
pub mod thumbs;
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
/// Turns on WKWebView's element fullscreen.
|
||||
///
|
||||
/// It is off by default in a Tauri window, which is why the native player has
|
||||
@@ -35,6 +79,7 @@ fn enable_element_fullscreen(window: &tauri::WebviewWindow) {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.menu(build_menu)
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
@@ -50,7 +95,51 @@ pub fn run() {
|
||||
// the background rather than the first time Settings is opened.
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use tauri::Manager;
|
||||
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;
|
||||
});
|
||||
@@ -69,6 +158,8 @@ pub fn run() {
|
||||
commands::download_video,
|
||||
commands::cancel_download,
|
||||
commands::delete_download,
|
||||
commands::delete_all_downloads,
|
||||
commands::list_subtitles,
|
||||
commands::get_connectivity,
|
||||
commands::set_library_path,
|
||||
commands::open_external,
|
||||
|
||||
Reference in New Issue
Block a user