diff --git a/Cargo.lock b/Cargo.lock index 9fc008f..69e4455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3973,31 +3973,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.2" @@ -5365,7 +5340,6 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-stream", "toml", "tower-http", "tray-icon", diff --git a/Cargo.toml b/Cargo.toml index 80945a1..f2929a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ tray-icon = "0.13" notify-rust = { version = "4", default-features = false, features = ["z"] } axum = { version = "0.7", features = ["macros"] } tokio = { version = "1", features = ["full"] } -tokio-stream = { version = "0.1", features = ["sync"] } tower-http = { version = "0.5", features = ["cors"] } [profile.release] diff --git a/src/app.rs b/src/app.rs index 16d68d4..6989aae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -517,14 +517,18 @@ impl App { ui.separator(); + // Collect any right-click download action outside the loop + let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name) + for i in 0..self.library.len() { - let (name, total, has_playlists, size_bytes) = { + let (name, total, has_playlists, size_bytes, channel_url) = { let ch = &self.library[i]; ( ch.name.clone(), ch.total_videos(), !ch.playlists.is_empty(), Self::channel_total_size(ch), + ch.meta.as_ref().and_then(|m| m.channel_url.clone()), ) }; @@ -539,14 +543,34 @@ impl App { let label = format!("{} ({}{})", name, total, size_str); let ch_selected_no_pl = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i); - if ui + let resp = ui .selectable_label(ch_selected_no_pl, label) - .on_hover_text(self.library[i].path.display().to_string()) - .clicked() - { + .on_hover_text(self.library[i].path.display().to_string()); + if resp.clicked() { self.sidebar_view = SidebarView::Channel(i); self.selected_video = None; } + // Right-click context menu + let url_for_menu = channel_url.clone(); + let name_for_menu = name.clone(); + resp.context_menu(|ui| { + if let Some(ref url) = url_for_menu { + if ui.button("⬇ Check for new videos").clicked() { + pending_ch_download = Some((url.clone(), name_for_menu.clone())); + ui.close_menu(); + } + } else { + ui.add_enabled(false, egui::Button::new("⬇ Check for new videos")) + .on_hover_text("Download this channel first to store its URL"); + } + if ui.button("πŸ“ Open folder").clicked() { + let path = self.library[i].path.clone(); + if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() { + eprintln!("xdg-open: {e}"); + } + ui.close_menu(); + } + }); if is_ch_selected && has_playlists { let playlist_count = self.library[i].playlists.len(); @@ -564,6 +588,13 @@ impl App { } } } + + // Process deferred right-click download action + if let Some((url, ch_name)) = pending_ch_download { + let kind = detect_url_kind(&url); + self.downloader.start(url, &kind); + self.status = format!("Checking {} for new videos…", ch_name); + } }); }); } @@ -946,10 +977,10 @@ impl App { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Size ↓"); - ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Size ↑"); - ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Dur ↓"); - ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Dur ↑"); + ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Largest"); + ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Smallest"); + ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Longest"); + ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Shortest"); ui.selectable_value(&mut self.sort_mode, SortMode::Title, "Title"); ui.label(egui::RichText::new("Sort:").weak()); }); diff --git a/src/web.rs b/src/web.rs index 04bcfd9..50e9e9f 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,8 +1,4 @@ //! Web interface β€” run with `--web [PORT]` instead of the GUI. -//! -//! Serves a browser-based UI on http://localhost:PORT that mirrors the -//! desktop app's core features: browse library, start downloads, track -//! progress, toggle watched. use std::collections::HashSet; use std::path::PathBuf; @@ -11,21 +7,18 @@ use std::sync::{Arc, Mutex}; use axum::{ extract::{Path, State}, http::{header, StatusCode}, - response::{IntoResponse, Sse}, + response::IntoResponse, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; -use tokio::sync::broadcast; -use tokio_stream::wrappers::BroadcastStream; -use tokio_stream::StreamExt as _; use crate::config::Config; use crate::database::Database; use crate::downloader::{detect_url_kind, Downloader, JobState}; use crate::library; -// ── Shared state ───────────────────────────────────────────────────────────── +// ── Shared state ────────────────────────────────────────────────────────────── #[derive(Clone, Serialize)] pub struct JobSnapshot { @@ -42,13 +35,10 @@ pub struct WebState { pub watched: Mutex>, pub db_path: PathBuf, pub channels_root: PathBuf, - pub browser: String, - /// Broadcast channel for SSE progress events - pub progress_tx: broadcast::Sender, } impl WebState { - pub fn job_snapshots(dl: &Downloader) -> Vec { + fn job_snapshots(dl: &Downloader) -> Vec { dl.jobs .iter() .map(|j| JobSnapshot { @@ -112,7 +102,7 @@ struct ProgressResponse { jobs: Vec, } -// ── Route handlers ──────────────────────────────────────────────────────────── +// ── Handlers ────────────────────────────────────────────────────────────────── async fn get_index() -> impl IntoResponse { ( @@ -125,49 +115,45 @@ async fn get_library(State(state): State>) -> impl IntoResponse { let lib = state.library.lock().unwrap(); let watched = state.watched.lock().unwrap(); - let channels = lib - .iter() - .map(|ch| { - let size_bytes: u64 = ch - .videos - .iter() - .chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) - .filter_map(|v| v.file_size) - .sum(); + let to_info = |v: &library::Video, watched: &HashSet| VideoInfo { + id: v.id.clone(), + title: v.title.clone(), + duration_secs: v.duration_secs, + file_size: v.file_size, + has_video: v.video_path.is_some(), + has_live_chat: v.has_live_chat, + watched: watched.contains(&v.id), + }; - let to_info = |v: &library::Video| VideoInfo { - id: v.id.clone(), - title: v.title.clone(), - duration_secs: v.duration_secs, - file_size: v.file_size, - has_video: v.video_path.is_some(), - has_live_chat: v.has_live_chat, - watched: watched.contains(&v.id), - }; - - ChannelInfo { - name: ch.name.clone(), - total_videos: ch.total_videos(), - size_bytes, - subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count), - uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()), - channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()), - playlists: ch - .playlists - .iter() - .map(|p| PlaylistInfo { - name: p.name.clone(), - videos: p.videos.iter().map(to_info).collect(), - }) - .collect(), - videos: ch.videos.iter().map(to_info).collect(), - } - }) - .collect(); + let channels = lib.iter().map(|ch| { + let size_bytes: u64 = ch.videos.iter() + .chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) + .filter_map(|v| v.file_size) + .sum(); + ChannelInfo { + name: ch.name.clone(), + total_videos: ch.total_videos(), + size_bytes, + subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count), + uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()), + channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()), + playlists: ch.playlists.iter().map(|p| PlaylistInfo { + name: p.name.clone(), + videos: p.videos.iter().map(|v| to_info(v, &watched)).collect(), + }).collect(), + videos: ch.videos.iter().map(|v| to_info(v, &watched)).collect(), + } + }).collect(); Json(LibraryResponse { channels }) } +async fn get_progress(State(state): State>) -> impl IntoResponse { + let mut dl = state.downloader.lock().unwrap(); + dl.poll(); + Json(ProgressResponse { jobs: WebState::job_snapshots(&dl) }) +} + async fn post_download( State(state): State>, Json(body): Json, @@ -177,18 +163,10 @@ async fn post_download( return (StatusCode::BAD_REQUEST, "empty URL").into_response(); } let kind = detect_url_kind(&url); - { - let mut dl = state.downloader.lock().unwrap(); - dl.start(url, &kind); - } + state.downloader.lock().unwrap().start(url, &kind); (StatusCode::ACCEPTED, "ok").into_response() } -async fn get_progress(State(state): State>) -> impl IntoResponse { - let dl = state.downloader.lock().unwrap(); - Json(ProgressResponse { jobs: WebState::job_snapshots(&dl) }) -} - async fn post_watched( State(state): State>, Path(video_id): Path, @@ -202,53 +180,41 @@ async fn post_watched( if let Err(e) = db.set_watched(&video_id, now_watched) { return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); } - if now_watched { - watched.insert(video_id); - } else { - watched.remove(&video_id); - } + if now_watched { watched.insert(video_id); } else { watched.remove(&video_id); } (StatusCode::OK, if now_watched { "watched" } else { "unwatched" }).into_response() } -async fn get_events(State(state): State>) -> Sse>> { - let rx = state.progress_tx.subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|msg| { - msg.ok().map(|data| { - Ok(axum::response::sse::Event::default().data(data)) - }) - }); - Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()) -} - async fn post_rescan(State(state): State>) -> impl IntoResponse { - let root = state.channels_root.clone(); - let new_lib = library::scan_channels(&root); + let new_lib = library::scan_channels(&state.channels_root); + // Refresh watched from DB after rescan + if let Ok(db) = Database::open(&state.db_path) { + if let Ok(w) = db.get_watched() { + *state.watched.lock().unwrap() = w; + } + } *state.library.lock().unwrap() = new_lib; (StatusCode::OK, "rescanned") } -// ── Server entry point ──────────────────────────────────────────────────────── +// ── Entry point ─────────────────────────────────────────────────────────────── pub fn run(config: Config) -> ! { let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); - rt.block_on(async move { - serve(config).await; - }); + rt.block_on(serve(config)); unreachable!() } async fn serve(config: Config) { let channels_root = config.backup.directory.clone(); + let _ = std::fs::create_dir_all(&channels_root); let db_path = channels_root.join("yt-offline.db"); + let library = library::scan_channels(&channels_root); + let watched = Database::open(&db_path) + .and_then(|db| db.get_watched()) + .unwrap_or_default(); - let db = Database::open(&db_path).expect("web: open db"); - let watched = db.get_watched().unwrap_or_default(); - - let browser = config.player.browser.clone(); - let downloader = Downloader::new(channels_root.clone(), browser.clone()); - - let (progress_tx, _) = broadcast::channel::(64); + let downloader = Downloader::new(channels_root.clone(), config.player.browser.clone()); let state = Arc::new(WebState { library: Mutex::new(library), @@ -256,45 +222,26 @@ async fn serve(config: Config) { watched: Mutex::new(watched), db_path, channels_root, - browser, - progress_tx: progress_tx.clone(), - }); - - // Background task: poll downloader and broadcast SSE events - let poll_state = Arc::clone(&state); - tokio::spawn(async move { - loop { - { - let mut dl = poll_state.downloader.lock().unwrap(); - dl.poll(); - let snap = WebState::job_snapshots(&dl); - drop(dl); - if let Ok(json) = serde_json::to_string(&snap) { - let _ = poll_state.progress_tx.send(json); - } - } - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - } }); let app = Router::new() .route("/", get(get_index)) .route("/api/library", get(get_library)) - .route("/api/download", post(post_download)) .route("/api/progress", get(get_progress)) - .route("/api/events", get(get_events)) + .route("/api/download", post(post_download)) .route("/api/watched/:id", post(post_watched)) .route("/api/rescan", post(post_rescan)) .with_state(state); let port = config.web.port; - let addr = format!("0.0.0.0:{port}"); - let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind"); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + .await + .unwrap_or_else(|e| panic!("Cannot bind to port {port}: {e}")); println!("yt-offline web UI: http://localhost:{port}"); - axum::serve(listener, app).await.expect("serve"); + axum::serve(listener, app).await.expect("server error"); } -// ── Embedded HTML/JS UI ─────────────────────────────────────────────────────── +// ── Embedded UI ─────────────────────────────────────────────────────────────── const HTML_UI: &str = r#" @@ -304,198 +251,276 @@ const HTML_UI: &str = r#" yt-offline

yt-offline

- - - + + + +
-
+
+
+ + +
+
+
-
+
- -
+ +