First version of youtube-backup, a tool to backup your YouTube channel.
This commit is contained in:
parent
acf188738a
commit
abf3af5768
13 changed files with 1612 additions and 20 deletions
17
src/app.rs
17
src/app.rs
|
|
@ -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
31
src/config.rs
Normal 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
51
src/database.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
36
src/tray.rs
Normal 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)?)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue