Major update with lots of new features and fixes

This commit is contained in:
Luna 2026-05-17 05:36:04 -07:00
parent 17c149c21a
commit 74b3efd990
16 changed files with 1648 additions and 904 deletions

View file

@ -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 {

View file

@ -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 },

View file

@ -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 };

View file

@ -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('%')?;

View file

@ -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()));

View file

@ -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<()> {

View file

@ -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

File diff suppressed because it is too large Load diff