First version of youtube-backup, a tool to backup your YouTube channel.

This commit is contained in:
Luna 2026-05-11 02:33:52 -07:00
parent acf188738a
commit abf3af5768
13 changed files with 1612 additions and 20 deletions

View file

@ -4,6 +4,7 @@ use std::process::Command;
use eframe::egui;
use crate::config::Config;
use crate::downloader::{Downloader, JobState};
use crate::library::{self, Channel, Video};
@ -29,6 +30,7 @@ pub struct App {
show_downloads: 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.
@ -41,8 +43,18 @@ 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 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),
Err(e) => {
eprintln!("Warning: failed to load config.toml: {e}. Using default channels directory.");
(cwd.join("channels"), "mpv".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",
@ -59,6 +71,7 @@ impl App {
show_downloads: false,
dl_url: String::new(),
dl_dir: String::new(),
player_command,
textures: HashMap::new(),
decode_budget: 0,
desc_cache: HashMap::new(),
@ -137,7 +150,7 @@ impl App {
}
fn play(&mut self, path: &Path) {
match Command::new("mpv").arg(path).spawn() {
match Command::new(&self.player_command).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)),

31
src/config.rs Normal file
View file

@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub backup: BackupSection,
#[serde(default)]
pub player: PlayerSection,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BackupSection {
pub directory: PathBuf,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct PlayerSection {
#[serde(default = "default_player")]
pub command: String,
}
fn default_player() -> String {
"mpv".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())
}
}

51
src/database.rs Normal file
View file

@ -0,0 +1,51 @@
use rusqlite::{Connection, Result};
use std::path::Path;
pub struct Database {
conn: Connection,
}
impl Database {
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
let db = Database { conn };
db.init_schema()?;
Ok(db)
}
fn init_schema(&self) -> Result<()> {
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
title TEXT NOT NULL,
downloaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL,
directory TEXT NOT NULL,
status TEXT NOT NULL,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);",
)?;
Ok(())
}
pub fn record_download(&self, url: &str, dir: &str, status: &str) -> Result<()> {
self.conn.execute(
"INSERT INTO downloads (url, directory, status) VALUES (?1, ?2, ?3)",
[url, dir, status],
)?;
Ok(())
}
pub fn update_download_status(&self, id: i64, status: &str) -> Result<()> {
self.conn.execute(
"UPDATE downloads SET status = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
[status, &id.to_string()],
)?;
Ok(())
}
}

View file

@ -67,14 +67,24 @@ impl Downloader {
let _ = std::fs::create_dir_all(&target_dir);
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")
.arg("--no-progress-bar")
.arg("--write-description")
.arg("--write-subs")
.arg("--write-thumbnail")
.arg("--write-description")
.arg("-f")
.arg("mkv")
.arg("--embed-metadata")
.arg("--break-on-existing")
.arg("--cookies-from-browser")
.arg("firefox")
.arg("--download-archive")
.arg(archive_path.display().to_string())
.arg("--ignore-errors")
.arg("-o")
.arg(&out_template)

View file

@ -1,8 +1,11 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;
mod config;
mod database;
mod downloader;
mod library;
mod tray;
fn main() -> eframe::Result<()> {
let native_options = eframe::NativeOptions {

36
src/tray.rs Normal file
View file

@ -0,0 +1,36 @@
use tray_icon::{TrayIconBuilder, menu::Menu};
use std::path::PathBuf;
pub fn create_tray_icon(icon_path: Option<PathBuf>) -> Result<tray_icon::TrayIcon, Box<dyn std::error::Error>> {
let menu = Menu::new();
let icon = if let Some(path) = icon_path {
load_icon_from_file(&path)?
} else {
create_default_icon()?
};
let tray = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip("YouTube Backup\nDownload and manage YouTube channel backups")
.with_icon(icon)
.build()?;
Ok(tray)
}
fn load_icon_from_file(path: &PathBuf) -> Result<tray_icon::Icon, Box<dyn std::error::Error>> {
let img = image::open(path)?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
Ok(tray_icon::Icon::from_rgba(rgba.into_raw(), w, h)?)
}
fn create_default_icon() -> Result<tray_icon::Icon, Box<dyn std::error::Error>> {
let mut rgba = vec![0u8; 64 * 64 * 4];
for chunk in rgba.chunks_mut(4) {
chunk[0] = 255; // red
chunk[3] = 255; // alpha
}
Ok(tray_icon::Icon::from_rgba(rgba, 64, 64)?)
}