Add web themes, network binding, UI auth, library maintenance; optimize + fix rescan

Features:
- Port all 7 desktop themes (Dracula, Trans, Emo variants, etc.) to the web UI
- Configurable bind interface (localhost/Tailscale/LAN/all) with detected IPs,
  settable from both the web UI and desktop GUI
- Optional password that gates the whole UI and all API access via session
  cookies (Argon2), with a login page, logout, and middleware; settable from
  both UIs
- Library maintenance: scan for duplicate video IDs and missing assets
  (thumbnail/info.json/description), remove duplicates (scan-and-confirm, with
  out-of-library path guard), and re-fetch missing sidecars via yt-dlp.
  Surfaced in both the web UI and a desktop GUI window.

Optimizations / cleanup:
- Cache has_chapters at scan time (library.rs) instead of re-reading and
  parsing every info.json on each /api/library request
- Use the cached channel size in get_library
- Pool a single SQLite connection in WebState instead of opening one per request
- Remove a 1.2 GB stray nested src/yt-offline duplicate tree

Fixes:
- Rescan no longer clears all thumbnail textures (caused thumbnails to unload);
  prune only removed entries and bump library_generation so the card grid
  refreshes correctly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-21 02:58:09 -07:00
parent 74b3efd990
commit 1ef3fe56c6
10 changed files with 1126 additions and 89 deletions

View file

@ -133,7 +133,6 @@ impl Downloader {
/// 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 (out_arg, label) = match kind {
@ -155,41 +154,82 @@ impl Downloader {
),
};
let url_for_thread = url.clone();
thread::spawn(move || {
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("--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("--impersonate")
.arg("Chrome-146:Macos-26")
.arg("-o")
.arg(&out_arg)
.arg(&url_for_thread)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
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("--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("--impersonate")
.arg("Chrome-146:Macos-26")
.arg("-o")
.arg(&out_arg)
.arg(&url);
self.spawn_job(cmd, url, label);
}
/// Re-fetch missing sidecar assets (thumbnail, info.json, description,
/// subtitles) for a single video without re-downloading the video itself.
///
/// `dir`/`stem` come from the existing file on disk so the fetched sidecars
/// land with exactly the same filename stem and associate with the video.
pub fn repair(&mut self, video_id: &str, dir: &std::path::Path, stem: &str) {
let _ = std::fs::create_dir_all(dir);
// Escape literal `%` so yt-dlp doesn't treat it as an output field.
let safe_stem = stem.replace('%', "%%");
let out_arg = format!("{}/{}.%(ext)s", dir.display(), safe_stem);
let url = format!("https://www.youtube.com/watch?v={video_id}");
let label = format!("repair {stem}");
let mut cmd = Command::new("yt-dlp");
cmd.arg("--newline")
.arg("--no-color")
.arg("--skip-download")
.arg("--cookies")
.arg("cookies.txt")
.arg("--write-thumbnail")
.arg("--write-info-json")
.arg("--write-description")
.arg("--write-subs")
.arg("--write-auto-subs")
.arg("--extractor-args")
.arg("youtube:player_client=web")
.arg("--impersonate")
.arg("Chrome-146:Macos-26")
.arg("-o")
.arg(&out_arg)
.arg(&url);
self.spawn_job(cmd, url, label);
}
/// Spawn `cmd` on a background thread, streaming its output into a new [`Job`].
fn spawn_job(&mut self, mut cmd: Command, url: String, label: String) {
let (tx, rx) = channel();
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
thread::spawn(move || {
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(err) => {