add themes, settings GUI, URL-aware download routing, and playlist view

This commit is contained in:
Luna 2026-05-11 03:18:16 -07:00
parent bc06520bc8
commit c11d1c3366
7 changed files with 649 additions and 136 deletions

View file

@ -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"

View file

@ -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<Channel>,
library: Vec<library::Channel>,
selected_channel: Option<usize>,
selected_video: Option<(usize, usize)>,
selected_playlist: Option<(usize, usize)>,
selected_video: Option<String>,
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<PathBuf, Option<egui::TextureHandle>>,
/// How many new thumbnails we're still allowed to decode this frame.
decode_budget: u32,
desc_cache: HashMap<PathBuf, String>,
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::<usize>()
library.iter().map(|c| c.total_videos()).sum::<usize>()
);
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::<usize>()
self.library.iter().map(|c| c.total_videos()).sum::<usize>()
);
}
fn cards(&self) -> Vec<Card> {
let query = self.search.trim().to_lowercase();
let channel_indices: Vec<usize> = 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<egui::TextureHandle> {
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/<channel>/<playlist>/".to_string()),
UrlKind::Video => ("Video", "→ channels/<channel>/".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<egui::TextureHan
let rgba = image.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
let color_image = egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw());
Some(ctx.load_texture(
path.to_string_lossy(),
color_image,
egui::TextureOptions::LINEAR,
))
Some(ctx.load_texture(path.to_string_lossy(), color_image, egui::TextureOptions::LINEAR))
}
fn file_label(path: &Path) -> String {

View file

@ -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<Self, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(),
}
}
}

View file

@ -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<String>,
@ -50,26 +86,39 @@ impl Job {
pub struct Downloader {
pub jobs: Vec<Job>,
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/<channel>/<playlist>/".to_string(),
),
UrlKind::Video | UrlKind::Unknown => (
format!("{}/%(channel)s/%(title)s [%(id)s].%(ext)s", self.channels_root.display()),
"channels/<channel>/".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<f32> {
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
let pct_end = rest.find('%')?;

View file

@ -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<PathBuf>,
@ -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<Video>,
}
#[derive(Clone, Debug)]
pub struct Channel {
pub name: String,
pub path: PathBuf,
pub videos: Vec<Video>,
pub playlists: Vec<Playlist>,
}
impl Channel {
pub fn total_videos(&self) -> usize {
self.videos.len() + self.playlists.iter().map(|p| p.videos.len()).sum::<usize>()
}
}
pub fn scan_channels(root: &Path) -> Vec<Channel> {
@ -41,8 +54,8 @@ pub fn scan_channels(root: &Path) -> Vec<Channel> {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let videos = scan_channel_dir(&path);
channels.push(Channel { name, path, videos });
let (videos, playlists) = scan_channel_dir(&path);
channels.push(Channel { name, path, videos, playlists });
}
channels.sort_by_key(|c| c.name.to_lowercase());
channels
@ -56,10 +69,7 @@ enum FileKind {
Other,
}
/// Returns `(stem, kind)` for a file name, or `None` if it isn't a per-video file
/// (e.g. yt-dlp's `archive.txt`).
fn classify(file_name: &str) -> Option<(&str, FileKind)> {
// Compound suffixes first.
if let Some(stem) = file_name.strip_suffix(".live_chat.json") {
return Some((stem, FileKind::LiveChat));
}
@ -84,7 +94,6 @@ fn classify(file_name: &str) -> Option<(&str, FileKind)> {
Some((stem, kind))
}
/// Splits `Title [VIDEOID]` into `(title, id)`. Requires a trailing `[...]` group.
fn parse_stem(stem: &str) -> Option<(String, String)> {
let close = stem.rfind(']')?;
let open = stem[..close].rfind('[')?;
@ -97,7 +106,7 @@ fn parse_stem(stem: &str) -> Option<(String, String)> {
Some((title.to_string(), id.to_string()))
}
fn scan_channel_dir(dir: &Path) -> Vec<Video> {
pub fn scan_video_files(dir: &Path) -> Vec<Video> {
let mut by_stem: BTreeMap<String, Video> = BTreeMap::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
@ -143,3 +152,66 @@ fn scan_channel_dir(dir: &Path) -> Vec<Video> {
videos.sort_by_key(|v| v.title.to_lowercase());
videos
}
fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return (Vec::new(), Vec::new());
};
let mut file_entries = Vec::new();
let mut playlists = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().into_owned();
let videos = scan_video_files(&path);
if !videos.is_empty() {
playlists.push(Playlist { name, path, videos });
}
} else {
file_entries.push(entry);
}
}
let mut by_stem: BTreeMap<String, Video> = BTreeMap::new();
for entry in file_entries {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().into_owned();
let Some((stem, kind)) = classify(&file_name) else {
continue;
};
let Some((title, id)) = parse_stem(stem) else {
continue;
};
let video = by_stem.entry(stem.to_string()).or_insert_with(|| Video {
id,
title,
stem: stem.to_string(),
video_path: None,
thumb_path: None,
description_path: None,
has_live_chat: false,
});
match kind {
FileKind::Video => {
if video.video_path.is_none() {
video.video_path = Some(path);
}
}
FileKind::Thumb => {
if video.thumb_path.is_none() {
video.thumb_path = Some(path);
}
}
FileKind::Description => video.description_path = Some(path),
FileKind::LiveChat => video.has_live_chat = true,
FileKind::Other => {}
}
}
let mut videos: Vec<Video> = by_stem.into_values().collect();
videos.sort_by_key(|v| v.title.to_lowercase());
playlists.sort_by_key(|p| p.name.to_lowercase());
(videos, playlists)
}

View file

@ -5,6 +5,7 @@ mod config;
mod database;
mod downloader;
mod library;
mod theme;
mod tray;
fn main() -> eframe::Result<()> {
@ -12,11 +13,11 @@ fn main() -> eframe::Result<()> {
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([1280.0, 820.0])
.with_min_inner_size([800.0, 500.0])
.with_title("YouTube Backup"),
.with_title("yt-offline"),
..Default::default()
};
eframe::run_native(
"youtube-backup",
"yt-offline",
native_options,
Box::new(|cc| Ok(Box::new(app::App::new(cc)))),
)

183
src/theme.rs Normal file
View file

@ -0,0 +1,183 @@
use eframe::egui::{self, Color32, Stroke};
pub const THEMES: &[(&str, &str)] = &[
("dark", "Dark"),
("light", "Light"),
("dracula", "Dracula"),
("trans", "Trans"),
("emo-nocturnal", "Emo: Nocturnal"),
("emo-coffin", "Emo: Coffin"),
("emo-scene-queen", "Emo: Scene Queen"),
];
pub fn apply(ctx: &egui::Context, name: &str) {
let visuals = match name {
"light" => egui::Visuals::light(),
"dracula" => dracula(),
"trans" => trans(),
"emo-nocturnal" => emo_nocturnal(),
"emo-coffin" => emo_coffin(),
"emo-scene-queen" => emo_scene_queen(),
_ => egui::Visuals::dark(),
};
ctx.set_visuals(visuals);
}
fn hex(v: u32) -> Color32 {
Color32::from_rgb(((v >> 16) & 0xff) as u8, ((v >> 8) & 0xff) as u8, (v & 0xff) as u8)
}
fn dracula() -> egui::Visuals {
let mut v = egui::Visuals::dark();
v.panel_fill = hex(0x282a36);
v.window_fill = hex(0x282a36);
v.extreme_bg_color = hex(0x1e2029);
v.faint_bg_color = hex(0x343746);
v.code_bg_color = hex(0x1e2029);
v.selection.bg_fill = hex(0x44475a);
v.selection.stroke = Stroke::new(1.0, hex(0xbd93f9));
v.hyperlink_color = hex(0x8be9fd);
v.override_text_color = Some(hex(0xf8f8f2));
v.widgets.noninteractive.bg_fill = hex(0x343746);
v.widgets.noninteractive.weak_bg_fill = hex(0x2f3242);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xf8f8f2));
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x44475a));
v.widgets.inactive.bg_fill = hex(0x44475a);
v.widgets.inactive.weak_bg_fill = hex(0x3d4059);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xf8f8f2));
v.widgets.hovered.bg_fill = hex(0x6272a4);
v.widgets.hovered.weak_bg_fill = hex(0x5566a0);
v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xf8f8f2));
v.widgets.active.bg_fill = hex(0xbd93f9);
v.widgets.active.weak_bg_fill = hex(0xaa80f0);
v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x282a36));
v.widgets.open.bg_fill = hex(0x6272a4);
v.window_stroke = Stroke::new(1.0, hex(0x6272a4));
v
}
// Trans flag: light blue #55cdfc, pink #f7a8b8, white #ffffff
fn trans() -> egui::Visuals {
let mut v = egui::Visuals::light();
v.panel_fill = hex(0xe8f7fd);
v.window_fill = hex(0xfef0f4);
v.extreme_bg_color = hex(0xffffff);
v.faint_bg_color = hex(0xf5fbfe);
v.code_bg_color = hex(0xf0f9fe);
v.selection.bg_fill = hex(0x55cdfc);
v.selection.stroke = Stroke::new(1.0, hex(0x2288cc));
v.hyperlink_color = hex(0x0055aa);
v.override_text_color = Some(hex(0x222222));
v.widgets.noninteractive.bg_fill = hex(0xfce8f2);
v.widgets.noninteractive.weak_bg_fill = hex(0xfdf4f8);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x444444));
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xf7a8b8));
v.widgets.inactive.bg_fill = hex(0xf7a8b8);
v.widgets.inactive.weak_bg_fill = hex(0xfcccd8);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x333333));
v.widgets.hovered.bg_fill = hex(0x55cdfc);
v.widgets.hovered.weak_bg_fill = hex(0x88ddfd);
v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x111111));
v.widgets.active.bg_fill = hex(0x2288cc);
v.widgets.active.weak_bg_fill = hex(0x44aaee);
v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff));
v.widgets.open.bg_fill = hex(0xf7a8b8);
v.window_stroke = Stroke::new(1.0, hex(0xf7a8b8));
v
}
// Neon pink on black — 2000s club/Hot Topic energy
fn emo_nocturnal() -> egui::Visuals {
let mut v = egui::Visuals::dark();
v.panel_fill = hex(0x0a0a0a);
v.window_fill = hex(0x0d0d0d);
v.extreme_bg_color = hex(0x000000);
v.faint_bg_color = hex(0x111111);
v.code_bg_color = hex(0x050505);
v.selection.bg_fill = hex(0xff0090);
v.selection.stroke = Stroke::new(1.0, hex(0xff66c0));
v.hyperlink_color = hex(0x00f5ff);
v.override_text_color = Some(hex(0xe8e8e8));
v.widgets.noninteractive.bg_fill = hex(0x1a1a1a);
v.widgets.noninteractive.weak_bg_fill = hex(0x141414);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xe0e0e0));
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x2a2a2a));
v.widgets.inactive.bg_fill = hex(0x1f0018);
v.widgets.inactive.weak_bg_fill = hex(0x180013);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xd0d0d0));
v.widgets.hovered.bg_fill = hex(0x8b004d);
v.widgets.hovered.weak_bg_fill = hex(0x660038);
v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xff88cc));
v.widgets.active.bg_fill = hex(0xff0090);
v.widgets.active.weak_bg_fill = hex(0xcc0070);
v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff));
v.widgets.open.bg_fill = hex(0x8b004d);
v.window_stroke = Stroke::new(1.0, hex(0xff0090));
v.warn_fg_color = hex(0xffcc00);
v.error_fg_color = hex(0xff0090);
v
}
// Blood red on deep purple-black — cemetery goth
fn emo_coffin() -> egui::Visuals {
let mut v = egui::Visuals::dark();
v.panel_fill = hex(0x0d0009);
v.window_fill = hex(0x110010);
v.extreme_bg_color = hex(0x06000a);
v.faint_bg_color = hex(0x150014);
v.code_bg_color = hex(0x080008);
v.selection.bg_fill = hex(0x8b0000);
v.selection.stroke = Stroke::new(1.0, hex(0xcc2222));
v.hyperlink_color = hex(0xcc2222);
v.override_text_color = Some(hex(0xc0c0c0));
v.widgets.noninteractive.bg_fill = hex(0x1a0018);
v.widgets.noninteractive.weak_bg_fill = hex(0x140012);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xb0b0b0));
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x3a0030));
v.widgets.inactive.bg_fill = hex(0x230020);
v.widgets.inactive.weak_bg_fill = hex(0x1c0018);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xc0c0c0));
v.widgets.hovered.bg_fill = hex(0x5a0010);
v.widgets.hovered.weak_bg_fill = hex(0x440008);
v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xdd8888));
v.widgets.active.bg_fill = hex(0x8b0000);
v.widgets.active.weak_bg_fill = hex(0x700000);
v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xe0e0e0));
v.widgets.open.bg_fill = hex(0x5a0010);
v.window_stroke = Stroke::new(1.0, hex(0x8b0000));
v.warn_fg_color = hex(0xffaa00);
v.error_fg_color = hex(0xff3333);
v
}
// Neon lime and magenta on dark navy — MySpace/scene queen era
fn emo_scene_queen() -> egui::Visuals {
let mut v = egui::Visuals::dark();
v.panel_fill = hex(0x080818);
v.window_fill = hex(0x0a0a1e);
v.extreme_bg_color = hex(0x04040e);
v.faint_bg_color = hex(0x0d0d22);
v.code_bg_color = hex(0x060613);
v.selection.bg_fill = hex(0x39ff14);
v.selection.stroke = Stroke::new(1.0, hex(0x66ff44));
v.hyperlink_color = hex(0xff00ff);
v.override_text_color = Some(hex(0xd0d0ff));
v.widgets.noninteractive.bg_fill = hex(0x111128);
v.widgets.noninteractive.weak_bg_fill = hex(0x0d0d20);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc8c8ff));
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x222244));
v.widgets.inactive.bg_fill = hex(0x0d1a0a);
v.widgets.inactive.weak_bg_fill = hex(0x0a1408);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xc0c0f0));
v.widgets.hovered.bg_fill = hex(0x1a3d12);
v.widgets.hovered.weak_bg_fill = hex(0x14300e);
v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x88ff66));
v.widgets.active.bg_fill = hex(0x39ff14);
v.widgets.active.weak_bg_fill = hex(0x2acc10);
v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x080818));
v.widgets.open.bg_fill = hex(0x1a3d12);
v.window_stroke = Stroke::new(1.0, hex(0x39ff14));
v.warn_fg_color = hex(0xffcc00);
v.error_fg_color = hex(0xff4444);
v
}