From 1ef3fe56c69a19ab9d7073ce4646a8d1f115ac9e Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 21 May 2026 02:58:09 -0700 Subject: [PATCH] Add web themes, network binding, UI auth, library maintenance; optimize + fix rescan Features: - Port all 7 desktop themes (Dracula, Trans, Emo variants, etc.) to the web UI - Configurable bind interface (localhost/Tailscale/LAN/all) with detected IPs, settable from both the web UI and desktop GUI - Optional password that gates the whole UI and all API access via session cookies (Argon2), with a login page, logout, and middleware; settable from both UIs - Library maintenance: scan for duplicate video IDs and missing assets (thumbnail/info.json/description), remove duplicates (scan-and-confirm, with out-of-library path guard), and re-fetch missing sidecars via yt-dlp. Surfaced in both the web UI and a desktop GUI window. Optimizations / cleanup: - Cache has_chapters at scan time (library.rs) instead of re-reading and parsing every info.json on each /api/library request - Use the cached channel size in get_library - Pool a single SQLite connection in WebState instead of opening one per request - Remove a 1.2 GB stray nested src/yt-offline duplicate tree Fixes: - Rescan no longer clears all thumbnail textures (caused thumbnails to unload); prune only removed entries and bump library_generation so the card grid refreshes correctly Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 47 ++++ Cargo.toml | 2 + SECURITY_AUDIT.md | 7 +- src/app.rs | 226 +++++++++++++++++- src/config.rs | 6 + src/downloader.rs | 110 ++++++--- src/library.rs | 22 +- src/main.rs | 1 + src/maintenance.rs | 234 +++++++++++++++++++ src/web.rs | 560 +++++++++++++++++++++++++++++++++++++++++---- 10 files changed, 1126 insertions(+), 89 deletions(-) create mode 100644 src/maintenance.rs diff --git a/Cargo.lock b/Cargo.lock index 00f7fd8..532b285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,18 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -483,6 +495,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.6.0" @@ -510,6 +528,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block" version = "0.1.6" @@ -860,6 +887,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2591,6 +2619,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3251,6 +3290,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -4798,10 +4843,12 @@ dependencies = [ name = "yt-offline" version = "0.1.0" dependencies = [ + "argon2", "axum", "eframe", "image", "notify-rust", + "rand", "rusqlite", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 651ebfd..e009a5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["io"] } tokio-stream = "0.1" tower-http = { version = "0.5", features = ["cors", "fs"] } +argon2 = "0.5" +rand = "0.8" [profile.release] opt-level = 2 diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 9698f3a..2ded2d8 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -170,9 +170,10 @@ paste 1.0.15 ## Recommendations (Priority Order) ### HIGH -1. **Verify web server binding** — Ensure `axum` binds to `127.0.0.1` only, not `0.0.0.0`. - - Check: `src/web.rs:618` where the listener is created. - - Mitigation: Add a config option for `bind_addr` or document localhost-only setup. +1. ✅ **Web server binding** — **FIXED** + - Added `web.bind` config option (default `127.0.0.1`). + - Set in `config.toml` to `127.0.0.1` for localhost-only access. + - Users can change to `0.0.0.0` if they understand the security implications. ### MEDIUM 2. **File permissions on `cookies.txt`** — Remind users to `chmod 600 cookies.txt`. diff --git a/src/app.rs b/src/app.rs index 38d140b..4a739cb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -97,6 +97,13 @@ pub struct App { // Web server web_server_running: bool, web_server_shutdown: Option>, + // Settings-window scratch state (not persisted directly) + settings_bind_mode: String, + settings_password_enabled: bool, + settings_password_input: String, + // Maintenance (library health) window + show_maintenance: bool, + health_report: Option, } impl App { @@ -131,6 +138,8 @@ impl App { let resume_positions = db.get_positions().unwrap_or_default(); let browser = config.player.browser.clone(); + let config_bind = config.web.bind.clone(); + let password_set = config.web.download_password.is_some(); let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::(); let (thumb_result_tx, thumb_result_rx) = @@ -181,6 +190,11 @@ impl App { library_generation: 0, web_server_running: false, web_server_shutdown: None, + settings_bind_mode: crate::web::bind_mode_of(&config_bind).to_string(), + settings_password_enabled: password_set, + settings_password_input: String::new(), + show_maintenance: false, + health_report: None, } } @@ -189,7 +203,20 @@ impl App { self.sidebar_view = SidebarView::All; self.selected_video = None; self.desc_cache.clear(); - self.textures.clear(); + // Keep already-decoded thumbnail textures across rescans — they're keyed + // by file path and don't change. Drop only entries whose thumbnail is no + // longer in the library so removed videos don't leak GPU textures. This + // avoids the full thumbnail reload flicker on every rescan. + let valid: HashSet = self + .library + .iter() + .flat_map(|c| c.videos.iter().chain(c.playlists.iter().flat_map(|p| p.videos.iter()))) + .filter_map(|v| v.thumb_path.clone()) + .collect(); + self.textures.retain(|p, _| valid.contains(p)); + self.thumb_pending.retain(|p| valid.contains(p)); + // Bump the generation so the card cache recomputes against the new library. + self.library_generation += 1; self.status = format!( "Rescanned: {} channels, {} videos", self.library.len(), @@ -531,10 +558,22 @@ impl App { if ui.selectable_label(self.show_downloads, dl_label).clicked() { self.show_downloads = !self.show_downloads; } + if ui.selectable_label(self.show_maintenance, "🩺 Maintenance").clicked() { + self.show_maintenance = !self.show_maintenance; + if self.show_maintenance { + self.health_report = + Some(crate::maintenance::scan(&self.channels_root, &self.library)); + } + } if ui.selectable_label(self.show_settings, "⚙ Settings").clicked() { self.show_settings = !self.show_settings; if self.show_settings { self.settings_dir = self.channels_root.display().to_string(); + self.settings_bind_mode = + crate::web::bind_mode_of(&self.config.web.bind).to_string(); + self.settings_password_enabled = + self.config.web.download_password.is_some(); + self.settings_password_input.clear(); } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -769,6 +808,128 @@ impl App { }); } + fn maintenance_window(&mut self, ctx: &egui::Context) { + if !self.show_maintenance { + return; + } + let mut open = self.show_maintenance; + let report = self.health_report.clone().unwrap_or_default(); + + // Actions are collected during rendering and applied after the closure + // to avoid borrowing `self` while the report is borrowed immutably. + let mut to_remove: Vec = Vec::new(); + let mut to_repair: Vec = Vec::new(); + let mut rescan_health = false; + + egui::Window::new("🩺 Library health") + .open(&mut open) + .collapsible(false) + .resizable(true) + .default_width(620.0) + .default_height(500.0) + .show(ctx, |ui| { + if ui.button("⟳ Rescan health").clicked() { + rescan_health = true; + } + ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.heading(format!("Duplicates ({})", report.duplicates.len())); + if report.duplicates.is_empty() { + ui.label(egui::RichText::new("No duplicate video IDs found.").weak()); + } + for g in &report.duplicates { + ui.group(|ui| { + ui.label( + egui::RichText::new(format!("{} [{}]", g.title, g.id)).strong(), + ); + for c in &g.copies { + let size = c + .file_size + .map(format_size) + .unwrap_or_else(|| "no video".to_string()); + let tag = if c.recommended_keep { "✓ keep" } else { "✗ remove" }; + ui.label(format!( + " {} · {} · {} files — {}", + c.location, size, c.files.len(), tag + )); + } + if ui.button("🗑 Remove non-recommended copies").clicked() { + for c in &g.copies { + if !c.recommended_keep { + to_remove.extend(c.files.iter().cloned()); + } + } + } + }); + } + + ui.add_space(10.0); + ui.heading(format!("Missing assets ({})", report.missing.len())); + if report.missing.is_empty() { + ui.label( + egui::RichText::new( + "Every video has its thumbnail, metadata, and description.", + ) + .weak(), + ); + } + if report.missing.len() > 1 + && ui + .button(format!("⬇ Fetch all missing ({})", report.missing.len())) + .clicked() + { + for m in &report.missing { + to_repair.push(m.id.clone()); + } + } + for m in &report.missing { + ui.horizontal(|ui| { + let need: Vec<&str> = [ + m.missing_thumbnail.then_some("thumbnail"), + m.missing_info.then_some("metadata"), + m.missing_description.then_some("description"), + ] + .into_iter() + .flatten() + .collect(); + ui.label(format!("{} — missing {}", m.title, need.join(", "))); + if ui.button("⬇ Fetch").clicked() { + to_repair.push(m.id.clone()); + } + }); + } + }); + }); + self.show_maintenance = open; + + // ── Apply collected actions ──────────────────────────────────────── + let mut changed = false; + if !to_remove.is_empty() { + let (removed, errors) = + crate::maintenance::remove_files(&self.channels_root, &to_remove); + self.status = if errors.is_empty() { + format!("Removed {removed} file(s)") + } else { + format!("Removed {removed} file(s), {} error(s)", errors.len()) + }; + changed = true; + } + if !to_repair.is_empty() { + for id in &to_repair { + if let Some((dir, stem)) = crate::maintenance::locate(&self.library, id) { + self.downloader.repair(id, &dir, &stem); + } + } + self.status = format!("Queued {} repair(s) — see Downloads", to_repair.len()); + self.show_downloads = true; + } + if changed || rescan_health { + self.rescan(); + self.health_report = + Some(crate::maintenance::scan(&self.channels_root, &self.library)); + } + } + fn settings_window(&mut self, ctx: &egui::Context) { if !self.show_settings { return; @@ -864,6 +1025,45 @@ impl App { ); ui.end_row(); + ui.label("Bind interface:"); + let binds = crate::web::get_available_binds(self.config.web.port); + let selected_label = binds + .iter() + .find(|b| b.id == self.settings_bind_mode) + .map(|b| b.label.clone()) + .unwrap_or_else(|| "Localhost only".to_string()); + egui::ComboBox::from_id_salt("bind_combo") + .selected_text(selected_label) + .show_ui(ui, |ui| { + for b in &binds { + if ui + .selectable_label(self.settings_bind_mode == b.id, &b.label) + .clicked() + { + self.settings_bind_mode = b.id.clone(); + } + } + }); + ui.end_row(); + + ui.label("Download password:"); + ui.vertical(|ui| { + ui.checkbox(&mut self.settings_password_enabled, "require for web downloads"); + if self.settings_password_enabled { + ui.add( + egui::TextEdit::singleline(&mut self.settings_password_input) + .password(true) + .desired_width(220.0) + .hint_text(if self.config.web.download_password.is_some() { + "leave blank to keep current" + } else { + "set a password" + }), + ); + } + }); + ui.end_row(); + ui.label("Web server:"); ui.horizontal(|ui| { if self.web_server_running { @@ -890,6 +1090,23 @@ impl App { let new_dir = PathBuf::from(&self.settings_dir); let dir_changed = new_dir != self.config.backup.directory; self.config.backup.directory = new_dir.clone(); + + // Resolve the chosen interface to a concrete bind address. + let new_bind = crate::web::resolve_bind_mode(&self.settings_bind_mode); + let bind_changed = new_bind != self.config.web.bind; + self.config.web.bind = new_bind; + + // Apply the download-password setting. + if !self.settings_password_enabled { + self.config.web.download_password = None; + } else if !self.settings_password_input.is_empty() { + match crate::web::hash_password(&self.settings_password_input) { + Some(hash) => self.config.web.download_password = Some(hash), + None => self.status = "Error hashing password".to_string(), + } + self.settings_password_input.clear(); + } + match self.config.save(&self.config_path) { Ok(_) => self.status = "Settings saved.".to_string(), Err(e) => self.status = format!("Error saving config: {e}"), @@ -900,6 +1117,12 @@ impl App { let _ = std::fs::create_dir_all(&self.channels_root); self.rescan(); } + // Re-bind a running server so the new interface takes effect now. + if bind_changed && self.web_server_running { + self.stop_web_server(); + self.start_web_server(); + self.status = "Settings saved. Web server re-bound.".to_string(); + } } ui.label( egui::RichText::new("Theme previews immediately; other changes apply on save.") @@ -1334,6 +1557,7 @@ impl eframe::App for App { self.downloads_panel(ctx); } self.settings_window(ctx); + self.maintenance_window(ctx); self.detail_panel(ctx); egui::CentralPanel::default().show(ctx, |ui| { self.video_list(ctx, ui); diff --git a/src/config.rs b/src/config.rs index 85c8b49..3936628 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,6 +42,8 @@ impl Default for PlayerSection { } /// `[ui]` table — egui desktop theme. +/// +/// Available themes: `dark`, `light`, `dracula`, `trans`, `emo-nocturnal`, `emo-coffin`, `emo-scene-queen`. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UiSection { #[serde(default = "default_theme")] @@ -86,6 +88,9 @@ pub struct WebSection { pub transcode: bool, /// Public URL to the source repository, shown in the web UI per AGPL §13. pub source_url: Option, + /// Optional plaintext password required for downloads via web UI. + /// If set, users must provide this password to queue downloads. + pub download_password: Option, } impl Default for WebSection { @@ -95,6 +100,7 @@ impl Default for WebSection { bind: default_web_bind(), transcode: false, source_url: None, + download_password: None, } } } diff --git a/src/downloader.rs b/src/downloader.rs index aea8ea8..d1597c4 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -133,7 +133,6 @@ impl Downloader { /// The output path template is derived from `kind` so that channels, /// playlists, and individual videos land in the right sub-directories. pub fn start(&mut self, url: String, kind: &UrlKind) { - let (tx, rx) = channel(); let archive_path = self.channels_root.join("archive.txt"); let (out_arg, label) = match kind { @@ -155,41 +154,82 @@ impl Downloader { ), }; - let url_for_thread = url.clone(); - thread::spawn(move || { - let mut cmd = Command::new("yt-dlp"); - cmd.arg("--newline") - .arg("--no-color") - .arg("--cookies") - .arg("cookies.txt") - .arg("--write-subs") - .arg("--write-auto-subs") - .arg("--write-thumbnail") - .arg("--write-description") - .arg("--write-info-json") - .arg("--remux-video") - .arg("mkv") - .arg("--embed-metadata") - .arg("--embed-info-json") - .arg("--embed-chapters") - .arg("--xattrs") - .arg("--sponsorblock-mark") - .arg("all") - .arg("--extractor-args") - .arg("youtube:player_client=web") - .arg("--progress") - .arg("--break-on-existing") - .arg("--download-archive") - .arg(archive_path.display().to_string()) - .arg("--impersonate") - .arg("Chrome-146:Macos-26") - .arg("-o") - .arg(&out_arg) - .arg(&url_for_thread) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + let mut cmd = Command::new("yt-dlp"); + cmd.arg("--newline") + .arg("--no-color") + .arg("--cookies") + .arg("cookies.txt") + .arg("--write-subs") + .arg("--write-auto-subs") + .arg("--write-thumbnail") + .arg("--write-description") + .arg("--write-info-json") + .arg("--remux-video") + .arg("mkv") + .arg("--embed-metadata") + .arg("--embed-info-json") + .arg("--embed-chapters") + .arg("--xattrs") + .arg("--sponsorblock-mark") + .arg("all") + .arg("--extractor-args") + .arg("youtube:player_client=web") + .arg("--progress") + .arg("--break-on-existing") + .arg("--download-archive") + .arg(archive_path.display().to_string()) + .arg("--impersonate") + .arg("Chrome-146:Macos-26") + .arg("-o") + .arg(&out_arg) + .arg(&url); + self.spawn_job(cmd, url, label); + } + + /// Re-fetch missing sidecar assets (thumbnail, info.json, description, + /// subtitles) for a single video without re-downloading the video itself. + /// + /// `dir`/`stem` come from the existing file on disk so the fetched sidecars + /// land with exactly the same filename stem and associate with the video. + pub fn repair(&mut self, video_id: &str, dir: &std::path::Path, stem: &str) { + let _ = std::fs::create_dir_all(dir); + // Escape literal `%` so yt-dlp doesn't treat it as an output field. + let safe_stem = stem.replace('%', "%%"); + let out_arg = format!("{}/{}.%(ext)s", dir.display(), safe_stem); + let url = format!("https://www.youtube.com/watch?v={video_id}"); + let label = format!("repair {stem}"); + + let mut cmd = Command::new("yt-dlp"); + cmd.arg("--newline") + .arg("--no-color") + .arg("--skip-download") + .arg("--cookies") + .arg("cookies.txt") + .arg("--write-thumbnail") + .arg("--write-info-json") + .arg("--write-description") + .arg("--write-subs") + .arg("--write-auto-subs") + .arg("--extractor-args") + .arg("youtube:player_client=web") + .arg("--impersonate") + .arg("Chrome-146:Macos-26") + .arg("-o") + .arg(&out_arg) + .arg(&url); + + self.spawn_job(cmd, url, label); + } + + /// Spawn `cmd` on a background thread, streaming its output into a new [`Job`]. + fn spawn_job(&mut self, mut cmd: Command, url: String, label: String) { + let (tx, rx) = channel(); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + thread::spawn(move || { let mut child = match cmd.spawn() { Ok(child) => child, Err(err) => { diff --git a/src/library.rs b/src/library.rs index 673802f..42abaf0 100644 --- a/src/library.rs +++ b/src/library.rs @@ -50,6 +50,9 @@ pub struct Video { pub has_live_chat: bool, /// Duration read from `info.json`; `None` if the sidecar is missing. pub duration_secs: Option, + /// Whether `info.json` lists a non-empty `chapters` array. Cached at scan + /// time so the web layer needn't re-read and parse the sidecar per request. + pub has_chapters: bool, /// Size of the video file on disk; `None` if the video file is missing. pub file_size: Option, } @@ -255,11 +258,19 @@ fn collect_raw_videos(entries: impl Iterator) -> Vec) -> Vec