Major update with lots of new features and fixes
This commit is contained in:
parent
17c149c21a
commit
74b3efd990
16 changed files with 1648 additions and 904 deletions
158
src/app.rs
158
src/app.rs
|
|
@ -1,7 +1,13 @@
|
|||
//! egui desktop application — the main window, sidebar, video grid, and settings UI.
|
||||
//!
|
||||
//! The [`App`] struct holds all UI state and implements [`eframe::App`].
|
||||
//! Background work (downloads, scheduled rescans) runs in threads and
|
||||
//! communicates back via `mpsc` channels stored on `App`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui;
|
||||
|
|
@ -65,7 +71,9 @@ pub struct App {
|
|||
show_settings: bool,
|
||||
dl_url: String,
|
||||
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
||||
decode_budget: u32,
|
||||
thumb_request_tx: Sender<PathBuf>,
|
||||
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
|
||||
thumb_pending: HashSet<PathBuf>,
|
||||
desc_cache: HashMap<PathBuf, String>,
|
||||
status: String,
|
||||
settings_dir: String,
|
||||
|
|
@ -82,6 +90,13 @@ pub struct App {
|
|||
bulk_selected: HashSet<String>,
|
||||
// Scheduler
|
||||
last_scheduled_check: Option<Instant>,
|
||||
// Cards cache — recomputed only when inputs change
|
||||
cards_cache: Vec<Card>,
|
||||
cards_cache_key: Option<(String, SortMode, SidebarView, u64)>,
|
||||
library_generation: u64,
|
||||
// Web server
|
||||
web_server_running: bool,
|
||||
web_server_shutdown: Option<Sender<()>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -117,6 +132,20 @@ impl App {
|
|||
|
||||
let browser = config.player.browser.clone();
|
||||
|
||||
let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
|
||||
let (thumb_result_tx, thumb_result_rx) =
|
||||
std::sync::mpsc::channel::<(PathBuf, Option<egui::ColorImage>)>();
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(path) = thumb_request_rx.recv() {
|
||||
let img = decode_thumbnail_image(&path);
|
||||
if thumb_result_tx.send((path, img)).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
config,
|
||||
config_path,
|
||||
|
|
@ -130,7 +159,9 @@ impl App {
|
|||
show_settings: false,
|
||||
dl_url: String::new(),
|
||||
textures: HashMap::new(),
|
||||
decode_budget: 0,
|
||||
thumb_request_tx,
|
||||
thumb_result_rx,
|
||||
thumb_pending: HashSet::new(),
|
||||
desc_cache: HashMap::new(),
|
||||
status,
|
||||
settings_dir,
|
||||
|
|
@ -145,6 +176,11 @@ impl App {
|
|||
bulk_mode: false,
|
||||
bulk_selected: HashSet::new(),
|
||||
last_scheduled_check: None,
|
||||
cards_cache: Vec::new(),
|
||||
cards_cache_key: None,
|
||||
library_generation: 0,
|
||||
web_server_running: false,
|
||||
web_server_shutdown: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +197,21 @@ impl App {
|
|||
);
|
||||
}
|
||||
|
||||
fn cards(&self) -> Vec<Card> {
|
||||
fn cards_take(&mut self) -> Vec<Card> {
|
||||
let key = (
|
||||
self.search.clone(),
|
||||
self.sort_mode.clone(),
|
||||
self.sidebar_view.clone(),
|
||||
self.library_generation,
|
||||
);
|
||||
if self.cards_cache_key.as_ref() != Some(&key) {
|
||||
self.cards_cache = self.compute_cards();
|
||||
self.cards_cache_key = Some(key);
|
||||
}
|
||||
std::mem::take(&mut self.cards_cache)
|
||||
}
|
||||
|
||||
fn compute_cards(&self) -> Vec<Card> {
|
||||
let query = self.search.trim().to_lowercase();
|
||||
|
||||
let mut cards = Vec::new();
|
||||
|
|
@ -265,18 +315,15 @@ impl App {
|
|||
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) {
|
||||
return slot.clone();
|
||||
}
|
||||
if self.decode_budget == 0 {
|
||||
ctx.request_repaint();
|
||||
return None;
|
||||
let pb = path.to_path_buf();
|
||||
if self.thumb_pending.insert(pb.clone()) {
|
||||
let _ = self.thumb_request_tx.send(pb);
|
||||
}
|
||||
self.decode_budget -= 1;
|
||||
let handle = decode_thumbnail(ctx, path);
|
||||
self.textures.insert(path.to_path_buf(), handle.clone());
|
||||
handle
|
||||
None
|
||||
}
|
||||
|
||||
fn description(&mut self, video: &Video) -> String {
|
||||
|
|
@ -365,6 +412,28 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn start_web_server(&mut self) {
|
||||
if self.web_server_running {
|
||||
return;
|
||||
}
|
||||
let shutdown = crate::web::run_with_shutdown(self.config.clone());
|
||||
self.web_server_shutdown = Some(shutdown);
|
||||
self.web_server_running = true;
|
||||
let port = self.config.web.port;
|
||||
self.status = format!("Web server started on port {port}. Access at http://localhost:{port}");
|
||||
}
|
||||
|
||||
fn stop_web_server(&mut self) {
|
||||
if !self.web_server_running {
|
||||
return;
|
||||
}
|
||||
if let Some(shutdown) = self.web_server_shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
self.web_server_running = false;
|
||||
self.status = "Web server stopped.".to_string();
|
||||
}
|
||||
|
||||
fn bulk_mark_watched(&mut self, watched: bool) {
|
||||
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
|
||||
for id in &ids {
|
||||
|
|
@ -429,10 +498,7 @@ impl App {
|
|||
}
|
||||
|
||||
fn channel_total_size(ch: &library::Channel) -> u64 {
|
||||
ch.videos.iter()
|
||||
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.filter_map(|v| v.file_size)
|
||||
.sum()
|
||||
ch.total_size_cached
|
||||
}
|
||||
|
||||
fn top_bar(&mut self, ctx: &egui::Context) {
|
||||
|
|
@ -521,14 +587,24 @@ impl App {
|
|||
let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name)
|
||||
|
||||
for i in 0..self.library.len() {
|
||||
let (name, total, has_playlists, size_bytes, channel_url) = {
|
||||
let (name, total, has_playlists, size_bytes, channel_url, url_inferred) = {
|
||||
let ch = &self.library[i];
|
||||
let meta_url = ch.meta.as_ref().and_then(|m| m.channel_url.clone());
|
||||
let (url, inferred) = if let Some(u) = meta_url {
|
||||
(Some(u), false)
|
||||
} else {
|
||||
let folder_url = ch.path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| format!("https://www.youtube.com/@{n}"));
|
||||
(folder_url, true)
|
||||
};
|
||||
(
|
||||
ch.name.clone(),
|
||||
ch.total_videos(),
|
||||
!ch.playlists.is_empty(),
|
||||
Self::channel_total_size(ch),
|
||||
ch.meta.as_ref().and_then(|m| m.channel_url.clone()),
|
||||
url,
|
||||
inferred,
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -555,13 +631,14 @@ impl App {
|
|||
let name_for_menu = name.clone();
|
||||
resp.context_menu(|ui| {
|
||||
if let Some(ref url) = url_for_menu {
|
||||
if ui.button("⬇ Check for new videos").clicked() {
|
||||
let mut btn = ui.button("⬇ Check for new videos");
|
||||
if url_inferred {
|
||||
btn = btn.on_hover_text(format!("URL inferred from folder name:\n{url}"));
|
||||
}
|
||||
if btn.clicked() {
|
||||
pending_ch_download = Some((url.clone(), name_for_menu.clone()));
|
||||
ui.close_menu();
|
||||
}
|
||||
} else {
|
||||
ui.add_enabled(false, egui::Button::new("⬇ Check for new videos"))
|
||||
.on_hover_text("Download this channel first to store its URL");
|
||||
}
|
||||
if ui.button("📁 Open folder").clicked() {
|
||||
let path = self.library[i].path.clone();
|
||||
|
|
@ -786,6 +863,22 @@ impl App {
|
|||
.range(1024..=65535),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Web server:");
|
||||
ui.horizontal(|ui| {
|
||||
if self.web_server_running {
|
||||
if ui.button("🛑 Stop").clicked() {
|
||||
self.stop_web_server();
|
||||
}
|
||||
ui.label(egui::RichText::new("Running").small().weak());
|
||||
} else {
|
||||
if ui.button("▶ Start").clicked() {
|
||||
self.start_web_server();
|
||||
}
|
||||
ui.label(egui::RichText::new("Stopped").small().weak());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
|
@ -911,7 +1004,7 @@ impl App {
|
|||
}
|
||||
|
||||
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||
let cards = self.cards();
|
||||
let cards = self.cards_take();
|
||||
let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _));
|
||||
|
||||
// Channel metadata banner
|
||||
|
|
@ -1181,6 +1274,7 @@ impl App {
|
|||
ui.separator();
|
||||
}
|
||||
});
|
||||
self.cards_cache = cards;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1221,7 +1315,18 @@ impl eframe::App for App {
|
|||
}
|
||||
}
|
||||
|
||||
self.decode_budget = 6;
|
||||
// Drain any decoded thumbnails from the worker thread
|
||||
while let Ok((path, img)) = self.thumb_result_rx.try_recv() {
|
||||
self.thumb_pending.remove(&path);
|
||||
let handle = img.map(|color_image| {
|
||||
ctx.load_texture(
|
||||
path.to_string_lossy(),
|
||||
color_image,
|
||||
egui::TextureOptions::LINEAR,
|
||||
)
|
||||
});
|
||||
self.textures.insert(path, handle);
|
||||
}
|
||||
|
||||
self.top_bar(ctx);
|
||||
self.channel_panel(ctx);
|
||||
|
|
@ -1286,13 +1391,12 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
|
|||
}
|
||||
}
|
||||
|
||||
fn decode_thumbnail(ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
||||
fn decode_thumbnail_image(path: &Path) -> Option<egui::ColorImage> {
|
||||
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))
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()))
|
||||
}
|
||||
|
||||
fn file_label(path: &Path) -> String {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
//! Application configuration loaded from `config.toml`.
|
||||
//!
|
||||
//! Each top-level section maps to a TOML table. Missing sections get sane
|
||||
//! defaults so existing config files continue to work after upgrades.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Root configuration object, serialised from/to `config.toml`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
pub backup: BackupSection,
|
||||
|
|
@ -14,11 +20,13 @@ pub struct Config {
|
|||
pub web: WebSection,
|
||||
}
|
||||
|
||||
/// `[backup]` table — where to store downloaded videos.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BackupSection {
|
||||
pub directory: PathBuf,
|
||||
}
|
||||
|
||||
/// `[player]` table — external player and browser cookie source.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PlayerSection {
|
||||
#[serde(default = "default_player")]
|
||||
|
|
@ -33,6 +41,7 @@ impl Default for PlayerSection {
|
|||
}
|
||||
}
|
||||
|
||||
/// `[ui]` table — egui desktop theme.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UiSection {
|
||||
#[serde(default = "default_theme")]
|
||||
|
|
@ -45,6 +54,7 @@ impl Default for UiSection {
|
|||
}
|
||||
}
|
||||
|
||||
/// `[scheduler]` table — periodic background download schedule.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SchedulerSection {
|
||||
#[serde(default)]
|
||||
|
|
@ -59,15 +69,33 @@ impl Default for SchedulerSection {
|
|||
}
|
||||
}
|
||||
|
||||
/// `[web]` table — built-in HTTP server settings.
|
||||
///
|
||||
/// `source_url` is **required for AGPL §13 compliance**: set it to a URL
|
||||
/// where the running source code can be obtained (e.g. your Codeberg repo).
|
||||
/// It is shown as a "Source" link in the web UI footer.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct WebSection {
|
||||
#[serde(default = "default_web_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_web_bind")]
|
||||
/// Address to bind the HTTP server to. Defaults to `127.0.0.1` (localhost only).
|
||||
/// Set to `0.0.0.0` to accept connections from any interface (not recommended).
|
||||
pub bind: String,
|
||||
#[serde(default)]
|
||||
pub transcode: bool,
|
||||
/// Public URL to the source repository, shown in the web UI per AGPL §13.
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for WebSection {
|
||||
fn default() -> Self {
|
||||
Self { port: default_web_port() }
|
||||
Self {
|
||||
port: default_web_port(),
|
||||
bind: default_web_bind(),
|
||||
transcode: false,
|
||||
source_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,19 +104,23 @@ fn default_browser() -> String { "firefox".to_string() }
|
|||
fn default_theme() -> String { "dark".to_string() }
|
||||
fn default_interval_hours() -> u32 { 24 }
|
||||
fn default_web_port() -> u16 { 8080 }
|
||||
fn default_web_bind() -> String { "127.0.0.1".to_string() }
|
||||
|
||||
impl Config {
|
||||
/// Load and parse `config.toml` from `path`.
|
||||
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())
|
||||
}
|
||||
|
||||
/// Serialise the config back to `path` in pretty TOML.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Construct a minimal default config pointing `backup.directory` at `dir`.
|
||||
pub fn default_with_dir(dir: PathBuf) -> Self {
|
||||
Self {
|
||||
backup: BackupSection { directory: dir },
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
//! Persistent storage for watched status and playback positions.
|
||||
//!
|
||||
//! Uses a bundled SQLite database (`yt-offline.db` by default).
|
||||
//!
|
||||
//! # Schema
|
||||
//!
|
||||
//! | Table | Columns | Purpose |
|
||||
//! |---|---|---|
|
||||
//! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched |
|
||||
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
|
||||
|
||||
use rusqlite::{Connection, Result};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
/// Thin wrapper around a SQLite connection with schema management.
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open or create the database at `path`, running schema migrations.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
let db = Database { conn };
|
||||
|
|
@ -14,6 +27,7 @@ impl Database {
|
|||
Ok(db)
|
||||
}
|
||||
|
||||
/// Open an in-memory database — used in tests.
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Database { conn };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,27 @@
|
|||
//! Running `yt-dlp` in the background and surfacing its progress to the UI.
|
||||
//!
|
||||
//! Each call to [`Downloader::start`] spawns a thread that runs `yt-dlp` and
|
||||
//! pipes stdout/stderr back to the main thread through an `mpsc` channel.
|
||||
//! The caller polls for updates via [`Downloader::poll`], which drains the
|
||||
//! channel into each [`Job`]'s log buffer.
|
||||
//!
|
||||
//! # yt-dlp command flags used
|
||||
//!
|
||||
//! | Flag | Purpose |
|
||||
//! |---|---|
|
||||
//! | `--cookies cookies.txt` | Pass browser cookies for age-gated/member videos |
|
||||
//! | `--write-subs --write-auto-subs` | Download subtitles alongside the video |
|
||||
//! | `--write-thumbnail` | Download channel/video thumbnails |
|
||||
//! | `--write-description` | Save video description as a sidecar `.description` file |
|
||||
//! | `--write-info-json` | Save full metadata as a `.info.json` sidecar |
|
||||
//! | `--remux-video mkv` | Re-container to MKV (no re-encode) |
|
||||
//! | `--embed-metadata --embed-info-json --embed-chapters` | Embed rich metadata into the MKV |
|
||||
//! | `--xattrs` | Store metadata in filesystem extended attributes |
|
||||
//! | `--sponsorblock-mark all` | Mark (but don't remove) SponsorBlock segments |
|
||||
//! | `--extractor-args youtube:player_client=web` | Use the web player API to avoid throttling |
|
||||
//! | `--impersonate Chrome-146:Macos-26` | Impersonate a real browser for bot detection |
|
||||
//! | `--break-on-existing` | Stop when the archive file records the video as already downloaded |
|
||||
//! | `--download-archive archive.txt` | Record downloaded IDs to avoid re-downloading |
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -6,13 +29,17 @@ use std::process::{Command, Stdio};
|
|||
use std::sync::mpsc::{channel, Receiver};
|
||||
use std::thread;
|
||||
|
||||
/// Describes the kind of YouTube URL being downloaded, which determines the
|
||||
/// output path template passed to yt-dlp.
|
||||
pub enum UrlKind {
|
||||
/// A channel URL (`/@handle`, `/channel/ID`, or `/c/name`).
|
||||
Channel { handle: String },
|
||||
Playlist,
|
||||
Video,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Classify a YouTube URL into a [`UrlKind`] by inspecting its path.
|
||||
pub fn detect_url_kind(url: &str) -> UrlKind {
|
||||
if url.contains("playlist?list=") {
|
||||
return UrlKind::Playlist;
|
||||
|
|
@ -39,6 +66,7 @@ fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> {
|
|||
if end == 0 { None } else { Some(&rest[..end]) }
|
||||
}
|
||||
|
||||
/// Lifecycle state of a download job.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JobState {
|
||||
Running,
|
||||
|
|
@ -46,17 +74,22 @@ pub enum JobState {
|
|||
Failed,
|
||||
}
|
||||
|
||||
/// Internal message sent from the yt-dlp thread to the job.
|
||||
enum Msg {
|
||||
Line(String),
|
||||
Progress(f32),
|
||||
Finished(bool),
|
||||
}
|
||||
|
||||
/// A single yt-dlp invocation tracked by the downloader.
|
||||
pub struct Job {
|
||||
pub url: String,
|
||||
/// Short human-readable path shown in the UI (e.g. `channels/handle/`).
|
||||
pub label: String,
|
||||
pub state: JobState,
|
||||
/// Download progress as a fraction in `[0.0, 1.0]`.
|
||||
pub progress: f32,
|
||||
/// Rolling log buffer — capped at 800 lines to avoid unbounded growth.
|
||||
pub log: Vec<String>,
|
||||
rx: Receiver<Msg>,
|
||||
}
|
||||
|
|
@ -81,9 +114,12 @@ impl Job {
|
|||
}
|
||||
}
|
||||
|
||||
/// Manages all active and recently completed yt-dlp download jobs.
|
||||
pub struct Downloader {
|
||||
pub jobs: Vec<Job>,
|
||||
pub channels_root: PathBuf,
|
||||
/// Browser name passed to `--cookies-from-browser` (unused) — cookie file
|
||||
/// is currently always `cookies.txt`.
|
||||
pub browser: String,
|
||||
}
|
||||
|
||||
|
|
@ -92,10 +128,13 @@ impl Downloader {
|
|||
Self { jobs: Vec::new(), channels_root, browser }
|
||||
}
|
||||
|
||||
/// Spawn a yt-dlp process for `url` and track it as a new [`Job`].
|
||||
///
|
||||
/// The output path template is derived from `kind` so that channels,
|
||||
/// playlists, and individual videos land in the right sub-directories.
|
||||
pub fn start(&mut self, url: String, kind: &UrlKind) {
|
||||
let (tx, rx) = channel();
|
||||
let archive_path = self.channels_root.join("archive.txt");
|
||||
let browser = self.browser.clone();
|
||||
|
||||
let (out_arg, label) = match kind {
|
||||
UrlKind::Channel { handle } => {
|
||||
|
|
@ -121,25 +160,32 @@ impl Downloader {
|
|||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("--newline")
|
||||
.arg("--no-color")
|
||||
.arg("--cookies")
|
||||
.arg("cookies.txt")
|
||||
.arg("--write-subs")
|
||||
.arg("--write-auto-subs")
|
||||
.arg("--write-thumbnail")
|
||||
.arg("--write-description")
|
||||
.arg("--write-info-json")
|
||||
.arg("-f")
|
||||
.arg("--remux-video")
|
||||
.arg("mkv")
|
||||
.arg("--embed-metadata")
|
||||
.arg("--embed-info-json")
|
||||
.arg("--embed-chapters")
|
||||
.arg("--xattrs")
|
||||
.arg("--sponsorblock-mark")
|
||||
.arg("all")
|
||||
.arg("--extractor-args")
|
||||
.arg("youtube:player_client=web")
|
||||
.arg("--progress")
|
||||
.arg("--break-on-existing")
|
||||
.arg("--download-archive")
|
||||
.arg(archive_path.display().to_string())
|
||||
.arg("--ignore-errors")
|
||||
.arg("--impersonate")
|
||||
.arg("Chrome-146:Macos-26")
|
||||
.arg("-o")
|
||||
.arg(&out_arg);
|
||||
|
||||
if !browser.is_empty() && browser != "none" {
|
||||
cmd.arg("--cookies-from-browser").arg(&browser);
|
||||
}
|
||||
|
||||
cmd.arg(&url_for_thread)
|
||||
.arg(&out_arg)
|
||||
.arg(&url_for_thread)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
|
@ -178,6 +224,9 @@ impl Downloader {
|
|||
self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: Vec::new(), rx });
|
||||
}
|
||||
|
||||
/// Drain pending messages from all job threads into their log buffers.
|
||||
///
|
||||
/// Call this regularly from the UI event loop to pick up progress updates.
|
||||
pub fn poll(&mut self) {
|
||||
for job in &mut self.jobs {
|
||||
job.drain();
|
||||
|
|
@ -187,8 +236,24 @@ impl Downloader {
|
|||
pub fn any_running(&self) -> bool {
|
||||
self.jobs.iter().any(|j| j.state == JobState::Running)
|
||||
}
|
||||
|
||||
/// Remove all jobs that have finished (done or failed), keeping only running ones.
|
||||
pub fn clear_finished(&mut self) {
|
||||
self.jobs.retain(|j| j.state == JobState::Running);
|
||||
}
|
||||
|
||||
/// Remove a single finished job by index. Silently ignores the request
|
||||
/// if the job is still running.
|
||||
pub fn remove_job(&mut self, idx: usize) {
|
||||
if let Some(j) = self.jobs.get(idx) {
|
||||
if j.state != JobState::Running {
|
||||
self.jobs.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a yt-dlp `[download] 42.7% …` line into a `[0.0, 1.0]` fraction.
|
||||
fn parse_progress(line: &str) -> Option<f32> {
|
||||
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
|
||||
let pct_end = rest.find('%')?;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,23 @@
|
|||
//! Scanning the `channels/` directory tree into channels, playlists, and videos.
|
||||
//!
|
||||
//! # Directory layout expected
|
||||
//!
|
||||
//! ```text
|
||||
//! channels/
|
||||
//! <channel-name>/
|
||||
//! Title [VIDEO_ID].mkv
|
||||
//! Title [VIDEO_ID].webp ← thumbnail
|
||||
//! Title [VIDEO_ID].description
|
||||
//! Title [VIDEO_ID].info.json
|
||||
//! Title [VIDEO_ID].en.vtt ← subtitle (lang = "en")
|
||||
//! <playlist-name>/
|
||||
//! Title [VIDEO_ID].mkv
|
||||
//! …
|
||||
//! ```
|
||||
//!
|
||||
//! Files that don't match the `Title [ID].ext` naming convention are silently
|
||||
//! ignored. Hidden directories (name starts with `.`) and directories that
|
||||
//! contain no recognisable video files are skipped.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -6,8 +25,18 @@ use std::path::{Path, PathBuf};
|
|||
const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"];
|
||||
const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"];
|
||||
|
||||
/// A single WebVTT subtitle track discovered alongside a video file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Subtitle {
|
||||
/// ISO 639-1/2 language code extracted from the `.lang.vtt` filename suffix.
|
||||
pub lang: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// A fully enriched video entry, ready to serve to the UI.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Video {
|
||||
/// yt-dlp video ID (the part inside `[…]` in the filename).
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -15,11 +44,17 @@ pub struct Video {
|
|||
pub video_path: Option<PathBuf>,
|
||||
pub thumb_path: Option<PathBuf>,
|
||||
pub description_path: Option<PathBuf>,
|
||||
/// Path to the `.info.json` sidecar — used to read duration, chapters, etc.
|
||||
pub info_path: Option<PathBuf>,
|
||||
pub subtitles: Vec<Subtitle>,
|
||||
pub has_live_chat: bool,
|
||||
/// Duration read from `info.json`; `None` if the sidecar is missing.
|
||||
pub duration_secs: Option<f64>,
|
||||
/// Size of the video file on disk; `None` if the video file is missing.
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// A sub-directory inside a channel that contains videos (treated as a playlist).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Playlist {
|
||||
pub name: String,
|
||||
|
|
@ -28,6 +63,7 @@ pub struct Playlist {
|
|||
pub videos: Vec<Video>,
|
||||
}
|
||||
|
||||
/// Channel-level metadata pulled from the first available `info.json`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChannelMeta {
|
||||
pub subscriber_count: Option<u64>,
|
||||
|
|
@ -35,21 +71,32 @@ pub struct ChannelMeta {
|
|||
pub uploader: Option<String>,
|
||||
}
|
||||
|
||||
/// A top-level channel directory with all its videos and playlists.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Channel {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
/// Videos stored directly inside the channel directory (not in a sub-folder).
|
||||
pub videos: Vec<Video>,
|
||||
/// Sub-directories that contain at least one video.
|
||||
pub playlists: Vec<Playlist>,
|
||||
pub meta: Option<ChannelMeta>,
|
||||
/// Cached sum of `videos.len() + playlists[*].videos.len()`.
|
||||
pub total_videos_cached: usize,
|
||||
/// Cached sum of all video file sizes.
|
||||
pub total_size_cached: u64,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn total_videos(&self) -> usize {
|
||||
self.videos.len() + self.playlists.iter().map(|p| p.videos.len()).sum::<usize>()
|
||||
self.total_videos_cached
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan `root` for channel directories and return them sorted alphabetically.
|
||||
///
|
||||
/// Skips hidden directories (names starting with `.`) and directories that
|
||||
/// contain no recognisable video files.
|
||||
pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
||||
let mut channels = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return channels };
|
||||
|
|
@ -57,9 +104,26 @@ pub fn scan_channels(root: &Path) -> Vec<Channel> {
|
|||
let path = entry.path();
|
||||
if !path.is_dir() { continue; }
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') { continue; }
|
||||
let (videos, playlists) = scan_channel_dir(&path);
|
||||
if videos.is_empty() && playlists.is_empty() { continue; }
|
||||
let meta = load_channel_meta(&videos);
|
||||
channels.push(Channel { name, path, videos, playlists, meta });
|
||||
let total_videos_cached =
|
||||
videos.len() + playlists.iter().map(|p| p.videos.len()).sum::<usize>();
|
||||
let total_size_cached = videos
|
||||
.iter()
|
||||
.chain(playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.filter_map(|v| v.file_size)
|
||||
.sum();
|
||||
channels.push(Channel {
|
||||
name,
|
||||
path,
|
||||
videos,
|
||||
playlists,
|
||||
meta,
|
||||
total_videos_cached,
|
||||
total_size_cached,
|
||||
});
|
||||
}
|
||||
channels.sort_by_key(|c| c.name.to_lowercase());
|
||||
channels
|
||||
|
|
@ -134,15 +198,28 @@ struct RawVideo {
|
|||
thumb_path: Option<PathBuf>,
|
||||
description_path: Option<PathBuf>,
|
||||
info_path: Option<PathBuf>,
|
||||
subtitles: Vec<Subtitle>,
|
||||
has_live_chat: bool,
|
||||
}
|
||||
|
||||
fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<RawVideo> {
|
||||
let mut by_stem: BTreeMap<String, RawVideo> = BTreeMap::new();
|
||||
let mut pending_subs: Vec<(String, String, PathBuf)> = Vec::new();
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if !path.is_file() { continue; }
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
// Subtitles have stems like "Title [id].en.vtt" — strip the .vtt and trailing .lang
|
||||
if let Some(sub_stem) = file_name.strip_suffix(".vtt") {
|
||||
if let Some(dot) = sub_stem.rfind('.') {
|
||||
let lang = sub_stem[dot + 1..].to_string();
|
||||
let video_stem = sub_stem[..dot].to_string();
|
||||
pending_subs.push((video_stem, lang, path));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let Some((stem, kind)) = classify(&file_name) else { continue };
|
||||
let Some((title, id)) = parse_stem(stem) else { continue };
|
||||
let raw = by_stem.entry(stem.to_string()).or_insert_with(|| RawVideo {
|
||||
|
|
@ -153,6 +230,7 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
|
|||
thumb_path: None,
|
||||
description_path: None,
|
||||
info_path: None,
|
||||
subtitles: Vec::new(),
|
||||
has_live_chat: false,
|
||||
});
|
||||
match kind {
|
||||
|
|
@ -164,6 +242,14 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
|
|||
FileKind::Other => {}
|
||||
}
|
||||
}
|
||||
for (video_stem, lang, path) in pending_subs {
|
||||
if let Some(raw) = by_stem.get_mut(&video_stem) {
|
||||
raw.subtitles.push(Subtitle { lang, path });
|
||||
}
|
||||
}
|
||||
for raw in by_stem.values_mut() {
|
||||
raw.subtitles.sort_by(|a, b| a.lang.cmp(&b.lang));
|
||||
}
|
||||
by_stem.into_values().collect()
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +270,8 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
|||
video_path: raw.video_path,
|
||||
thumb_path: raw.thumb_path,
|
||||
description_path: raw.description_path,
|
||||
info_path: raw.info_path,
|
||||
subtitles: raw.subtitles,
|
||||
has_live_chat: raw.has_live_chat,
|
||||
duration_secs,
|
||||
file_size,
|
||||
|
|
@ -193,6 +281,9 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
|||
videos
|
||||
}
|
||||
|
||||
/// Scan a single flat directory for video files and return enriched `Video` entries.
|
||||
///
|
||||
/// Used when rescanning a playlist directory without a full library reload.
|
||||
pub fn scan_video_files(dir: &Path) -> Vec<Video> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let raws = collect_raw_videos(entries.flatten().filter(|e| e.path().is_file()));
|
||||
|
|
|
|||
14
src/main.rs
14
src/main.rs
|
|
@ -1,3 +1,16 @@
|
|||
//! yt-offline — desktop and web app for archiving YouTube content with yt-dlp.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! * **GUI mode** (default): `yt-offline`
|
||||
//! * **Web mode**: `yt-offline --web [PORT]` — starts a headless HTTP server
|
||||
//! on the configured port (default 8080).
|
||||
//!
|
||||
//! Configuration is read from `config.toml` in the current working directory.
|
||||
//! See [`config`] for all available options.
|
||||
//!
|
||||
//! Licensed under the GNU Affero General Public License v3 or later (AGPL-3.0+).
|
||||
//! Source code must be made available to network users per AGPL §13.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod app;
|
||||
|
|
@ -6,7 +19,6 @@ mod database;
|
|||
mod downloader;
|
||||
mod library;
|
||||
mod theme;
|
||||
mod tray;
|
||||
mod web;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
|
|
|
|||
36
src/tray.rs
36
src/tray.rs
|
|
@ -1,36 +0,0 @@
|
|||
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)?)
|
||||
}
|
||||
1168
src/web.rs
1168
src/web.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue