From c11d1c336613c4ed5da724c00977e86b21052f7f Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 11 May 2026 03:18:16 -0700 Subject: [PATCH] add themes, settings GUI, URL-aware download routing, and playlist view --- Cargo.toml | 4 +- src/app.rs | 394 +++++++++++++++++++++++++++++++++------------- src/config.rs | 32 ++++ src/downloader.rs | 77 +++++++-- src/library.rs | 90 +++++++++-- src/main.rs | 5 +- src/theme.rs | 183 +++++++++++++++++++++ 7 files changed, 649 insertions(+), 136 deletions(-) create mode 100644 src/theme.rs diff --git a/Cargo.toml b/Cargo.toml index 27f6907..859b113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "youtube-backup" +name = "yt-offline" version = "0.1.0" edition = "2021" -description = "A small yt-dlp front-end: browse downloaded channels and queue new downloads" +description = "A desktop app for archiving YouTube channels with yt-dlp" [dependencies] eframe = "0.29" diff --git a/src/app.rs b/src/app.rs index 0e6753c..92dcf51 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,13 +5,11 @@ use std::process::Command; use eframe::egui; use crate::config::Config; -use crate::downloader::{Downloader, JobState}; -use crate::library::{self, Channel, Video}; +use crate::downloader::{detect_url_kind, Downloader, JobState, UrlKind}; +use crate::library::{self, Video}; +use crate::theme; -/// Flattened, cheap-to-clone view of one video for a single frame of rendering. struct Card { - channel_idx: usize, - video_idx: usize, channel_name: String, title: String, id: String, @@ -21,107 +19,162 @@ struct Card { } pub struct App { + config: Config, + config_path: PathBuf, channels_root: PathBuf, - library: Vec, + library: Vec, selected_channel: Option, - selected_video: Option<(usize, usize)>, + selected_playlist: Option<(usize, usize)>, + selected_video: Option, search: String, downloader: Downloader, show_downloads: bool, + show_settings: bool, dl_url: String, - dl_dir: String, - player_command: String, - /// Decoded thumbnails. `None` means "tried and failed / not loadable". textures: HashMap>, - /// How many new thumbnails we're still allowed to decode this frame. decode_budget: u32, desc_cache: HashMap, status: String, + settings_dir: String, } impl App { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - cc.egui_ctx.set_visuals(egui::Visuals::dark()); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let config_path = cwd.join("config.toml"); - let (channels_root, player_command) = match Config::load(&config_path) { - Ok(config) => (config.backup.directory.clone(), config.player.command), + let config = match Config::load(&config_path) { + Ok(c) => c, Err(e) => { - eprintln!("Warning: failed to load config.toml: {e}. Using default channels directory."); - (cwd.join("channels"), "mpv".to_string()) + eprintln!("Warning: failed to load config.toml: {e}. Using defaults."); + Config::default_with_dir(cwd.join("channels")) } }; + theme::apply(&cc.egui_ctx, &config.ui.theme); + + let channels_root = config.backup.directory.clone(); + let settings_dir = channels_root.display().to_string(); let _ = std::fs::create_dir_all(&channels_root); - let _db_path = channels_root.parent().map(|p| p.join("backup.db")); let library = library::scan_channels(&channels_root); let status = format!( "{} channels, {} videos", library.len(), - library.iter().map(|c| c.videos.len()).sum::() + library.iter().map(|c| c.total_videos()).sum::() ); + Self { + config, + config_path, channels_root: channels_root.clone(), library, selected_channel: None, + selected_playlist: None, selected_video: None, search: String::new(), downloader: Downloader::new(channels_root), show_downloads: false, + show_settings: false, dl_url: String::new(), - dl_dir: String::new(), - player_command, textures: HashMap::new(), decode_budget: 0, desc_cache: HashMap::new(), status, + settings_dir, } } fn rescan(&mut self) { self.library = library::scan_channels(&self.channels_root); self.selected_channel = None; + self.selected_playlist = None; self.selected_video = None; self.desc_cache.clear(); + self.textures.clear(); self.status = format!( "Rescanned: {} channels, {} videos", self.library.len(), - self.library.iter().map(|c| c.videos.len()).sum::() + self.library.iter().map(|c| c.total_videos()).sum::() ); } fn cards(&self) -> Vec { let query = self.search.trim().to_lowercase(); + + let channel_indices: Vec = if let Some(ci) = self.selected_channel { + vec![ci] + } else { + (0..self.library.len()).collect() + }; + let mut cards = Vec::new(); - for (ci, channel) in self.library.iter().enumerate() { - if let Some(sel) = self.selected_channel { - if sel != ci { - continue; + for ci in channel_indices { + let Some(channel) = self.library.get(ci) else { continue }; + + let playlist_filter = match self.selected_playlist { + Some((pci, pi)) if pci == ci => Some(pi), + _ => None, + }; + + if let Some(pi) = playlist_filter { + let Some(playlist) = channel.playlists.get(pi) else { continue }; + for v in &playlist.videos { + if !query.is_empty() + && !v.title.to_lowercase().contains(&query) + && !v.id.to_lowercase().contains(&query) + { + continue; + } + cards.push(Card { + channel_name: channel.name.clone(), + title: v.title.clone(), + id: v.id.clone(), + video_path: v.video_path.clone(), + thumb_path: v.thumb_path.clone(), + has_live_chat: v.has_live_chat, + }); } - } - for (vi, v) in channel.videos.iter().enumerate() { - if !query.is_empty() - && !v.title.to_lowercase().contains(&query) - && !v.id.to_lowercase().contains(&query) - { - continue; + } else { + let all_videos: Vec<&library::Video> = channel + .videos + .iter() + .chain(channel.playlists.iter().flat_map(|p| p.videos.iter())) + .collect(); + for v in all_videos { + if !query.is_empty() + && !v.title.to_lowercase().contains(&query) + && !v.id.to_lowercase().contains(&query) + { + continue; + } + cards.push(Card { + channel_name: channel.name.clone(), + title: v.title.clone(), + id: v.id.clone(), + video_path: v.video_path.clone(), + thumb_path: v.thumb_path.clone(), + has_live_chat: v.has_live_chat, + }); } - cards.push(Card { - channel_idx: ci, - video_idx: vi, - channel_name: channel.name.clone(), - title: v.title.clone(), - id: v.id.clone(), - video_path: v.video_path.clone(), - thumb_path: v.thumb_path.clone(), - has_live_chat: v.has_live_chat, - }); } } 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 + } + fn texture(&mut self, ctx: &egui::Context, path: &Path) -> Option { if let Some(slot) = self.textures.get(path) { return slot.clone(); @@ -150,7 +203,8 @@ impl App { } fn play(&mut self, path: &Path) { - match Command::new(&self.player_command).arg(path).spawn() { + let cmd = self.config.player.command.clone(); + match Command::new(&cmd).arg(path).spawn() { Ok(_) => self.status = format!("Playing {}", file_label(path)), Err(_) => match Command::new("xdg-open").arg(path).spawn() { Ok(_) => self.status = format!("Opened {} in default player", file_label(path)), @@ -160,14 +214,9 @@ impl App { } fn open_in_file_manager(&mut self, path: &Path) { - let target = if path.is_dir() { - path - } else { - path.parent().unwrap_or(path) - }; - match Command::new("xdg-open").arg(target).spawn() { - Ok(_) => {} - Err(e) => self.status = format!("Couldn't open folder: {e}"), + let target = if path.is_dir() { path } else { path.parent().unwrap_or(path) }; + if let Err(e) = Command::new("xdg-open").arg(target).spawn() { + self.status = format!("Couldn't open folder: {e}"); } } @@ -175,7 +224,7 @@ impl App { egui::TopBottomPanel::top("top_bar").show(ctx, |ui| { ui.add_space(2.0); ui.horizontal(|ui| { - ui.heading("YouTube Backup"); + ui.heading("yt-offline"); ui.separator(); ui.label("๐Ÿ”"); ui.add( @@ -190,14 +239,16 @@ impl App { if ui.button("โŸณ Rescan").clicked() { self.rescan(); } - let dl_label = if self.show_downloads { - "โฌ‡ Downloads โ–ธ" - } else { - "โฌ‡ Downloads" - }; + let dl_label = if self.show_downloads { "โฌ‡ Downloads โ–ธ" } else { "โฌ‡ Downloads" }; if ui.selectable_label(self.show_downloads, dl_label).clicked() { self.show_downloads = !self.show_downloads; } + 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(); + } + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(&self.status).weak()); }); @@ -215,25 +266,51 @@ impl App { ui.heading("Channels"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { - let total: usize = self.library.iter().map(|c| c.videos.len()).sum(); + let total: usize = self.library.iter().map(|c| c.total_videos()).sum(); if ui - .selectable_label(self.selected_channel.is_none(), format!("โŠž All ({total})")) + .selectable_label( + self.selected_channel.is_none(), + format!("โŠž All ({total})"), + ) .clicked() { self.selected_channel = None; + self.selected_playlist = None; self.selected_video = None; } ui.separator(); - for (i, channel) in self.library.iter().enumerate() { - let label = format!("{} ({})", channel.name, channel.videos.len()); + for i in 0..self.library.len() { + let is_selected = self.selected_channel == Some(i); + let (name, total, has_playlists) = { + let ch = &self.library[i]; + (ch.name.clone(), ch.total_videos(), !ch.playlists.is_empty()) + }; + let label = format!("{} ({})", name, total); if ui - .selectable_label(self.selected_channel == Some(i), label) - .on_hover_text(channel.path.display().to_string()) + .selectable_label(is_selected && self.selected_playlist.is_none(), label) + .on_hover_text(self.library[i].path.display().to_string()) .clicked() { self.selected_channel = Some(i); + self.selected_playlist = None; self.selected_video = None; } + if is_selected && has_playlists { + let playlist_count = self.library[i].playlists.len(); + for pi in 0..playlist_count { + let (pl_name, pl_len) = { + let pl = &self.library[i].playlists[pi]; + (pl.name.clone(), pl.videos.len()) + }; + let is_pl = self.selected_playlist == Some((i, pi)); + let pl_label = + format!(" โ”” {} ({})", pl_name, pl_len); + if ui.selectable_label(is_pl, pl_label).clicked() { + self.selected_playlist = Some((i, pi)); + self.selected_video = None; + } + } + } } }); }); @@ -252,24 +329,35 @@ impl App { .hint_text("https://www.youtube.com/โ€ฆ") .desired_width(f32::INFINITY), ); - ui.horizontal(|ui| { - ui.label("Save into channels/"); - ui.add( - egui::TextEdit::singleline(&mut self.dl_dir) - .hint_text("folder_name") - .desired_width(170.0), - ); - }); - let ready = !self.dl_url.trim().is_empty() && !self.dl_dir.trim().is_empty(); - if ui - .add_enabled(ready, egui::Button::new("โฌ‡ Start download")) - .clicked() - { - let url = self.dl_url.trim().to_string(); - let dir = self.dl_dir.trim().to_string(); - self.downloader.start(url, dir.clone()); - self.status = format!("Downloading into channels/{dir}"); + + let kind = detect_url_kind(self.dl_url.trim()); + let (type_label, dest_preview) = match &kind { + UrlKind::Channel { handle } => { + ("Channel", format!("โ†’ channels/{}/", handle)) + } + UrlKind::Playlist => ("Playlist", "โ†’ channels///".to_string()), + UrlKind::Video => ("Video", "โ†’ channels//".to_string()), + UrlKind::Unknown => ("โ€”", String::new()), + }; + + if !self.dl_url.trim().is_empty() { + ui.horizontal(|ui| { + ui.label("Type:"); + ui.strong(type_label); + }); + if !dest_preview.is_empty() { + ui.label(egui::RichText::new(&dest_preview).small().weak()); + } } + + 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.status = format!("Downloading: {dest}"); + } + ui.separator(); ui.horizontal(|ui| { ui.heading("Jobs"); @@ -277,9 +365,7 @@ impl App { && !self.downloader.any_running() && ui.button("Clear finished").clicked() { - self.downloader - .jobs - .retain(|j| j.state == JobState::Running); + self.downloader.jobs.retain(|j| j.state == JobState::Running); } }); if self.downloader.jobs.is_empty() { @@ -295,7 +381,7 @@ impl App { ui.group(|ui| { ui.horizontal(|ui| { ui.colored_label(color, text); - ui.label(format!("โ†’ channels/{}", job.dir_name)); + ui.label(&job.label); }); ui.label(egui::RichText::new(&job.url).small().weak()); if job.state == JobState::Running { @@ -312,9 +398,7 @@ impl App { .stick_to_bottom(true) .show(ui, |ui| { for line in &job.log { - ui.label( - egui::RichText::new(line).small().monospace(), - ); + ui.label(egui::RichText::new(line).small().monospace()); } }); }); @@ -324,11 +408,93 @@ impl App { }); } - fn detail_panel(&mut self, ctx: &egui::Context) { - let Some((ci, vi)) = self.selected_video else { + fn settings_window(&mut self, ctx: &egui::Context) { + if !self.show_settings { return; + } + let mut open = self.show_settings; + egui::Window::new("โš™ Settings") + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(440.0) + .show(ctx, |ui| { + egui::Grid::new("settings_grid") + .num_columns(2) + .spacing([12.0, 8.0]) + .striped(true) + .show(ui, |ui| { + ui.label("Backup directory:"); + ui.add( + egui::TextEdit::singleline(&mut self.settings_dir) + .desired_width(280.0) + .hint_text("/path/to/channels"), + ); + ui.end_row(); + + ui.label("Player command:"); + ui.add( + egui::TextEdit::singleline(&mut self.config.player.command) + .desired_width(280.0) + .hint_text("mpv"), + ); + ui.end_row(); + + ui.label("Theme:"); + egui::ComboBox::from_id_salt("theme_combo") + .selected_text( + theme::THEMES + .iter() + .find(|(id, _)| *id == self.config.ui.theme) + .map(|(_, label)| *label) + .unwrap_or("Dark"), + ) + .show_ui(ui, |ui| { + for (id, label) in theme::THEMES { + if ui + .selectable_label(self.config.ui.theme == *id, *label) + .clicked() + { + self.config.ui.theme = id.to_string(); + theme::apply(ctx, id); + } + } + }); + ui.end_row(); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + + ui.horizontal(|ui| { + if ui.button("Apply & Save").clicked() { + let new_dir = PathBuf::from(&self.settings_dir); + let dir_changed = new_dir != self.config.backup.directory; + self.config.backup.directory = new_dir.clone(); + match self.config.save(&self.config_path) { + Ok(_) => self.status = "Settings saved.".to_string(), + Err(e) => self.status = format!("Error saving config: {e}"), + } + if dir_changed { + self.channels_root = new_dir.clone(); + self.downloader.channels_root = new_dir; + let _ = std::fs::create_dir_all(&self.channels_root); + self.rescan(); + } + } + ui.label(egui::RichText::new("Theme previews immediately; other changes apply on save.").weak().small()); + }); + }); + self.show_settings = open; + } + + fn detail_panel(&mut self, ctx: &egui::Context) { + let selected_id = match &self.selected_video { + Some(id) => id.clone(), + None => return, }; - let Some(video) = self.library.get(ci).and_then(|c| c.videos.get(vi)).cloned() else { + let Some((video, _channel_name)) = self.find_video_by_id(&selected_id) else { self.selected_video = None; return; }; @@ -354,7 +520,9 @@ impl App { ui.label(egui::RichText::new("๐Ÿ’ฌ has live chat").small().weak()); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(egui::RichText::new(format!("id: {}", video.id)).monospace().weak()); + ui.label( + egui::RichText::new(format!("id: {}", video.id)).monospace().weak(), + ); if ui.button("โœ– close").clicked() { self.selected_video = None; } @@ -362,9 +530,11 @@ impl App { }); ui.separator(); let description = self.description(&video); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - ui.add(egui::Label::new(description).wrap()); - }); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add(egui::Label::new(description).wrap()); + }); }); } @@ -374,7 +544,9 @@ impl App { ui.horizontal(|ui| { ui.label(format!("{} videos", cards.len())); if !self.search.trim().is_empty() { - ui.label(egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak()); + ui.label( + egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak(), + ); } }); ui.separator(); @@ -395,11 +567,12 @@ impl App { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { let thumb_size = egui::vec2(176.0, 99.0); for card in &cards { - let selected = self.selected_video == Some((card.channel_idx, card.video_idx)); + let selected = self.selected_video.as_deref() == Some(card.id.as_str()); let mut clicked_card = false; let mut play_card = false; ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click()); + let (rect, resp) = + ui.allocate_exact_size(thumb_size, egui::Sense::click()); let texture = card .thumb_path .as_ref() @@ -411,7 +584,11 @@ impl App { .paint_at(ui, rect); } None => { - ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(38)); + ui.painter().rect_filled( + rect, + 4.0, + egui::Color32::from_gray(38), + ); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, @@ -437,14 +614,19 @@ impl App { ui.vertical(|ui| { if ui - .selectable_label(selected, egui::RichText::new(&card.title).strong()) + .selectable_label( + selected, + egui::RichText::new(&card.title).strong(), + ) .clicked() { clicked_card = true; } ui.horizontal(|ui| { if show_channel { - ui.label(egui::RichText::new(&card.channel_name).small().weak()); + ui.label( + egui::RichText::new(&card.channel_name).small().weak(), + ); ui.label(egui::RichText::new("ยท").weak()); } ui.label(egui::RichText::new(&card.id).small().monospace().weak()); @@ -473,9 +655,9 @@ impl App { if let Some(p) = card.video_path.clone() { self.play(&p); } - self.selected_video = Some((card.channel_idx, card.video_idx)); + self.selected_video = Some(card.id.clone()); } else if clicked_card { - self.selected_video = Some((card.channel_idx, card.video_idx)); + self.selected_video = Some(card.id.clone()); } ui.separator(); } @@ -489,7 +671,6 @@ impl eframe::App for App { if self.downloader.any_running() { ctx.request_repaint_after(std::time::Duration::from_millis(250)); } - // Decode at most a few thumbnails per frame so scrolling stays smooth. self.decode_budget = 6; self.top_bar(ctx); @@ -497,6 +678,7 @@ impl eframe::App for App { if self.show_downloads { self.downloads_panel(ctx); } + self.settings_window(ctx); self.detail_panel(ctx); egui::CentralPanel::default().show(ctx, |ui| { self.video_list(ctx, ui); @@ -510,11 +692,7 @@ fn decode_thumbnail(ctx: &egui::Context, path: &Path) -> Option String { diff --git a/src/config.rs b/src/config.rs index b9fa884..728ee26 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,8 @@ pub struct Config { pub backup: BackupSection, #[serde(default)] pub player: PlayerSection, + #[serde(default)] + pub ui: UiSection, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -19,13 +21,43 @@ pub struct PlayerSection { pub command: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UiSection { + #[serde(default = "default_theme")] + pub theme: String, +} + +impl Default for UiSection { + fn default() -> Self { + Self { theme: default_theme() } + } +} + fn default_player() -> String { "mpv".to_string() } +fn default_theme() -> String { + "dark".to_string() +} + impl Config { pub fn load(path: &Path) -> Result> { let contents = std::fs::read_to_string(path)?; toml::from_str(&contents).map_err(|e| e.into()) } + + pub fn save(&self, path: &Path) -> Result<(), Box> { + let contents = toml::to_string_pretty(self)?; + std::fs::write(path, contents)?; + Ok(()) + } + + pub fn default_with_dir(dir: PathBuf) -> Self { + Self { + backup: BackupSection { directory: dir }, + player: PlayerSection::default(), + ui: UiSection::default(), + } + } } diff --git a/src/downloader.rs b/src/downloader.rs index 3129845..d1c9ff0 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -6,6 +6,42 @@ use std::process::{Command, Stdio}; use std::sync::mpsc::{channel, Receiver}; use std::thread; +pub enum UrlKind { + Channel { handle: String }, + Playlist, + Video, + Unknown, +} + +/// Detects the kind of YouTube URL from a string. +pub fn detect_url_kind(url: &str) -> UrlKind { + if url.contains("playlist?list=") { + return UrlKind::Playlist; + } + if let Some(h) = extract_after(url, "/@") { + return UrlKind::Channel { handle: h.to_string() }; + } + if let Some(h) = extract_after(url, "/channel/") { + return UrlKind::Channel { handle: h.to_string() }; + } + if let Some(h) = extract_after(url, "/c/") { + return UrlKind::Channel { handle: h.to_string() }; + } + if url.contains("watch?v=") || url.contains("youtu.be/") { + return UrlKind::Video; + } + UrlKind::Unknown +} + +fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> { + let start = url.find(marker)? + marker.len(); + let rest = &url[start..]; + let end = rest + .find(|c| c == '/' || c == '?' || c == '&' || c == '#') + .unwrap_or(rest.len()); + if end == 0 { None } else { Some(&rest[..end]) } +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum JobState { Running, @@ -21,7 +57,7 @@ enum Msg { pub struct Job { pub url: String, - pub dir_name: String, + pub label: String, pub state: JobState, pub progress: f32, pub log: Vec, @@ -50,26 +86,39 @@ impl Job { pub struct Downloader { pub jobs: Vec, - channels_root: PathBuf, + pub channels_root: PathBuf, } impl Downloader { pub fn new(channels_root: PathBuf) -> Self { - Self { - jobs: Vec::new(), - channels_root, - } + Self { jobs: Vec::new(), channels_root } } - pub fn start(&mut self, url: String, dir_name: String) { + pub fn start(&mut self, url: String, kind: &UrlKind) { let (tx, rx) = channel(); - let target_dir = self.channels_root.join(&dir_name); - let _ = std::fs::create_dir_all(&target_dir); + let archive_path = self.channels_root.join("archive.txt"); + + let (out_arg, label) = match kind { + UrlKind::Channel { handle } => { + let dir = self.channels_root.join(handle); + let _ = std::fs::create_dir_all(&dir); + ( + format!("{}/%(title)s [%(id)s].%(ext)s", dir.display()), + format!("channels/{}/", handle), + ) + } + UrlKind::Playlist => ( + format!("{}/%(channel)s/%(playlist_title)s/%(title)s [%(id)s].%(ext)s", self.channels_root.display()), + "channels///".to_string(), + ), + UrlKind::Video | UrlKind::Unknown => ( + format!("{}/%(channel)s/%(title)s [%(id)s].%(ext)s", self.channels_root.display()), + "channels//".to_string(), + ), + }; let url_for_thread = url.clone(); - let archive_path = self.channels_root.join("archive.txt"); thread::spawn(move || { - let out_template = format!("{}/%(title)s [%(id)s].%(ext)s", target_dir.display()); let spawn_result = Command::new("yt-dlp") .arg("--newline") .arg("--no-color") @@ -87,7 +136,7 @@ impl Downloader { .arg(archive_path.display().to_string()) .arg("--ignore-errors") .arg("-o") - .arg(&out_template) + .arg(&out_arg) .arg(&url_for_thread) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -103,7 +152,6 @@ impl Downloader { } }; - // Forward stderr on its own thread so a full pipe can't deadlock stdout. if let Some(stderr) = child.stderr.take() { let tx = tx.clone(); thread::spawn(move || { @@ -128,7 +176,7 @@ impl Downloader { self.jobs.push(Job { url, - dir_name, + label, state: JobState::Running, progress: 0.0, log: Vec::new(), @@ -147,7 +195,6 @@ impl Downloader { } } -/// Parses the percentage out of a yt-dlp `[download] 12.3% of ...` line. fn parse_progress(line: &str) -> Option { let rest = line.trim_start().strip_prefix("[download]")?.trim_start(); let pct_end = rest.find('%')?; diff --git a/src/library.rs b/src/library.rs index 9c4d30e..e111da2 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,4 +1,4 @@ -//! Scanning the `channels/` directory tree into channels and videos. +//! Scanning the `channels/` directory tree into channels, playlists, and videos. //! //! yt-dlp's default output template produces files named `Title [VIDEOID].ext`, //! so every file that belongs to one video shares the stem `Title [VIDEOID]`. @@ -14,7 +14,6 @@ const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"]; pub struct Video { pub id: String, pub title: String, - /// The shared filename stem, e.g. `Title [VIDEOID]`. #[allow(dead_code)] pub stem: String, pub video_path: Option, @@ -23,11 +22,25 @@ pub struct Video { pub has_live_chat: bool, } +#[derive(Clone, Debug)] +pub struct Playlist { + pub name: String, + pub path: PathBuf, + pub videos: Vec