The core of content-aware duplicate detection — finding the same video under a different ID (reupload / mirror / re-encode), which the ID-based maintenance scan can't. - src/fingerprint.rs: samples 6 frames per video via ffmpeg keyframe seek (fast — never full-decodes), downscales to a 9x8 grayscale grid, and dHashes each to a 64-bit frame hash. `group_similar` clusters videos whose frame hashes match within a Hamming threshold, comparing only within a duration-tolerance window (sorted sliding window + union-find) so it stays near-linear instead of O(n²). Parallel `compute_batch` over a hand-rolled scoped thread pool (no rayon), with a progress counter. - src/database.rs: `video_fingerprint` table keyed by (path, mtime) like info_cache — hash once, reuse forever; new downloads hashed as they arrive. Store / load / mtime-gate / prune helpers. Unit tests cover dHash, Hamming, duration-bucketed + transitive grouping, and DB round-trip/replace/prune. An opt-in `real_ffmpeg_groups_reencodes` test (run with --ignored) generates a video + a downscaled CRF-38 re-encode + an unrelated clip and asserts the first two group and the third doesn't — measured ~0.5s/video serial (≈65ms/video across 8 cores). UI wiring (background build + review screen in both front-ends) follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
3.4 KiB
Rust
95 lines
3.4 KiB
Rust
//! 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;
|
|
mod config;
|
|
mod crash;
|
|
mod database;
|
|
mod disk_space;
|
|
mod download_options;
|
|
mod downloader;
|
|
mod error_class;
|
|
mod fingerprint;
|
|
mod library;
|
|
mod maintenance;
|
|
mod platform;
|
|
mod plex;
|
|
mod pot_provider;
|
|
mod stats;
|
|
mod theme;
|
|
mod tray;
|
|
mod util;
|
|
mod vtt;
|
|
mod web;
|
|
mod ytdlp_bin;
|
|
|
|
fn main() -> eframe::Result<()> {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
// Load the config first — both modes share the channels_root path,
|
|
// and the panic hook needs it before anything else runs so a crash
|
|
// in the very early startup (DB open, tray init, ksni) still lands
|
|
// in the crash log.
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
|
let cfg_path = cwd.join("config.toml");
|
|
let mut cfg = config::Config::load(&cfg_path)
|
|
.unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels")));
|
|
|
|
// Install the persistent panic logger. Logs go alongside the SQLite
|
|
// database; the parent of channels_root is the same "library root"
|
|
// the desktop UI shows.
|
|
let crash_dir = cfg.backup.directory.parent()
|
|
.map(std::path::Path::to_path_buf)
|
|
.unwrap_or_else(|| cfg.backup.directory.clone());
|
|
crash::install(&crash_dir);
|
|
|
|
// --web [port] → run the web interface instead of the GUI
|
|
if let Some(pos) = args.iter().position(|a| a == "--web") {
|
|
// Override port if provided after --web
|
|
if let Some(port_str) = args.get(pos + 1) {
|
|
if let Ok(port) = port_str.parse::<u16>() {
|
|
cfg.web.port = port;
|
|
}
|
|
}
|
|
web::run(cfg);
|
|
}
|
|
|
|
// Start the optional system tray. Returns None if the icon decode
|
|
// fails (shouldn't happen — it's embedded) or the thread won't spawn.
|
|
// A Some result does NOT guarantee a visible tray icon — that depends
|
|
// on whether a StatusNotifier host is registered (KDE: yes; GNOME:
|
|
// requires AppIndicator extension; others: varies). When no host
|
|
// exists, ksni quietly does nothing and the app behaves as if the
|
|
// tray flag were off.
|
|
const TRAY_ICON_PNG: &[u8] = include_bytes!("../icon.png");
|
|
let tray = tray::start(TRAY_ICON_PNG);
|
|
|
|
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("yt-offline"),
|
|
// Force the wgpu (Vulkan) renderer. The default glow/OpenGL path
|
|
// crashes on NVIDIA + Wayland when the window is maximized
|
|
// (Glutin EGL_BAD_ALLOC). wgpu reconfigures the swapchain cleanly.
|
|
renderer: eframe::Renderer::Wgpu,
|
|
..Default::default()
|
|
};
|
|
eframe::run_native(
|
|
"yt-offline",
|
|
native_options,
|
|
Box::new(|cc| Ok(Box::new(app::App::new(cc, tray)))),
|
|
)
|
|
}
|