diff --git a/src/app.rs b/src/app.rs index 63f3007..63e24a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use eframe::egui; use crate::config::Config; use crate::database::Database; -use crate::downloader::{detect_url_kind, Downloader, JobState, UrlKind}; +use crate::downloader::{detect_url_kind, DownloadQuality, Downloader, JobState, UrlKind}; use crate::library::{self, Video}; use crate::theme; @@ -35,14 +35,18 @@ enum SortMode { DurationDesc, SizeAsc, SizeDesc, + DateDesc, + DateAsc, } #[derive(Clone, PartialEq)] enum SidebarView { + Channels, All, Channel(usize), Playlist(usize, usize), ContinueWatching, + Music, } struct Card { @@ -54,6 +58,7 @@ struct Card { has_live_chat: bool, duration_secs: Option, file_size: Option, + upload_date: Option, watched: bool, resume_pos: Option, } @@ -71,13 +76,19 @@ pub struct App { show_settings: bool, dl_url: String, dl_full_scan: bool, + dl_quality: DownloadQuality, + dl_music_mode: bool, textures: HashMap>, thumb_request_tx: Sender, thumb_result_rx: Receiver<(PathBuf, Option)>, thumb_pending: HashSet, desc_cache: HashMap, status: String, + music_library: Vec, + music_root: PathBuf, settings_dir: String, + settings_plex_path: String, + plex_status: String, db: Database, card_density: f32, sort_mode: SortMode, @@ -143,9 +154,18 @@ impl App { let watched = db.get_watched().unwrap_or_default(); let resume_positions = db.get_positions().unwrap_or_default(); + let music_root = channels_root.with_file_name("music"); + let music_library = library::scan_music(&music_root); + + let max_concurrent = config.backup.max_concurrent; + let use_bundled_ytdlp = config.backup.use_bundled_ytdlp; let browser = config.player.browser.clone(); let config_bind = config.web.bind.clone(); let password_set = db.get_setting("password_hash").ok().flatten().is_some(); + let plex_path_str = config.plex.library_path + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::(); @@ -171,18 +191,24 @@ impl App { sidebar_view: SidebarView::All, selected_video: None, search: String::new(), - downloader: Downloader::new(channels_root, browser), + downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp), show_downloads: false, show_settings: false, dl_url: String::new(), - dl_full_scan: false, + dl_full_scan: true, + dl_quality: DownloadQuality::Best, + dl_music_mode: false, textures: HashMap::new(), thumb_request_tx, thumb_result_rx, thumb_pending: HashSet::new(), desc_cache: HashMap::new(), status, + music_library, + music_root, settings_dir, + settings_plex_path: plex_path_str, + plex_status: String::new(), db, card_density: 1.0, sort_mode: SortMode::Title, @@ -213,6 +239,7 @@ impl App { fn rescan(&mut self) { self.library = library::scan_channels(&self.channels_root); + self.music_library = library::scan_music(&self.music_root); self.sidebar_view = SidebarView::All; self.selected_video = None; self.desc_cache.clear(); @@ -273,12 +300,14 @@ impl App { has_live_chat: v.has_live_chat, duration_secs: v.duration_secs, file_size: v.file_size, + upload_date: v.upload_date.clone(), watched: self.watched.contains(&v.id), resume_pos, }); }; match &self.sidebar_view { + SidebarView::Channels | SidebarView::Music => { return cards; } // rendered separately SidebarView::ContinueWatching => { for ch in &self.library { for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) { @@ -336,23 +365,29 @@ impl App { cards.sort_by_key(|c| c.file_size.unwrap_or(0)); cards.reverse(); } + SortMode::DateDesc => { + // Empty/missing dates sort to the end of "newest first". + cards.sort_by(|a, b| b.upload_date.as_deref().unwrap_or("").cmp(a.upload_date.as_deref().unwrap_or(""))); + } + SortMode::DateAsc => { + // Empty/missing dates sort to the end of "oldest first" too. + cards.sort_by(|a, b| { + match (a.upload_date.as_deref(), b.upload_date.as_deref()) { + (Some(x), Some(y)) => x.cmp(y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }); + } } cards } fn find_video_by_id(&self, id: &str) -> Option<(Video, String)> { - for channel in &self.library { - if let Some(v) = channel.videos.iter().find(|v| v.id == id) { - return Some((v.clone(), channel.name.clone())); - } - for playlist in &channel.playlists { - if let Some(v) = playlist.videos.iter().find(|v| v.id == id) { - return Some((v.clone(), channel.name.clone())); - } - } - } - None + library::find_video(&self.library, id) + .map(|(v, ch)| (v.clone(), ch.name.clone())) } fn texture(&mut self, _ctx: &egui::Context, path: &Path) -> Option { @@ -381,7 +416,14 @@ impl App { fn play_with_tracking(&mut self, path: &Path, video_id: String) { let cmd = self.config.player.command.clone(); - let use_mpv_ipc = cmd.contains("mpv"); + // Only enable IPC for genuine mpv invocations β€” substring matching + // would also fire for things like `mympv-wrapper`, `gnomempv`, etc., + // which don't implement the JSON-IPC protocol. + let exe = std::path::Path::new(&cmd) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(cmd.as_str()); + let use_mpv_ipc = exe == "mpv" || exe == "mpv.exe"; #[cfg(unix)] let sock_path = format!("/tmp/yt-offline-{video_id}.sock"); @@ -501,7 +543,7 @@ impl App { .collect(); for url in urls { let kind = detect_url_kind(&url); - self.downloader.start(url, &kind, false); + self.downloader.start(url, &kind, true, DownloadQuality::Best); count += 1; } self.status = format!("Scheduled check: started {} channel downloads", count); @@ -582,6 +624,9 @@ impl App { self.show_settings = !self.show_settings; if self.show_settings { self.settings_dir = self.channels_root.display().to_string(); + self.settings_plex_path = self.config.plex.library_path + .as_deref().map(|p| p.display().to_string()).unwrap_or_default(); + self.plex_status.clear(); self.settings_bind_mode = crate::web::bind_mode_of(&self.config.web.bind).to_string(); self.settings_password_enabled = @@ -616,6 +661,17 @@ impl App { let total: usize = self.library.iter().map(|c| c.total_videos()).sum(); let resume_count = self.resume_positions.len(); + if ui + .selectable_label( + self.sidebar_view == SidebarView::Channels, + format!("⊟ Channels ({})", self.library.len()), + ) + .clicked() + { + self.sidebar_view = SidebarView::Channels; + self.selected_video = None; + } + if ui .selectable_label( self.sidebar_view == SidebarView::All, @@ -640,6 +696,18 @@ impl App { } } + let music_count = self.music_library.len(); + if ui + .selectable_label( + self.sidebar_view == SidebarView::Music, + format!("β™« Music ({music_count})"), + ) + .clicked() + { + self.sidebar_view = SidebarView::Music; + self.selected_video = None; + } + ui.separator(); // Collect any right-click download action outside the loop @@ -718,7 +786,7 @@ 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.dl_full_scan); + self.downloader.start(url, &kind, !self.dl_full_scan, DownloadQuality::Best); self.status = format!("Checking {} for new videos…", ch_name); } }); @@ -759,14 +827,39 @@ impl App { } } - ui.checkbox(&mut self.dl_full_scan, "Full scan (check every video, fills gaps)"); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.dl_music_mode, false, "🎬 Video"); + ui.selectable_value(&mut self.dl_music_mode, true, "🎡 Music"); + }); + + if self.dl_music_mode { + ui.label(egui::RichText::new("Audio-only β€” saves to music/ directory").small().weak()); + } else { + ui.horizontal(|ui| { + ui.label("Quality:"); + egui::ComboBox::from_id_salt("dl_quality") + .selected_text(self.dl_quality.label()) + .show_ui(ui, |ui| { + for &q in DownloadQuality::all() { + ui.selectable_value(&mut self.dl_quality, q, q.label()); + } + }); + }); + ui.checkbox(&mut self.dl_full_scan, "Fast mode (stop at first already-downloaded video)") + .on_hover_text("Faster for large channels but may miss new videos if gaps exist in the archive. Leave off to check every video."); + } let ready = !self.dl_url.trim().is_empty(); if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() { let url = self.dl_url.trim().to_string(); let dest = dest_preview.clone(); - self.downloader.start(url, &kind, self.dl_full_scan); - self.status = format!("Downloading: {dest}"); + if self.dl_music_mode { + self.downloader.start_music(url); + self.status = "Downloading music…".to_string(); + } else { + self.downloader.start(url, &kind, !self.dl_full_scan, self.dl_quality); + self.status = format!("Downloading: {dest}"); + } } ui.separator(); @@ -780,9 +873,24 @@ impl App { self.prev_job_states.clear(); } }); - if self.downloader.jobs.is_empty() { + if self.downloader.jobs.is_empty() && self.downloader.pending_count() == 0 { ui.label(egui::RichText::new("Nothing queued yet.").weak()); } + let pending_count = self.downloader.pending_count(); + if pending_count > 0 { + let max = self.downloader.max_concurrent; + ui.label( + egui::RichText::new(format!( + "⏳ {pending_count} queued (max {max} concurrent)" + )) + .small() + .weak(), + ); + let snapshots = self.downloader.pending_snapshots(); + for (label, _url) in &snapshots { + ui.label(egui::RichText::new(format!(" Β· {label}")).small().weak()); + } + } let mut remove_job: Option = None; egui::ScrollArea::vertical().show(ui, |ui| { let n = self.downloader.jobs.len(); @@ -811,7 +919,7 @@ impl App { if job.state == JobState::Running { ui.add(egui::ProgressBar::new(job.progress).show_percentage()); } - let last = job.log.last().map(String::as_str).unwrap_or(""); + let last = job.log.back().map(String::as_str).unwrap_or(""); if !last.is_empty() { ui.label(egui::RichText::new(last).small().monospace()); } @@ -1046,6 +1154,36 @@ impl App { ); ui.end_row(); + ui.label("Max concurrent downloads:"); + ui.add( + egui::DragValue::new(&mut self.config.backup.max_concurrent) + .range(1..=10), + ) + .on_hover_text("Maximum simultaneous yt-dlp processes. Extra downloads queue automatically."); + ui.end_row(); + + ui.label("yt-dlp binary:"); + ui.horizontal(|ui| { + ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, false, "System") + .on_hover_text("Use whatever yt-dlp is on PATH."); + ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, true, "Bundled") + .on_hover_text("Use the yt-dlp + deno installed under ~/.local/share/yt-offline/bin/."); + let installed = crate::ytdlp_bin::bundled_installed(); + let btn_label = if installed { "Update" } else { "Install" }; + if ui.button(btn_label) + .on_hover_text("Download (or update) the bundled yt-dlp + deno from GitHub. Streams output as a job.") + .clicked() + { + self.downloader.start_ytdlp_update(); + } + if installed { + ui.label(egui::RichText::new("βœ“ installed").weak().small()); + } else { + ui.label(egui::RichText::new("not installed").weak().small()); + } + }); + ui.end_row(); + ui.label("Web UI port:"); ui.add( egui::DragValue::new(&mut self.config.web.port) @@ -1171,6 +1309,46 @@ impl App { ui.end_row(); }); + ui.add_space(8.0); + ui.separator(); + ui.heading("Plex"); + ui.add_space(4.0); + ui.label("Plex library path:"); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.settings_plex_path) + .hint_text("/media/plex/YouTube") + .desired_width(300.0), + ); + }); + ui.label( + egui::RichText::new( + "Creates a TV-show symlink tree here. Point a Plex TV library at this folder.", + ) + .small() + .weak(), + ); + ui.horizontal(|ui| { + let can_generate = !self.settings_plex_path.trim().is_empty(); + if ui.add_enabled(can_generate, egui::Button::new("⟳ Generate Plex library")).clicked() { + let plex_path = PathBuf::from(self.settings_plex_path.trim()); + let result = crate::plex::generate(&self.library, &plex_path); + self.plex_status = if result.errors.is_empty() { + format!("{} link(s) created/updated", result.links_created) + } else { + format!( + "{} link(s) created, {} error(s): {}", + result.links_created, + result.errors.len(), + result.errors.first().unwrap_or(&String::new()) + ) + }; + } + if !self.plex_status.is_empty() { + ui.label(egui::RichText::new(&self.plex_status).small()); + } + }); + ui.add_space(8.0); ui.separator(); ui.add_space(4.0); @@ -1181,6 +1359,14 @@ impl App { let dir_changed = new_dir != self.config.backup.directory; self.config.backup.directory = new_dir.clone(); + // Plex library path + let plex_trimmed = self.settings_plex_path.trim(); + self.config.plex.library_path = if plex_trimmed.is_empty() { + None + } else { + Some(PathBuf::from(plex_trimmed)) + }; + // 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; @@ -1212,6 +1398,8 @@ impl App { Ok(_) => self.status = "Settings saved.".to_string(), Err(e) => self.status = format!("Error saving config: {e}"), } + self.downloader.max_concurrent = self.config.backup.max_concurrent; + self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp; if dir_changed { self.channels_root = new_dir.clone(); self.downloader.channels_root = new_dir; @@ -1327,7 +1515,154 @@ impl App { }); } + fn channel_grid(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + let density = self.card_density; + let thumb_w = (176.0 * density).round(); + let thumb_h = (99.0 * density).round(); + let card_w = thumb_w + 4.0; // 2px border each side + + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + let available_w = ui.available_width(); + let cols = ((available_w / (card_w + 12.0)) as usize).max(1); + let n = self.library.len(); + + for row_start in (0..n).step_by(cols) { + ui.horizontal(|ui| { + for i in row_start..(row_start + cols).min(n) { + // Collect data we need without holding a borrow into self.library + let (name, total, size_bytes, thumb_path) = { + let ch = &self.library[i]; + let thumb = ch.videos.iter() + .chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) + .find_map(|v| v.thumb_path.clone()); + (ch.name.clone(), ch.total_videos(), ch.total_size_cached, thumb) + }; + + ui.push_id(i, |ui| { + let (card_rect, card_resp) = ui.allocate_exact_size( + egui::vec2(card_w, thumb_h + 46.0 * density), + egui::Sense::click(), + ); + let visuals = ui.visuals(); + let border_color = if card_resp.hovered() { + visuals.selection.bg_fill + } else { + visuals.widgets.noninteractive.bg_stroke.color + }; + ui.painter().rect_stroke(card_rect, 6.0, egui::Stroke::new(2.0, border_color)); + + let thumb_rect = egui::Rect::from_min_size( + card_rect.min + egui::vec2(2.0, 2.0), + egui::vec2(thumb_w, thumb_h), + ); + let texture = thumb_path.as_ref().and_then(|p| self.texture(ctx, p)); + match &texture { + Some(handle) => { + egui::Image::new(handle) + .maintain_aspect_ratio(true) + .paint_at(ui, thumb_rect); + } + None => { + ui.painter().rect_filled(thumb_rect, 4.0, egui::Color32::from_gray(30)); + ui.painter().text( + thumb_rect.center(), + egui::Align2::CENTER_CENTER, + "πŸ“Ί", + egui::FontId::proportional(28.0 * density), + egui::Color32::from_gray(100), + ); + } + } + + let text_top = card_rect.min + egui::vec2(6.0, thumb_h + 6.0); + ui.painter().text( + text_top, + egui::Align2::LEFT_TOP, + &name, + egui::FontId::proportional(13.0 * density), + visuals.text_color(), + ); + let sub = format!( + "{} video{}{}", + total, + if total == 1 { "" } else { "s" }, + if size_bytes > 0 { format!(" Β· {}", format_size(size_bytes)) } else { String::new() } + ); + ui.painter().text( + text_top + egui::vec2(0.0, 16.0 * density), + egui::Align2::LEFT_TOP, + &sub, + egui::FontId::proportional(11.0 * density), + visuals.weak_text_color(), + ); + + if card_resp.clicked() { + self.sidebar_view = SidebarView::Channel(i); + self.selected_video = None; + } + }); + ui.add_space(8.0); + } + }); + ui.add_space(8.0); + } + }); + } + + fn music_view(&mut self, ui: &mut egui::Ui) { + ui.heading("Music"); + if self.music_library.is_empty() { + ui.label(egui::RichText::new( + "No tracks yet. Download audio with Music mode in the Downloads panel." + ).weak()); + return; + } + let mut current_artist = String::new(); + egui::ScrollArea::vertical().show(ui, |ui| { + for track in &self.music_library { + if track.artist != current_artist { + current_artist = track.artist.clone(); + ui.add_space(6.0); + ui.label(egui::RichText::new(&track.artist).strong()); + ui.separator(); + } + ui.push_id(&track.id, |ui| { + ui.horizontal(|ui| { + let dur = track.duration_secs + .map(|s| { + let m = s as u64 / 60; + let sec = s as u64 % 60; + format!("{m}:{sec:02}") + }) + .unwrap_or_default(); + if ui.selectable_label(false, &track.title).clicked() { + if let Err(e) = Command::new(&self.config.player.command) + .arg(&track.path) + .spawn() + { + self.status = format!("Could not open player: {e}"); + } + } + if !dur.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(dur).small().weak()); + }); + } + }); + }); + } + }); + } + fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + if self.sidebar_view == SidebarView::Channels { + self.channel_grid(ctx, ui); + return; + } + if self.sidebar_view == SidebarView::Music { + self.music_view(ui); + return; + } let cards = self.cards_take(); let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _)); @@ -1394,6 +1729,8 @@ impl App { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.selectable_value(&mut self.sort_mode, SortMode::DateDesc, "Newest"); + ui.selectable_value(&mut self.sort_mode, SortMode::DateAsc, "Oldest"); 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"); @@ -1605,12 +1942,14 @@ impl App { impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.downloader.poll(); + // Snapshot the previous-frame running state BEFORE check_notifications + // overwrites prev_job_states with the current frame's. Otherwise + // was_running tracks the wrong frame and the auto-rescan never fires. + let was_running_prev = self.prev_job_states.values().any(|&s| s == JobState::Running); self.check_notifications(); let any_running = self.downloader.any_running(); - // Auto-rescan when all downloads finish - let was_running = self.prev_job_states.values().any(|&s| s == JobState::Running); - if was_running && !any_running { + if was_running_prev && !any_running { self.rescan(); } @@ -1620,9 +1959,10 @@ impl eframe::App for App { // Scheduled channel checks if self.config.scheduler.enabled && !any_running { - let interval = std::time::Duration::from_secs( - self.config.scheduler.interval_hours as u64 * 3600, - ); + // Defensive clamp: a manually-edited config.toml with 0 hours + // would otherwise re-fire every frame. + let hours = self.config.scheduler.interval_hours.max(1); + let interval = std::time::Duration::from_secs(hours as u64 * 3600); let due = self.last_scheduled_check .map_or(true, |t| t.elapsed() >= interval); if due { diff --git a/src/config.rs b/src/config.rs index c020ae4..2cb8dc6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,14 +18,27 @@ pub struct Config { pub scheduler: SchedulerSection, #[serde(default)] pub web: WebSection, + #[serde(default)] + pub plex: PlexSection, } /// `[backup]` table β€” where to store downloaded videos. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BackupSection { pub directory: PathBuf, + /// Maximum simultaneous yt-dlp processes. Extra downloads queue and start + /// automatically when a slot opens. Set to 0 for no limit (not recommended). + #[serde(default = "default_max_concurrent")] + pub max_concurrent: usize, + /// If true, use the bundled yt-dlp + deno binaries managed by yt-offline + /// (installed under `~/.local/share/yt-offline/bin/`). If false, use the + /// `yt-dlp` found on the system PATH. + #[serde(default)] + pub use_bundled_ytdlp: bool, } +fn default_max_concurrent() -> usize { 3 } + /// `[player]` table β€” external player and browser cookie source. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct PlayerSection { @@ -71,6 +84,14 @@ impl Default for SchedulerSection { } } +/// `[plex]` table β€” Plex-compatible TV-show symlink library. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct PlexSection { + /// Directory where the Plex symlink tree is written. + /// Leave unset to disable Plex library generation. + pub library_path: Option, +} + /// `[web]` table β€” built-in HTTP server settings. /// /// `source_url` is **required for AGPL Β§13 compliance**: set it to a URL @@ -125,11 +146,16 @@ impl Config { /// Construct a minimal default config pointing `backup.directory` at `dir`. pub fn default_with_dir(dir: PathBuf) -> Self { Self { - backup: BackupSection { directory: dir }, + backup: BackupSection { + directory: dir, + max_concurrent: default_max_concurrent(), + use_bundled_ytdlp: false, + }, player: PlayerSection::default(), ui: UiSection::default(), scheduler: SchedulerSection::default(), web: WebSection::default(), + plex: PlexSection::default(), } } } diff --git a/src/database.rs b/src/database.rs index 60bb0fc..a2619e5 100644 --- a/src/database.rs +++ b/src/database.rs @@ -21,8 +21,23 @@ pub struct Database { impl Database { /// Open or create the database at `path`, running schema migrations. + /// + /// On Unix the file mode is tightened to `0600` so the Argon2 password + /// hash and resume positions aren't readable by other local users. A + /// best-effort: failure is logged but doesn't abort startup. pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(path) { + let mut perms = meta.permissions(); + if perms.mode() & 0o777 != 0o600 { + perms.set_mode(0o600); + let _ = std::fs::set_permissions(path, perms); + } + } + } let db = Database { conn }; db.init_schema()?; Ok(db) diff --git a/src/downloader.rs b/src/downloader.rs index de138c6..d6b3cdb 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -23,12 +23,53 @@ //! | `--break-on-existing` | Stop when the archive file records the video as already downloaded | //! | `--download-archive archive.txt` | Record downloaded IDs to avoid re-downloading | +use std::collections::VecDeque; use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::mpsc::{channel, Receiver}; use std::thread; +use crate::ytdlp_bin; + +/// Video quality level passed as a `-f` format selector to yt-dlp. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum DownloadQuality { + /// No `-f` flag β€” yt-dlp picks the best available streams (default). + #[default] + Best, + Res1080, + Res720, + Res480, + Res360, +} + +impl DownloadQuality { + pub fn format_spec(self) -> Option<&'static str> { + match self { + Self::Best => None, + Self::Res1080 => Some("bestvideo[height<=1080]+bestaudio/best[height<=1080]"), + Self::Res720 => Some("bestvideo[height<=720]+bestaudio/best[height<=720]"), + Self::Res480 => Some("bestvideo[height<=480]+bestaudio/best[height<=480]"), + Self::Res360 => Some("bestvideo[height<=360]+bestaudio/best[height<=360]"), + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Best => "Best", + Self::Res1080 => "1080p", + Self::Res720 => "720p", + Self::Res480 => "480p", + Self::Res360 => "360p", + } + } + + pub fn all() -> &'static [DownloadQuality] { + &[Self::Best, Self::Res1080, Self::Res720, Self::Res480, Self::Res360] + } +} + /// Describes the kind of YouTube URL being downloaded, which determines the /// output path template passed to yt-dlp. pub enum UrlKind { @@ -96,6 +137,9 @@ enum Msg { Finished(bool), } +/// Maximum lines retained in [`Job::log`] before old lines are evicted. +const JOB_LOG_CAP: usize = 800; + /// A single yt-dlp invocation tracked by the downloader. pub struct Job { pub url: String, @@ -104,8 +148,8 @@ pub struct Job { pub state: JobState, /// Download progress as a fraction in `[0.0, 1.0]`. pub progress: f32, - /// Rolling log buffer β€” capped at 800 lines to avoid unbounded growth. - pub log: Vec, + /// Rolling log buffer β€” capped at [`JOB_LOG_CAP`] lines via O(1) front-pop. + pub log: VecDeque, rx: Receiver, } @@ -114,10 +158,9 @@ impl Job { while let Ok(msg) = self.rx.try_recv() { match msg { Msg::Line(line) => { - self.log.push(line); - if self.log.len() > 800 { - let cut = self.log.len() - 800; - self.log.drain(0..cut); + self.log.push_back(line); + while self.log.len() > JOB_LOG_CAP { + self.log.pop_front(); } } Msg::Progress(p) => self.progress = p, @@ -129,18 +172,108 @@ impl Job { } } -/// Manages all active and recently completed yt-dlp download jobs. +/// A download waiting to start once a concurrency slot opens. +struct PendingJob { + cmd: Command, + url: String, + label: String, +} + +/// Manages all active, queued, and recently completed yt-dlp download jobs. pub struct Downloader { pub jobs: Vec, + pending: VecDeque, pub channels_root: PathBuf, - /// Browser name passed to `--cookies-from-browser` (unused) β€” cookie file - /// is currently always `cookies.txt`. + /// Browser name passed to `--cookies-from-browser` *when no cookies.txt + /// exists in the working directory*. Set to `"none"` to skip the fallback + /// entirely. Pasted/imported cookies.txt always takes precedence. pub browser: String, + /// Maximum number of simultaneous yt-dlp processes. 0 = unlimited. + pub max_concurrent: usize, + /// If true, invoke the bundled yt-dlp under [`ytdlp_bin::bundled_dir`] + /// instead of the system PATH yt-dlp. + pub use_bundled_ytdlp: bool, } impl Downloader { - pub fn new(channels_root: PathBuf, browser: String) -> Self { - Self { jobs: Vec::new(), channels_root, browser } + pub fn new(channels_root: PathBuf, browser: String, max_concurrent: usize, use_bundled_ytdlp: bool) -> Self { + Self { + jobs: Vec::new(), + pending: VecDeque::new(), + channels_root, + browser, + max_concurrent, + use_bundled_ytdlp, + } + } + + /// Append the cookie-source flags. Prefers `cookies.txt` in the working + /// directory (set up via the cookies UI), falling back to + /// `--cookies-from-browser ` when no cookies.txt exists and the + /// user has chosen a browser (anything other than `"none"`). + fn apply_cookie_flags(&self, cmd: &mut Command) { + let cookies_txt = std::path::Path::new("cookies.txt"); + if cookies_txt.exists() { + cmd.arg("--cookies").arg("cookies.txt"); + } else if !self.browser.is_empty() && self.browser != "none" { + cmd.arg("--cookies-from-browser").arg(&self.browser); + } + } + + /// Append the retry and throttling flags applied to every yt-dlp invocation. + /// + /// YouTube occasionally resets connections mid-transfer; with default + /// settings yt-dlp gives up after 10 quick retries. We bump the retry + /// count and add a linear backoff so transient resets self-heal. The + /// sleep flags throttle per-IP request rate slightly so we don't trip + /// YouTube's rate limiter when many channels are being checked at once. + fn apply_retry_flags(cmd: &mut Command) { + cmd.arg("--retries").arg("30") + .arg("--fragment-retries").arg("30") + .arg("--retry-sleep").arg("linear=1:30:2") + .arg("--sleep-requests").arg("1"); + } + + /// Build a fresh `Command` invoking the currently configured yt-dlp binary. + /// + /// In bundled mode, defensively re-applies the executable bit on every + /// binary inside the bundled bin dir. Without this, downloads can fail + /// with EACCES if a previous install left the file un-executable (e.g. + /// because the chmod step of the install script never ran). + fn ytdlp_cmd(&self) -> Command { + let path = ytdlp_bin::ytdlp_invocation(self.use_bundled_ytdlp); + if self.use_bundled_ytdlp { + ytdlp_bin::ensure_bundled_executable(); + } + Command::new(path) + } + + /// Number of jobs waiting in the queue (not yet started). + pub fn pending_count(&self) -> usize { + self.pending.len() + } + + /// Labels and URLs of all queued (not yet started) jobs, in queue order. + pub fn pending_snapshots(&self) -> Vec<(String, String)> { + self.pending.iter().map(|p| (p.label.clone(), p.url.clone())).collect() + } + + /// Promote pending jobs into running slots while capacity allows. + fn promote_queued(&mut self) { + while !self.pending.is_empty() { + if self.max_concurrent > 0 { + let running = self.jobs.iter().filter(|j| j.state == JobState::Running).count(); + if running >= self.max_concurrent { break; } + } + let p = self.pending.pop_front().unwrap(); + self.spawn_job(p.cmd, p.url, p.label); + } + } + + /// Push a command into the pending queue (or start immediately if a slot is free). + fn enqueue(&mut self, cmd: Command, url: String, label: String) { + self.pending.push_back(PendingJob { cmd, url, label }); + self.promote_queued(); } /// Spawn a yt-dlp process for `url` and track it as a new [`Job`]. @@ -153,7 +286,7 @@ impl Downloader { /// video β€” fast for routine channel checks. When `full_scan` is true the /// flag is omitted so every video is checked individually against the /// download archive; slower, but correctly fills gaps in the history. - pub fn start(&mut self, url: String, kind: &UrlKind, full_scan: bool) { + pub fn start(&mut self, url: String, kind: &UrlKind, full_scan: bool, quality: DownloadQuality) { let archive_path = self.channels_root.join("archive.txt"); let (out_arg, label) = match kind { @@ -175,12 +308,10 @@ impl Downloader { ), }; - let mut cmd = Command::new("yt-dlp"); - cmd.arg("--newline") - .arg("--no-color") - .arg("--cookies") - .arg("cookies.txt") - .arg("--write-subs") + let mut cmd = self.ytdlp_cmd(); + cmd.arg("--newline").arg("--no-color"); + self.apply_cookie_flags(&mut cmd); + cmd.arg("--write-subs") .arg("--write-auto-subs") .arg("--write-thumbnail") .arg("--write-description") @@ -200,6 +331,9 @@ impl Downloader { .arg("--extractor-args") .arg("youtube:player_client=web") .arg("--progress"); + if let Some(fmt) = quality.format_spec() { + cmd.arg("-f").arg(fmt); + } if !full_scan { cmd.arg("--break-on-existing"); } @@ -210,8 +344,9 @@ impl Downloader { .arg("-o") .arg(&out_arg) .arg(&url); + Self::apply_retry_flags(&mut cmd); - self.spawn_job(cmd, url, label); + self.enqueue(cmd, url, label); } /// Re-fetch missing sidecar assets (thumbnail, info.json, description, @@ -227,13 +362,10 @@ impl Downloader { 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") + let mut cmd = self.ytdlp_cmd(); + cmd.arg("--newline").arg("--no-color").arg("--skip-download"); + self.apply_cookie_flags(&mut cmd); + cmd.arg("--write-thumbnail") .arg("--write-info-json") .arg("--write-description") .arg("--write-subs") @@ -245,8 +377,62 @@ impl Downloader { .arg("-o") .arg(&out_arg) .arg(&url); + Self::apply_retry_flags(&mut cmd); - self.spawn_job(cmd, url, label); + self.enqueue(cmd, url, label); + } + + /// Path to the music download directory (sibling of `channels_root`). + pub fn music_root(&self) -> PathBuf { + self.channels_root.with_file_name("music") + } + + /// Download `url` as audio-only, storing tracks in `music//`. + pub fn start_music(&mut self, url: String) { + let music_root = self.music_root(); + let _ = std::fs::create_dir_all(&music_root); + let archive_path = self.channels_root.join("archive.txt"); + let out_arg = format!( + "{}/%(artist,channel|Unknown)s/%(title)s [%(id)s].%(ext)s", + music_root.display() + ); + let label = "music".to_string(); + + let mut cmd = self.ytdlp_cmd(); + cmd.arg("--newline").arg("--no-color"); + self.apply_cookie_flags(&mut cmd); + cmd.arg("--extract-audio") + .arg("--audio-format") + .arg("best") + .arg("--audio-quality") + .arg("0") + .arg("--write-thumbnail") + .arg("--write-info-json") + .arg("--embed-metadata") + .arg("--xattrs") + .arg("--extractor-args") + .arg("youtube:player_client=web") + .arg("--impersonate") + .arg("Chrome-146:Macos-26") + .arg("--progress") + .arg("--download-archive") + .arg(archive_path.display().to_string()) + .arg("-o") + .arg(&out_arg) + .arg(&url); + Self::apply_retry_flags(&mut cmd); + + self.enqueue(cmd, url, label); + } + + /// Enqueue a job that downloads (or updates) the bundled yt-dlp + deno + /// binaries into [`ytdlp_bin::bundled_dir`]. Streams the curl/unzip output + /// into a normal [`Job`] entry so the user sees progress in the UI. + pub fn start_ytdlp_update(&mut self) { + let cmd = ytdlp_bin::install_command(); + let url = "https://github.com/yt-dlp/yt-dlp/releases/latest".to_string(); + let label = "update bundled yt-dlp + deno".to_string(); + self.enqueue(cmd, url, label); } /// Spawn `cmd` on a background thread, streaming its output into a new [`Job`]. @@ -256,6 +442,19 @@ impl Downloader { .stdout(Stdio::piped()) .stderr(Stdio::piped()); + // Prepend the bundled bin dir to PATH so yt-dlp can locate the bundled + // `deno` for JavaScript signature deciphering. Harmless when the dir + // doesn't exist or bundled mode is disabled. + let bundled_dir = ytdlp_bin::bundled_dir(); + if bundled_dir.exists() { + let sep = if cfg!(windows) { ";" } else { ":" }; + let new_path = match std::env::var_os("PATH") { + Some(existing) => format!("{}{}{}", bundled_dir.display(), sep, existing.to_string_lossy()), + None => bundled_dir.display().to_string(), + }; + cmd.env("PATH", new_path); + } + thread::spawn(move || { let mut child = match cmd.spawn() { Ok(child) => child, @@ -310,16 +509,18 @@ impl Downloader { let _ = tx.send(Msg::Finished(ok)); }); - self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: Vec::new(), rx }); + self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: VecDeque::new(), rx }); } - /// Drain pending messages from all job threads into their log buffers. + /// Drain pending messages from all job threads and promote queued jobs. /// /// Call this regularly from the UI event loop to pick up progress updates. pub fn poll(&mut self) { for job in &mut self.jobs { job.drain(); } + // Re-check after draining: finished jobs free slots for queued ones. + self.promote_queued(); } pub fn any_running(&self) -> bool { @@ -349,3 +550,54 @@ fn parse_progress(line: &str) -> Option { let value: f32 = rest[..pct_end].trim().parse().ok()?; Some((value / 100.0).clamp(0.0, 1.0)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_progress_typical() { + let p = parse_progress("[download] 42.7% of 100MiB at 5MiB/s ETA 00:10").unwrap(); + assert!((p - 0.427).abs() < 1e-4); + } + + #[test] + fn parse_progress_clamps_to_one() { + let p = parse_progress("[download] 150% of garbage").unwrap(); + assert_eq!(p, 1.0); + } + + #[test] + fn parse_progress_rejects_non_download_lines() { + assert!(parse_progress("[info] Writing thumbnail").is_none()); + assert!(parse_progress("").is_none()); + } + + #[test] + fn extract_after_strips_at_separator() { + assert_eq!(extract_after("https://youtube.com/@handle/videos", "/@"), Some("handle")); + assert_eq!(extract_after("https://youtube.com/@handle", "/@"), Some("handle")); + assert_eq!(extract_after("https://youtube.com/@handle?x=1", "/@"), Some("handle")); + assert_eq!(extract_after("nothing-here", "/@"), None); + } + + #[test] + fn check_url_for_folder_picks_channel_form_for_ids() { + let url = check_url_for_folder("UC1234567890123456789012"); + assert_eq!(url, "https://www.youtube.com/channel/UC1234567890123456789012"); + } + + #[test] + fn check_url_for_folder_picks_handle_form_otherwise() { + let url = check_url_for_folder("LinusTechTips"); + assert_eq!(url, "https://www.youtube.com/@LinusTechTips"); + } + + #[test] + fn detect_url_kind_classifies_paths() { + assert!(matches!(detect_url_kind("https://www.youtube.com/playlist?list=PL1"), UrlKind::Playlist)); + assert!(matches!(detect_url_kind("https://www.youtube.com/watch?v=abc"), UrlKind::Video)); + assert!(matches!(detect_url_kind("https://youtu.be/abc"), UrlKind::Video)); + assert!(matches!(detect_url_kind("https://www.youtube.com/@handle"), UrlKind::Channel { .. })); + } +} diff --git a/src/library.rs b/src/library.rs index 8310c75..33ab792 100644 --- a/src/library.rs +++ b/src/library.rs @@ -23,6 +23,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"]; +const AUDIO_EXTS: &[&str] = &["mp3", "m4a", "opus", "flac", "ogg", "wav", "aac"]; const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"]; /// A single WebVTT subtitle track discovered alongside a video file. @@ -33,6 +34,20 @@ pub struct Subtitle { pub path: PathBuf, } +/// An audio track in the music library. +#[derive(Clone, Debug)] +pub struct Track { + pub id: String, + pub title: String, + pub artist: String, + pub path: PathBuf, + pub thumb_path: Option, + #[allow(dead_code)] + pub info_path: Option, + pub duration_secs: Option, + pub file_size: Option, +} + /// A fully enriched video entry, ready to serve to the UI. #[derive(Clone, Debug)] pub struct Video { @@ -55,6 +70,9 @@ pub struct Video { pub has_chapters: bool, /// Size of the video file on disk; `None` if the video file is missing. pub file_size: Option, + /// Upload date as `YYYYMMDD` (yt-dlp's native format from info.json). + /// `None` if the info.json sidecar is missing or lacks the field. + pub upload_date: Option, } /// A sub-directory inside a channel that contains videos (treated as a playlist). @@ -94,22 +112,58 @@ impl Channel { pub fn total_videos(&self) -> usize { self.total_videos_cached } + + /// Iterate over every [`Video`] in this channel, including those nested + /// inside playlists. Used widely; previously open-coded at each call site. + pub fn all_videos(&self) -> impl Iterator { + self.videos + .iter() + .chain(self.playlists.iter().flat_map(|p| p.videos.iter())) + } +} + +/// Find a video by ID across a slice of channels. Returns the matching +/// [`Video`] alongside the channel it belongs to. +pub fn find_video<'a>(channels: &'a [Channel], id: &str) -> Option<(&'a Video, &'a Channel)> { + for ch in channels { + if let Some(v) = ch.all_videos().find(|v| v.id == id) { + return Some((v, ch)); + } + } + None } /// Scan `root` for channel directories and return them sorted alphabetically. /// /// Skips hidden directories (names starting with `.`) and directories that /// contain no recognisable video files. +/// +/// Each channel's per-video info.json reads are parallelised across the +/// available CPUs because that's where ~all the time goes for large +/// libraries (one fs read + one JSON parse per video, multiplied by hundreds +/// or thousands). pub fn scan_channels(root: &Path) -> Vec { - let mut channels = Vec::new(); - let Ok(entries) = std::fs::read_dir(root) else { return channels }; - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { continue; } - let name = entry.file_name().to_string_lossy().into_owned(); - if name.starts_with('.') { continue; } + let Ok(entries) = std::fs::read_dir(root) else { return Vec::new() }; + let dirs: Vec<(String, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let path = e.path(); + if !path.is_dir() { return None; } + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { return None; } + Some((name, path)) + }) + .collect(); + + // Process channels in parallel. We size the worker pool to min(channels, CPUs) + // so a small library doesn't spin up needless threads. + let n_workers = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + .min(dirs.len().max(1)); + let mut channels = parallel_map(dirs, n_workers, |(name, path)| { let (videos, playlists) = scan_channel_dir(&path); - if videos.is_empty() && playlists.is_empty() { continue; } + if videos.is_empty() && playlists.is_empty() { return None; } let meta = load_channel_meta(&videos); let total_videos_cached = videos.len() + playlists.iter().map(|p| p.videos.len()).sum::(); @@ -118,7 +172,7 @@ pub fn scan_channels(root: &Path) -> Vec { .chain(playlists.iter().flat_map(|p| p.videos.iter())) .filter_map(|v| v.file_size) .sum(); - channels.push(Channel { + Some(Channel { name, path, videos, @@ -126,12 +180,67 @@ pub fn scan_channels(root: &Path) -> Vec { meta, total_videos_cached, total_size_cached, - }); - } + }) + }) + .into_iter() + .flatten() + .collect::>(); channels.sort_by_key(|c| c.name.to_lowercase()); channels } +/// Fan an `items` slice across `n_workers` threads, applying `f` to each item +/// and returning results in the original input order. +/// +/// Stdlib-only mini work-stealer: an atomic index hands out the next slot to +/// any worker that's free. Used to parallelise channel-directory scans +/// without dragging in rayon. +fn parallel_map(items: Vec, n_workers: usize, f: F) -> Vec +where + I: Send + 'static, + O: Send + 'static + Default, + F: Fn(I) -> O + Send + Sync + 'static, +{ + let len = items.len(); + if len == 0 { return Vec::new(); } + if n_workers <= 1 { + return items.into_iter().map(f).collect(); + } + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + let items: Vec>> = items.into_iter().map(|v| Mutex::new(Some(v))).collect(); + let items = Arc::new(items); + let results: Vec> = (0..len).map(|_| Mutex::new(O::default())).collect(); + let results = Arc::new(results); + let next = Arc::new(AtomicUsize::new(0)); + let f = Arc::new(f); + + let mut handles = Vec::with_capacity(n_workers); + for _ in 0..n_workers { + let items = items.clone(); + let results = results.clone(); + let next = next.clone(); + let f = f.clone(); + handles.push(std::thread::spawn(move || { + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + if i >= len { break; } + let input = items[i].lock().unwrap().take().unwrap(); + let out = f(input); + *results[i].lock().unwrap() = out; + } + })); + } + for h in handles { let _ = h.join(); } + + Arc::try_unwrap(results) + .unwrap_or_else(|_| unreachable!("workers joined; refs released")) + .into_iter() + .map(|m| m.into_inner().unwrap()) + .collect() +} + fn load_channel_meta(videos: &[Video]) -> Option { // Pull channel-level fields out of the first video's info.json let info_path = videos.iter().find_map(|v| { @@ -261,7 +370,7 @@ fn collect_raw_videos(entries: impl Iterator) -> Vec) -> Vec