Add the Rust/egui app sources
- library.rs: scans channels/<name>/ into channels + videos by filename stem - downloader.rs: runs yt-dlp in a background thread, streams progress to the UI - app.rs / main.rs: channel sidebar, searchable thumbnail list, detail/description panel, downloads panel; plays videos via mpv (falls back to xdg-open) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ce503e50e2
commit
acf188738a
7 changed files with 5181 additions and 5 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -1,8 +1,9 @@
|
||||||
# Downloaded channel archives
|
# Downloaded channel archives (videos, thumbnails, descriptions)
|
||||||
/channels/
|
/channels/
|
||||||
|
|
||||||
# Node
|
# Rust build artifacts
|
||||||
node_modules/
|
/target/
|
||||||
dist/
|
|
||||||
*.log
|
# Editor / OS cruft
|
||||||
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
4341
Cargo.lock
generated
Normal file
4341
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "youtube-backup"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "A small yt-dlp front-end: browse downloaded channels and queue new downloads"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = "0.29"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["webp", "jpeg", "png"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 2
|
||||||
511
src/app.rs
Normal file
511
src/app.rs
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::downloader::{Downloader, JobState};
|
||||||
|
use crate::library::{self, Channel, Video};
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
video_path: Option<PathBuf>,
|
||||||
|
thumb_path: Option<PathBuf>,
|
||||||
|
has_live_chat: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
channels_root: PathBuf,
|
||||||
|
library: Vec<Channel>,
|
||||||
|
selected_channel: Option<usize>,
|
||||||
|
selected_video: Option<(usize, usize)>,
|
||||||
|
search: String,
|
||||||
|
downloader: Downloader,
|
||||||
|
show_downloads: bool,
|
||||||
|
dl_url: String,
|
||||||
|
dl_dir: 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 channels_root = cwd.join("channels");
|
||||||
|
let _ = std::fs::create_dir_all(&channels_root);
|
||||||
|
let library = library::scan_channels(&channels_root);
|
||||||
|
let status = format!(
|
||||||
|
"{} channels, {} videos",
|
||||||
|
library.len(),
|
||||||
|
library.iter().map(|c| c.videos.len()).sum::<usize>()
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
channels_root: channels_root.clone(),
|
||||||
|
library,
|
||||||
|
selected_channel: None,
|
||||||
|
selected_video: None,
|
||||||
|
search: String::new(),
|
||||||
|
downloader: Downloader::new(channels_root),
|
||||||
|
show_downloads: false,
|
||||||
|
dl_url: String::new(),
|
||||||
|
dl_dir: String::new(),
|
||||||
|
textures: HashMap::new(),
|
||||||
|
decode_budget: 0,
|
||||||
|
desc_cache: HashMap::new(),
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rescan(&mut self) {
|
||||||
|
self.library = library::scan_channels(&self.channels_root);
|
||||||
|
self.selected_channel = None;
|
||||||
|
self.selected_video = None;
|
||||||
|
self.desc_cache.clear();
|
||||||
|
self.status = format!(
|
||||||
|
"Rescanned: {} channels, {} videos",
|
||||||
|
self.library.len(),
|
||||||
|
self.library.iter().map(|c| c.videos.len()).sum::<usize>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cards(&self) -> Vec<Card> {
|
||||||
|
let query = self.search.trim().to_lowercase();
|
||||||
|
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 (vi, v) in channel.videos.iter().enumerate() {
|
||||||
|
if !query.is_empty()
|
||||||
|
&& !v.title.to_lowercase().contains(&query)
|
||||||
|
&& !v.id.to_lowercase().contains(&query)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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 texture(&mut self, ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
||||||
|
if let Some(slot) = self.textures.get(path) {
|
||||||
|
return slot.clone();
|
||||||
|
}
|
||||||
|
if self.decode_budget == 0 {
|
||||||
|
ctx.request_repaint();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.decode_budget -= 1;
|
||||||
|
let handle = decode_thumbnail(ctx, path);
|
||||||
|
self.textures.insert(path.to_path_buf(), handle.clone());
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&mut self, video: &Video) -> String {
|
||||||
|
let Some(path) = &video.description_path else {
|
||||||
|
return "(no description file)".to_string();
|
||||||
|
};
|
||||||
|
if let Some(text) = self.desc_cache.get(path) {
|
||||||
|
return text.clone();
|
||||||
|
}
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| format!("(could not read description: {e})"));
|
||||||
|
self.desc_cache.insert(path.clone(), text.clone());
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play(&mut self, path: &Path) {
|
||||||
|
match Command::new("mpv").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)),
|
||||||
|
Err(e) => self.status = format!("Couldn't open {}: {e}", file_label(path)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn top_bar(&mut self, ctx: &egui::Context) {
|
||||||
|
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||||
|
ui.add_space(2.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.heading("YouTube Backup");
|
||||||
|
ui.separator();
|
||||||
|
ui.label("🔍");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.search)
|
||||||
|
.hint_text("filter by title or id")
|
||||||
|
.desired_width(260.0),
|
||||||
|
);
|
||||||
|
if !self.search.is_empty() && ui.button("✖").on_hover_text("clear").clicked() {
|
||||||
|
self.search.clear();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("⟳ Rescan").clicked() {
|
||||||
|
self.rescan();
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
ui.label(egui::RichText::new(&self.status).weak());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(2.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_panel(&mut self, ctx: &egui::Context) {
|
||||||
|
egui::SidePanel::left("channels")
|
||||||
|
.resizable(true)
|
||||||
|
.default_width(230.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.heading("Channels");
|
||||||
|
ui.separator();
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
let total: usize = self.library.iter().map(|c| c.videos.len()).sum();
|
||||||
|
if ui
|
||||||
|
.selectable_label(self.selected_channel.is_none(), format!("⊞ All ({total})"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.selected_channel = None;
|
||||||
|
self.selected_video = None;
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
for (i, channel) in self.library.iter().enumerate() {
|
||||||
|
let label = format!("{} ({})", channel.name, channel.videos.len());
|
||||||
|
if ui
|
||||||
|
.selectable_label(self.selected_channel == Some(i), label)
|
||||||
|
.on_hover_text(channel.path.display().to_string())
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.selected_channel = Some(i);
|
||||||
|
self.selected_video = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn downloads_panel(&mut self, ctx: &egui::Context) {
|
||||||
|
egui::SidePanel::right("downloads")
|
||||||
|
.resizable(true)
|
||||||
|
.default_width(380.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.heading("New download");
|
||||||
|
ui.label("Video, playlist, or channel URL:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.dl_url)
|
||||||
|
.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}");
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.heading("Jobs");
|
||||||
|
if !self.downloader.jobs.is_empty()
|
||||||
|
&& !self.downloader.any_running()
|
||||||
|
&& ui.button("Clear finished").clicked()
|
||||||
|
{
|
||||||
|
self.downloader
|
||||||
|
.jobs
|
||||||
|
.retain(|j| j.state == JobState::Running);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if self.downloader.jobs.is_empty() {
|
||||||
|
ui.label(egui::RichText::new("Nothing queued yet.").weak());
|
||||||
|
}
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
for job in self.downloader.jobs.iter().rev() {
|
||||||
|
let (text, color) = match job.state {
|
||||||
|
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
|
||||||
|
JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 110)),
|
||||||
|
JobState::Failed => ("failed", egui::Color32::from_rgb(220, 110, 110)),
|
||||||
|
};
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(color, text);
|
||||||
|
ui.label(format!("→ channels/{}", job.dir_name));
|
||||||
|
});
|
||||||
|
ui.label(egui::RichText::new(&job.url).small().weak());
|
||||||
|
if job.state == JobState::Running {
|
||||||
|
ui.add(egui::ProgressBar::new(job.progress).show_percentage());
|
||||||
|
}
|
||||||
|
let last = job.log.last().map(String::as_str).unwrap_or("");
|
||||||
|
if !last.is_empty() {
|
||||||
|
ui.label(egui::RichText::new(last).small().monospace());
|
||||||
|
}
|
||||||
|
ui.collapsing("output log", |ui| {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(180.0)
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.stick_to_bottom(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for line in &job.log {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(line).small().monospace(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detail_panel(&mut self, ctx: &egui::Context) {
|
||||||
|
let Some((ci, vi)) = self.selected_video else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(video) = self.library.get(ci).and_then(|c| c.videos.get(vi)).cloned() else {
|
||||||
|
self.selected_video = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
egui::TopBottomPanel::bottom("detail")
|
||||||
|
.resizable(true)
|
||||||
|
.default_height(220.0)
|
||||||
|
.min_height(80.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.heading(&video.title);
|
||||||
|
if video.video_path.is_some() && ui.button("▶ Play").clicked() {
|
||||||
|
if let Some(p) = video.video_path.clone() {
|
||||||
|
self.play(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(p) = video.video_path.clone() {
|
||||||
|
if ui.button("📁 Show file").clicked() {
|
||||||
|
self.open_in_file_manager(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if video.has_live_chat {
|
||||||
|
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());
|
||||||
|
if ui.button("✖ close").clicked() {
|
||||||
|
self.selected_video = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
let description = self.description(&video);
|
||||||
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||||
|
ui.add(egui::Label::new(description).wrap());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||||
|
let cards = self.cards();
|
||||||
|
let show_channel = self.selected_channel.is_none();
|
||||||
|
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.separator();
|
||||||
|
if cards.is_empty() {
|
||||||
|
ui.add_space(20.0);
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.label(egui::RichText::new("Nothing here.").weak());
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Drop a yt-dlp download into channels/<name>/, or use the Downloads panel.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 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 texture = card
|
||||||
|
.thumb_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| self.texture(ctx, p));
|
||||||
|
match &texture {
|
||||||
|
Some(handle) => {
|
||||||
|
egui::Image::new(handle)
|
||||||
|
.maintain_aspect_ratio(true)
|
||||||
|
.paint_at(ui, rect);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(38));
|
||||||
|
ui.painter().text(
|
||||||
|
rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
"▶",
|
||||||
|
egui::FontId::proportional(26.0),
|
||||||
|
egui::Color32::from_gray(110),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
rect,
|
||||||
|
4.0,
|
||||||
|
egui::Stroke::new(2.0, egui::Color32::from_rgb(120, 170, 230)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if resp.clicked() {
|
||||||
|
clicked_card = true;
|
||||||
|
}
|
||||||
|
if resp.double_clicked() {
|
||||||
|
play_card = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
if ui
|
||||||
|
.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("·").weak());
|
||||||
|
}
|
||||||
|
ui.label(egui::RichText::new(&card.id).small().monospace().weak());
|
||||||
|
if card.has_live_chat {
|
||||||
|
ui.label(egui::RichText::new("· 💬").small().weak());
|
||||||
|
}
|
||||||
|
if card.video_path.is_none() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("· no video file")
|
||||||
|
.small()
|
||||||
|
.color(egui::Color32::from_rgb(200, 140, 90)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
|
||||||
|
play_card = true;
|
||||||
|
}
|
||||||
|
if ui.small_button("Details").clicked() {
|
||||||
|
clicked_card = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if play_card {
|
||||||
|
if let Some(p) = card.video_path.clone() {
|
||||||
|
self.play(&p);
|
||||||
|
}
|
||||||
|
self.selected_video = Some((card.channel_idx, card.video_idx));
|
||||||
|
} else if clicked_card {
|
||||||
|
self.selected_video = Some((card.channel_idx, card.video_idx));
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for App {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.downloader.poll();
|
||||||
|
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);
|
||||||
|
self.channel_panel(ctx);
|
||||||
|
if self.show_downloads {
|
||||||
|
self.downloads_panel(ctx);
|
||||||
|
}
|
||||||
|
self.detail_panel(ctx);
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
self.video_list(ctx, ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_thumbnail(ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
||||||
|
let image = image::open(path).ok()?;
|
||||||
|
let image = image.thumbnail(384, 216);
|
||||||
|
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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_label(path: &Path) -> String {
|
||||||
|
path.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
146
src/downloader.rs
Normal file
146
src/downloader.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
//! Running `yt-dlp` in the background and surfacing its progress to the UI.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::mpsc::{channel, Receiver};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum JobState {
|
||||||
|
Running,
|
||||||
|
Done,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Msg {
|
||||||
|
Line(String),
|
||||||
|
Progress(f32),
|
||||||
|
Finished(bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Job {
|
||||||
|
pub url: String,
|
||||||
|
pub dir_name: String,
|
||||||
|
pub state: JobState,
|
||||||
|
pub progress: f32,
|
||||||
|
pub log: Vec<String>,
|
||||||
|
rx: Receiver<Msg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job {
|
||||||
|
fn drain(&mut self) {
|
||||||
|
while let Ok(msg) = self.rx.try_recv() {
|
||||||
|
match msg {
|
||||||
|
Msg::Line(line) => {
|
||||||
|
self.log.push(line);
|
||||||
|
if self.log.len() > 800 {
|
||||||
|
let cut = self.log.len() - 800;
|
||||||
|
self.log.drain(0..cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::Progress(p) => self.progress = p,
|
||||||
|
Msg::Finished(ok) => {
|
||||||
|
self.state = if ok { JobState::Done } else { JobState::Failed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Downloader {
|
||||||
|
pub jobs: Vec<Job>,
|
||||||
|
channels_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader {
|
||||||
|
pub fn new(channels_root: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
jobs: Vec::new(),
|
||||||
|
channels_root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self, url: String, dir_name: String) {
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
let target_dir = self.channels_root.join(&dir_name);
|
||||||
|
let _ = std::fs::create_dir_all(&target_dir);
|
||||||
|
|
||||||
|
let url_for_thread = url.clone();
|
||||||
|
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")
|
||||||
|
.arg("--no-progress-bar")
|
||||||
|
.arg("--write-description")
|
||||||
|
.arg("--write-thumbnail")
|
||||||
|
.arg("--ignore-errors")
|
||||||
|
.arg("-o")
|
||||||
|
.arg(&out_template)
|
||||||
|
.arg(&url_for_thread)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn();
|
||||||
|
|
||||||
|
let mut child = match spawn_result {
|
||||||
|
Ok(child) => child,
|
||||||
|
Err(err) => {
|
||||||
|
let _ = tx.send(Msg::Line(format!("could not launch yt-dlp: {err}")));
|
||||||
|
let _ = tx.send(Msg::Finished(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 || {
|
||||||
|
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||||
|
let _ = tx.send(Msg::Line(format!("[stderr] {line}")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||||
|
if let Some(p) = parse_progress(&line) {
|
||||||
|
let _ = tx.send(Msg::Progress(p));
|
||||||
|
}
|
||||||
|
let _ = tx.send(Msg::Line(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = child.wait().map(|s| s.success()).unwrap_or(false);
|
||||||
|
let _ = tx.send(Msg::Finished(ok));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.jobs.push(Job {
|
||||||
|
url,
|
||||||
|
dir_name,
|
||||||
|
state: JobState::Running,
|
||||||
|
progress: 0.0,
|
||||||
|
log: Vec::new(),
|
||||||
|
rx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll(&mut self) {
|
||||||
|
for job in &mut self.jobs {
|
||||||
|
job.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn any_running(&self) -> bool {
|
||||||
|
self.jobs.iter().any(|j| j.state == JobState::Running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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('%')?;
|
||||||
|
let value: f32 = rest[..pct_end].trim().parse().ok()?;
|
||||||
|
Some((value / 100.0).clamp(0.0, 1.0))
|
||||||
|
}
|
||||||
145
src/library.rs
Normal file
145
src/library.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
//! Scanning the `channels/` directory tree into channels 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]`.
|
||||||
|
//! We group files by that stem.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"];
|
||||||
|
const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
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>,
|
||||||
|
pub thumb_path: Option<PathBuf>,
|
||||||
|
pub description_path: Option<PathBuf>,
|
||||||
|
pub has_live_chat: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Channel {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub videos: Vec<Video>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
||||||
|
let mut channels = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(root) else {
|
||||||
|
return channels;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let videos = scan_channel_dir(&path);
|
||||||
|
channels.push(Channel { name, path, videos });
|
||||||
|
}
|
||||||
|
channels.sort_by_key(|c| c.name.to_lowercase());
|
||||||
|
channels
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FileKind {
|
||||||
|
Video,
|
||||||
|
Thumb,
|
||||||
|
Description,
|
||||||
|
LiveChat,
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
if let Some(stem) = file_name.strip_suffix(".info.json") {
|
||||||
|
return Some((stem, FileKind::Other));
|
||||||
|
}
|
||||||
|
let dot = file_name.rfind('.')?;
|
||||||
|
let stem = &file_name[..dot];
|
||||||
|
if stem.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ext = file_name[dot + 1..].to_lowercase();
|
||||||
|
let kind = if VIDEO_EXTS.contains(&ext.as_str()) {
|
||||||
|
FileKind::Video
|
||||||
|
} else if THUMB_EXTS.contains(&ext.as_str()) {
|
||||||
|
FileKind::Thumb
|
||||||
|
} else if ext == "description" {
|
||||||
|
FileKind::Description
|
||||||
|
} else {
|
||||||
|
FileKind::Other
|
||||||
|
};
|
||||||
|
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('[')?;
|
||||||
|
let id = stem[open + 1..close].trim();
|
||||||
|
if id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let title = stem[..open].trim().trim_end_matches('-').trim();
|
||||||
|
let title = if title.is_empty() { stem } else { title };
|
||||||
|
Some((title.to_string(), id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_channel_dir(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();
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
videos
|
||||||
|
}
|
||||||
20
src/main.rs
Normal file
20
src/main.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod downloader;
|
||||||
|
mod library;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let native_options = eframe::NativeOptions {
|
||||||
|
viewport: eframe::egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([1280.0, 820.0])
|
||||||
|
.with_min_inner_size([800.0, 500.0])
|
||||||
|
.with_title("YouTube Backup"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
eframe::run_native(
|
||||||
|
"youtube-backup",
|
||||||
|
native_options,
|
||||||
|
Box::new(|cc| Ok(Box::new(app::App::new(cc)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue