add themes, settings GUI, URL-aware download routing, and playlist view
This commit is contained in:
parent
bc06520bc8
commit
c11d1c3366
7 changed files with 649 additions and 136 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
[package]
|
[package]
|
||||||
name = "youtube-backup"
|
name = "yt-offline"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
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]
|
[dependencies]
|
||||||
eframe = "0.29"
|
eframe = "0.29"
|
||||||
|
|
|
||||||
366
src/app.rs
366
src/app.rs
|
|
@ -5,13 +5,11 @@ use std::process::Command;
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::downloader::{Downloader, JobState};
|
use crate::downloader::{detect_url_kind, Downloader, JobState, UrlKind};
|
||||||
use crate::library::{self, Channel, Video};
|
use crate::library::{self, Video};
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
/// Flattened, cheap-to-clone view of one video for a single frame of rendering.
|
|
||||||
struct Card {
|
struct Card {
|
||||||
channel_idx: usize,
|
|
||||||
video_idx: usize,
|
|
||||||
channel_name: String,
|
channel_name: String,
|
||||||
title: String,
|
title: String,
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -21,86 +19,128 @@ struct Card {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
|
config: Config,
|
||||||
|
config_path: PathBuf,
|
||||||
channels_root: PathBuf,
|
channels_root: PathBuf,
|
||||||
library: Vec<Channel>,
|
library: Vec<library::Channel>,
|
||||||
selected_channel: Option<usize>,
|
selected_channel: Option<usize>,
|
||||||
selected_video: Option<(usize, usize)>,
|
selected_playlist: Option<(usize, usize)>,
|
||||||
|
selected_video: Option<String>,
|
||||||
search: String,
|
search: String,
|
||||||
downloader: Downloader,
|
downloader: Downloader,
|
||||||
show_downloads: bool,
|
show_downloads: bool,
|
||||||
|
show_settings: bool,
|
||||||
dl_url: String,
|
dl_url: String,
|
||||||
dl_dir: String,
|
|
||||||
player_command: String,
|
|
||||||
/// Decoded thumbnails. `None` means "tried and failed / not loadable".
|
|
||||||
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
||||||
/// How many new thumbnails we're still allowed to decode this frame.
|
|
||||||
decode_budget: u32,
|
decode_budget: u32,
|
||||||
desc_cache: HashMap<PathBuf, String>,
|
desc_cache: HashMap<PathBuf, String>,
|
||||||
status: String,
|
status: String,
|
||||||
|
settings_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
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 cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
let config_path = cwd.join("config.toml");
|
let config_path = cwd.join("config.toml");
|
||||||
|
|
||||||
let (channels_root, player_command) = match Config::load(&config_path) {
|
let config = match Config::load(&config_path) {
|
||||||
Ok(config) => (config.backup.directory.clone(), config.player.command),
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Warning: failed to load config.toml: {e}. Using default channels directory.");
|
eprintln!("Warning: failed to load config.toml: {e}. Using defaults.");
|
||||||
(cwd.join("channels"), "mpv".to_string())
|
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 _ = 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 library = library::scan_channels(&channels_root);
|
||||||
let status = format!(
|
let status = format!(
|
||||||
"{} channels, {} videos",
|
"{} channels, {} videos",
|
||||||
library.len(),
|
library.len(),
|
||||||
library.iter().map(|c| c.videos.len()).sum::<usize>()
|
library.iter().map(|c| c.total_videos()).sum::<usize>()
|
||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
config,
|
||||||
|
config_path,
|
||||||
channels_root: channels_root.clone(),
|
channels_root: channels_root.clone(),
|
||||||
library,
|
library,
|
||||||
selected_channel: None,
|
selected_channel: None,
|
||||||
|
selected_playlist: None,
|
||||||
selected_video: None,
|
selected_video: None,
|
||||||
search: String::new(),
|
search: String::new(),
|
||||||
downloader: Downloader::new(channels_root),
|
downloader: Downloader::new(channels_root),
|
||||||
show_downloads: false,
|
show_downloads: false,
|
||||||
|
show_settings: false,
|
||||||
dl_url: String::new(),
|
dl_url: String::new(),
|
||||||
dl_dir: String::new(),
|
|
||||||
player_command,
|
|
||||||
textures: HashMap::new(),
|
textures: HashMap::new(),
|
||||||
decode_budget: 0,
|
decode_budget: 0,
|
||||||
desc_cache: HashMap::new(),
|
desc_cache: HashMap::new(),
|
||||||
status,
|
status,
|
||||||
|
settings_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rescan(&mut self) {
|
fn rescan(&mut self) {
|
||||||
self.library = library::scan_channels(&self.channels_root);
|
self.library = library::scan_channels(&self.channels_root);
|
||||||
self.selected_channel = None;
|
self.selected_channel = None;
|
||||||
|
self.selected_playlist = None;
|
||||||
self.selected_video = None;
|
self.selected_video = None;
|
||||||
self.desc_cache.clear();
|
self.desc_cache.clear();
|
||||||
|
self.textures.clear();
|
||||||
self.status = format!(
|
self.status = format!(
|
||||||
"Rescanned: {} channels, {} videos",
|
"Rescanned: {} channels, {} videos",
|
||||||
self.library.len(),
|
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> {
|
fn cards(&self) -> Vec<Card> {
|
||||||
let query = self.search.trim().to_lowercase();
|
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();
|
let mut cards = Vec::new();
|
||||||
for (ci, channel) in self.library.iter().enumerate() {
|
for ci in channel_indices {
|
||||||
if let Some(sel) = self.selected_channel {
|
let Some(channel) = self.library.get(ci) else { continue };
|
||||||
if sel != ci {
|
|
||||||
continue;
|
let playlist_filter = match self.selected_playlist {
|
||||||
}
|
Some((pci, pi)) if pci == ci => Some(pi),
|
||||||
}
|
_ => None,
|
||||||
for (vi, v) in channel.videos.iter().enumerate() {
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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()
|
if !query.is_empty()
|
||||||
&& !v.title.to_lowercase().contains(&query)
|
&& !v.title.to_lowercase().contains(&query)
|
||||||
&& !v.id.to_lowercase().contains(&query)
|
&& !v.id.to_lowercase().contains(&query)
|
||||||
|
|
@ -108,8 +148,6 @@ impl App {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
cards.push(Card {
|
cards.push(Card {
|
||||||
channel_idx: ci,
|
|
||||||
video_idx: vi,
|
|
||||||
channel_name: channel.name.clone(),
|
channel_name: channel.name.clone(),
|
||||||
title: v.title.clone(),
|
title: v.title.clone(),
|
||||||
id: v.id.clone(),
|
id: v.id.clone(),
|
||||||
|
|
@ -119,9 +157,24 @@ impl App {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
cards
|
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> {
|
fn texture(&mut self, ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
||||||
if let Some(slot) = self.textures.get(path) {
|
if let Some(slot) = self.textures.get(path) {
|
||||||
return slot.clone();
|
return slot.clone();
|
||||||
|
|
@ -150,7 +203,8 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(&mut self, path: &Path) {
|
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)),
|
Ok(_) => self.status = format!("Playing {}", file_label(path)),
|
||||||
Err(_) => match Command::new("xdg-open").arg(path).spawn() {
|
Err(_) => match Command::new("xdg-open").arg(path).spawn() {
|
||||||
Ok(_) => self.status = format!("Opened {} in default player", file_label(path)),
|
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) {
|
fn open_in_file_manager(&mut self, path: &Path) {
|
||||||
let target = if path.is_dir() {
|
let target = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
||||||
path
|
if let Err(e) = Command::new("xdg-open").arg(target).spawn() {
|
||||||
} else {
|
self.status = format!("Couldn't open folder: {e}");
|
||||||
path.parent().unwrap_or(path)
|
|
||||||
};
|
|
||||||
match Command::new("xdg-open").arg(target).spawn() {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => self.status = format!("Couldn't open folder: {e}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +224,7 @@ impl App {
|
||||||
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||||
ui.add_space(2.0);
|
ui.add_space(2.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.heading("YouTube Backup");
|
ui.heading("yt-offline");
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("🔍");
|
ui.label("🔍");
|
||||||
ui.add(
|
ui.add(
|
||||||
|
|
@ -190,14 +239,16 @@ impl App {
|
||||||
if ui.button("⟳ Rescan").clicked() {
|
if ui.button("⟳ Rescan").clicked() {
|
||||||
self.rescan();
|
self.rescan();
|
||||||
}
|
}
|
||||||
let dl_label = if self.show_downloads {
|
let dl_label = if self.show_downloads { "⬇ Downloads ▸" } else { "⬇ Downloads" };
|
||||||
"⬇ Downloads ▸"
|
|
||||||
} else {
|
|
||||||
"⬇ Downloads"
|
|
||||||
};
|
|
||||||
if ui.selectable_label(self.show_downloads, dl_label).clicked() {
|
if ui.selectable_label(self.show_downloads, dl_label).clicked() {
|
||||||
self.show_downloads = !self.show_downloads;
|
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.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.label(egui::RichText::new(&self.status).weak());
|
ui.label(egui::RichText::new(&self.status).weak());
|
||||||
});
|
});
|
||||||
|
|
@ -215,25 +266,51 @@ impl App {
|
||||||
ui.heading("Channels");
|
ui.heading("Channels");
|
||||||
ui.separator();
|
ui.separator();
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
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
|
if ui
|
||||||
.selectable_label(self.selected_channel.is_none(), format!("⊞ All ({total})"))
|
.selectable_label(
|
||||||
|
self.selected_channel.is_none(),
|
||||||
|
format!("⊞ All ({total})"),
|
||||||
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
self.selected_channel = None;
|
self.selected_channel = None;
|
||||||
|
self.selected_playlist = None;
|
||||||
self.selected_video = None;
|
self.selected_video = None;
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
for (i, channel) in self.library.iter().enumerate() {
|
for i in 0..self.library.len() {
|
||||||
let label = format!("{} ({})", channel.name, channel.videos.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
|
if ui
|
||||||
.selectable_label(self.selected_channel == Some(i), label)
|
.selectable_label(is_selected && self.selected_playlist.is_none(), label)
|
||||||
.on_hover_text(channel.path.display().to_string())
|
.on_hover_text(self.library[i].path.display().to_string())
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
self.selected_channel = Some(i);
|
self.selected_channel = Some(i);
|
||||||
|
self.selected_playlist = None;
|
||||||
self.selected_video = 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/…")
|
.hint_text("https://www.youtube.com/…")
|
||||||
.desired_width(f32::INFINITY),
|
.desired_width(f32::INFINITY),
|
||||||
);
|
);
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Save into channels/");
|
let kind = detect_url_kind(self.dl_url.trim());
|
||||||
ui.add(
|
let (type_label, dest_preview) = match &kind {
|
||||||
egui::TextEdit::singleline(&mut self.dl_dir)
|
UrlKind::Channel { handle } => {
|
||||||
.hint_text("folder_name")
|
("Channel", format!("→ channels/{}/", handle))
|
||||||
.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}");
|
|
||||||
}
|
}
|
||||||
|
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.separator();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.heading("Jobs");
|
ui.heading("Jobs");
|
||||||
|
|
@ -277,9 +365,7 @@ impl App {
|
||||||
&& !self.downloader.any_running()
|
&& !self.downloader.any_running()
|
||||||
&& ui.button("Clear finished").clicked()
|
&& ui.button("Clear finished").clicked()
|
||||||
{
|
{
|
||||||
self.downloader
|
self.downloader.jobs.retain(|j| j.state == JobState::Running);
|
||||||
.jobs
|
|
||||||
.retain(|j| j.state == JobState::Running);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if self.downloader.jobs.is_empty() {
|
if self.downloader.jobs.is_empty() {
|
||||||
|
|
@ -295,7 +381,7 @@ impl App {
|
||||||
ui.group(|ui| {
|
ui.group(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.colored_label(color, text);
|
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());
|
ui.label(egui::RichText::new(&job.url).small().weak());
|
||||||
if job.state == JobState::Running {
|
if job.state == JobState::Running {
|
||||||
|
|
@ -312,9 +398,7 @@ impl App {
|
||||||
.stick_to_bottom(true)
|
.stick_to_bottom(true)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for line in &job.log {
|
for line in &job.log {
|
||||||
ui.label(
|
ui.label(egui::RichText::new(line).small().monospace());
|
||||||
egui::RichText::new(line).small().monospace(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -324,11 +408,93 @@ impl App {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detail_panel(&mut self, ctx: &egui::Context) {
|
fn settings_window(&mut self, ctx: &egui::Context) {
|
||||||
let Some((ci, vi)) = self.selected_video else {
|
if !self.show_settings {
|
||||||
return;
|
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;
|
self.selected_video = None;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -354,7 +520,9 @@ impl App {
|
||||||
ui.label(egui::RichText::new("💬 has live chat").small().weak());
|
ui.label(egui::RichText::new("💬 has live chat").small().weak());
|
||||||
}
|
}
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
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() {
|
if ui.button("✖ close").clicked() {
|
||||||
self.selected_video = None;
|
self.selected_video = None;
|
||||||
}
|
}
|
||||||
|
|
@ -362,7 +530,9 @@ impl App {
|
||||||
});
|
});
|
||||||
ui.separator();
|
ui.separator();
|
||||||
let description = self.description(&video);
|
let description = self.description(&video);
|
||||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
ui.add(egui::Label::new(description).wrap());
|
ui.add(egui::Label::new(description).wrap());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -374,7 +544,9 @@ impl App {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(format!("{} videos", cards.len()));
|
ui.label(format!("{} videos", cards.len()));
|
||||||
if !self.search.trim().is_empty() {
|
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();
|
ui.separator();
|
||||||
|
|
@ -395,11 +567,12 @@ impl App {
|
||||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||||
let thumb_size = egui::vec2(176.0, 99.0);
|
let thumb_size = egui::vec2(176.0, 99.0);
|
||||||
for card in &cards {
|
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 clicked_card = false;
|
||||||
let mut play_card = false;
|
let mut play_card = false;
|
||||||
ui.horizontal(|ui| {
|
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
|
let texture = card
|
||||||
.thumb_path
|
.thumb_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -411,7 +584,11 @@ impl App {
|
||||||
.paint_at(ui, rect);
|
.paint_at(ui, rect);
|
||||||
}
|
}
|
||||||
None => {
|
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(
|
ui.painter().text(
|
||||||
rect.center(),
|
rect.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
|
|
@ -437,14 +614,19 @@ impl App {
|
||||||
|
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(selected, egui::RichText::new(&card.title).strong())
|
.selectable_label(
|
||||||
|
selected,
|
||||||
|
egui::RichText::new(&card.title).strong(),
|
||||||
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
clicked_card = true;
|
clicked_card = true;
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if show_channel {
|
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("·").weak());
|
||||||
}
|
}
|
||||||
ui.label(egui::RichText::new(&card.id).small().monospace().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() {
|
if let Some(p) = card.video_path.clone() {
|
||||||
self.play(&p);
|
self.play(&p);
|
||||||
}
|
}
|
||||||
self.selected_video = Some((card.channel_idx, card.video_idx));
|
self.selected_video = Some(card.id.clone());
|
||||||
} else if clicked_card {
|
} else if clicked_card {
|
||||||
self.selected_video = Some((card.channel_idx, card.video_idx));
|
self.selected_video = Some(card.id.clone());
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
}
|
}
|
||||||
|
|
@ -489,7 +671,6 @@ impl eframe::App for App {
|
||||||
if self.downloader.any_running() {
|
if self.downloader.any_running() {
|
||||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
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.decode_budget = 6;
|
||||||
|
|
||||||
self.top_bar(ctx);
|
self.top_bar(ctx);
|
||||||
|
|
@ -497,6 +678,7 @@ impl eframe::App for App {
|
||||||
if self.show_downloads {
|
if self.show_downloads {
|
||||||
self.downloads_panel(ctx);
|
self.downloads_panel(ctx);
|
||||||
}
|
}
|
||||||
|
self.settings_window(ctx);
|
||||||
self.detail_panel(ctx);
|
self.detail_panel(ctx);
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
self.video_list(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 rgba = image.to_rgba8();
|
||||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||||
let color_image = egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw());
|
let color_image = egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw());
|
||||||
Some(ctx.load_texture(
|
Some(ctx.load_texture(path.to_string_lossy(), color_image, egui::TextureOptions::LINEAR))
|
||||||
path.to_string_lossy(),
|
|
||||||
color_image,
|
|
||||||
egui::TextureOptions::LINEAR,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_label(path: &Path) -> String {
|
fn file_label(path: &Path) -> String {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ pub struct Config {
|
||||||
pub backup: BackupSection,
|
pub backup: BackupSection,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub player: PlayerSection,
|
pub player: PlayerSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ui: UiSection,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
|
@ -19,13 +21,43 @@ pub struct PlayerSection {
|
||||||
pub command: String,
|
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 {
|
fn default_player() -> String {
|
||||||
"mpv".to_string()
|
"mpv".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_theme() -> String {
|
||||||
|
"dark".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let contents = std::fs::read_to_string(path)?;
|
let contents = std::fs::read_to_string(path)?;
|
||||||
toml::from_str(&contents).map_err(|e| e.into())
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,42 @@ use std::process::{Command, Stdio};
|
||||||
use std::sync::mpsc::{channel, Receiver};
|
use std::sync::mpsc::{channel, Receiver};
|
||||||
use std::thread;
|
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)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum JobState {
|
pub enum JobState {
|
||||||
Running,
|
Running,
|
||||||
|
|
@ -21,7 +57,7 @@ enum Msg {
|
||||||
|
|
||||||
pub struct Job {
|
pub struct Job {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub dir_name: String,
|
pub label: String,
|
||||||
pub state: JobState,
|
pub state: JobState,
|
||||||
pub progress: f32,
|
pub progress: f32,
|
||||||
pub log: Vec<String>,
|
pub log: Vec<String>,
|
||||||
|
|
@ -50,26 +86,39 @@ impl Job {
|
||||||
|
|
||||||
pub struct Downloader {
|
pub struct Downloader {
|
||||||
pub jobs: Vec<Job>,
|
pub jobs: Vec<Job>,
|
||||||
channels_root: PathBuf,
|
pub channels_root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Downloader {
|
impl Downloader {
|
||||||
pub fn new(channels_root: PathBuf) -> Self {
|
pub fn new(channels_root: PathBuf) -> Self {
|
||||||
Self {
|
Self { jobs: Vec::new(), channels_root }
|
||||||
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 (tx, rx) = channel();
|
||||||
let target_dir = self.channels_root.join(&dir_name);
|
let archive_path = self.channels_root.join("archive.txt");
|
||||||
let _ = std::fs::create_dir_all(&target_dir);
|
|
||||||
|
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 url_for_thread = url.clone();
|
||||||
let archive_path = self.channels_root.join("archive.txt");
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let out_template = format!("{}/%(title)s [%(id)s].%(ext)s", target_dir.display());
|
|
||||||
let spawn_result = Command::new("yt-dlp")
|
let spawn_result = Command::new("yt-dlp")
|
||||||
.arg("--newline")
|
.arg("--newline")
|
||||||
.arg("--no-color")
|
.arg("--no-color")
|
||||||
|
|
@ -87,7 +136,7 @@ impl Downloader {
|
||||||
.arg(archive_path.display().to_string())
|
.arg(archive_path.display().to_string())
|
||||||
.arg("--ignore-errors")
|
.arg("--ignore-errors")
|
||||||
.arg("-o")
|
.arg("-o")
|
||||||
.arg(&out_template)
|
.arg(&out_arg)
|
||||||
.arg(&url_for_thread)
|
.arg(&url_for_thread)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.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() {
|
if let Some(stderr) = child.stderr.take() {
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
|
@ -128,7 +176,7 @@ impl Downloader {
|
||||||
|
|
||||||
self.jobs.push(Job {
|
self.jobs.push(Job {
|
||||||
url,
|
url,
|
||||||
dir_name,
|
label,
|
||||||
state: JobState::Running,
|
state: JobState::Running,
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
log: Vec::new(),
|
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> {
|
fn parse_progress(line: &str) -> Option<f32> {
|
||||||
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
|
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
|
||||||
let pct_end = rest.find('%')?;
|
let pct_end = rest.find('%')?;
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
//! 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]`.
|
//! 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 struct Video {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// The shared filename stem, e.g. `Title [VIDEOID]`.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub stem: String,
|
pub stem: String,
|
||||||
pub video_path: Option<PathBuf>,
|
pub video_path: Option<PathBuf>,
|
||||||
|
|
@ -23,11 +22,25 @@ pub struct Video {
|
||||||
pub has_live_chat: bool,
|
pub has_live_chat: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub videos: Vec<Video>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub videos: Vec<Video>,
|
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> {
|
pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
||||||
|
|
@ -41,8 +54,8 @@ pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
let videos = scan_channel_dir(&path);
|
let (videos, playlists) = scan_channel_dir(&path);
|
||||||
channels.push(Channel { name, path, videos });
|
channels.push(Channel { name, path, videos, playlists });
|
||||||
}
|
}
|
||||||
channels.sort_by_key(|c| c.name.to_lowercase());
|
channels.sort_by_key(|c| c.name.to_lowercase());
|
||||||
channels
|
channels
|
||||||
|
|
@ -56,10 +69,7 @@ enum FileKind {
|
||||||
Other,
|
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)> {
|
fn classify(file_name: &str) -> Option<(&str, FileKind)> {
|
||||||
// Compound suffixes first.
|
|
||||||
if let Some(stem) = file_name.strip_suffix(".live_chat.json") {
|
if let Some(stem) = file_name.strip_suffix(".live_chat.json") {
|
||||||
return Some((stem, FileKind::LiveChat));
|
return Some((stem, FileKind::LiveChat));
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +94,6 @@ fn classify(file_name: &str) -> Option<(&str, FileKind)> {
|
||||||
Some((stem, kind))
|
Some((stem, kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits `Title [VIDEOID]` into `(title, id)`. Requires a trailing `[...]` group.
|
|
||||||
fn parse_stem(stem: &str) -> Option<(String, String)> {
|
fn parse_stem(stem: &str) -> Option<(String, String)> {
|
||||||
let close = stem.rfind(']')?;
|
let close = stem.rfind(']')?;
|
||||||
let open = stem[..close].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()))
|
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 mut by_stem: BTreeMap<String, Video> = BTreeMap::new();
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
return Vec::new();
|
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.sort_by_key(|v| v.title.to_lowercase());
|
||||||
videos
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ mod config;
|
||||||
mod database;
|
mod database;
|
||||||
mod downloader;
|
mod downloader;
|
||||||
mod library;
|
mod library;
|
||||||
|
mod theme;
|
||||||
mod tray;
|
mod tray;
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
fn main() -> eframe::Result<()> {
|
||||||
|
|
@ -12,11 +13,11 @@ fn main() -> eframe::Result<()> {
|
||||||
viewport: eframe::egui::ViewportBuilder::default()
|
viewport: eframe::egui::ViewportBuilder::default()
|
||||||
.with_inner_size([1280.0, 820.0])
|
.with_inner_size([1280.0, 820.0])
|
||||||
.with_min_inner_size([800.0, 500.0])
|
.with_min_inner_size([800.0, 500.0])
|
||||||
.with_title("YouTube Backup"),
|
.with_title("yt-offline"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"youtube-backup",
|
"yt-offline",
|
||||||
native_options,
|
native_options,
|
||||||
Box::new(|cc| Ok(Box::new(app::App::new(cc)))),
|
Box::new(|cc| Ok(Box::new(app::App::new(cc)))),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
183
src/theme.rs
Normal file
183
src/theme.rs
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue