Add bundled yt-dlp, music library, Plex metadata, sort by date; security + perf hardening
Bug fixes - Desktop auto-rescan: snapshot was_running before check_notifications updates prev_job_states, so the post-download rescan actually fires. - web::run shutdown: replace `let _ = tx;` (which dropped the sender immediately and short-circuited the shutdown select!) with std::mem::forget(tx); add an idle fallback so the `-> !` is honored. - /api/preview now resolves the bundled yt-dlp path and extends PATH for deno discovery, matching the rest of the downloader. - Scheduler interval clamped to >=1h on read so a manually-edited config.toml with 0 hours can't trigger every tick. - maintenance::is_within canonicalizes the parent + filename when the target is missing; remove_files treats NotFound as success. Security - Session tokens stored with issued-at Instant and pruned past 30 day TTL on every touch (was: unbounded HashSet). - Login rate-limited per source IP: 5 failures → 60s lockout (429). - Secure cookie flag added when X-Forwarded-Proto: https is present. - 4 MiB body size cap via DefaultBodyLimit. - Security headers middleware: CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy. - JS safeUrl() defangs javascript:/vbscript:/data:text URLs before interpolation into src= attributes. - Bundled yt-dlp install verifies SHA-256 against the release's SHA2-256SUMS; deno hash printed for visual inspection. Install also reports byte counts every 3s so users see live progress. - yt-offline.db chmod 0600 at open time on Unix. New features - Bundled yt-dlp + deno mode: settings toggle (System/Bundled) plus one-click Install/Update from settings. Ensures executable bit on every launch in case the install chmod was missed. - Download queue with configurable max_concurrent (1–10); pending jobs surfaced in both UIs. - Music download mode: --extract-audio into music/<artist>/, separate sidebar entry, /api/music + /music-files/ endpoints, inline <audio>. - Download quality selector: Best / 1080p / 720p / 480p / 360p. - Sort videos by Newest / Oldest (upload_date from info.json with release_date fallback). - Plex metadata: per-episode .nfo (title, season/episode, aired, runtime, plot from .description), <stem>-thumb.jpg symlink, and show-level tvshow.nfo. - yt-dlp retry/throttle defaults: --retries 30 --fragment-retries 30 --retry-sleep linear=1:30:2 --sleep-requests 1 to ride out the "Connection reset by peer" failures. Performance - password_required cached in AtomicBool; refreshed only on password change. Eliminates a SQLite query per static-file fetch. - Job::log → VecDeque for O(1) front-pop instead of O(n) drain. - Library scan parallelized across cores via stdlib parallel_map. - Web UI poll: 600ms while active, 5s when idle, 2s after errors. - Music scan now recursive (Artist/Album/Track.opus). - percent_encode_segment uses write! to avoid per-byte String allocs. Code quality - 27 unit tests covering parse_progress, parse_stem, extract_after, detect_url_kind, srt_to_vtt, count_cookies, percent_encode_segment, bind_mode_of, hash_password, lang_label, is_within, sanitize, year_of, aired_date, xml_escape. - Unified library::find_video + Channel::all_videos() iterator, replacing three duplicated linear searches. - Downloader::browser wired to --cookies-from-browser as a fallback when no cookies.txt is present. - mpv detection now matches the binary basename, not substring. - Settings modal scroll fix; web settings expose all new fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1d72069913
commit
59996735f3
10 changed files with 2300 additions and 156 deletions
|
|
@ -191,11 +191,23 @@ pub fn scan(root: &Path, channels: &[Channel]) -> HealthReport {
|
|||
}
|
||||
|
||||
/// True if `target` resolves to a location inside `root`.
|
||||
///
|
||||
/// `target` may not exist (e.g. the caller is about to delete it). In that
|
||||
/// case we canonicalise the parent directory and join the file name back on,
|
||||
/// so a deleted file still gets a correct safety verdict instead of a false
|
||||
/// "outside library" refusal.
|
||||
fn is_within(root: &Path, target: &Path) -> bool {
|
||||
match (root.canonicalize(), target.canonicalize()) {
|
||||
(Ok(r), Ok(t)) => t.starts_with(r),
|
||||
_ => false,
|
||||
}
|
||||
let Ok(canon_root) = root.canonicalize() else { return false };
|
||||
let canon_target = match target.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
let Some(parent) = target.parent() else { return false };
|
||||
let Some(name) = target.file_name() else { return false };
|
||||
let Ok(canon_parent) = parent.canonicalize() else { return false };
|
||||
canon_parent.join(name)
|
||||
}
|
||||
};
|
||||
canon_target.starts_with(canon_root)
|
||||
}
|
||||
|
||||
/// Delete the given files, refusing any path that escapes `root`.
|
||||
|
|
@ -212,12 +224,54 @@ pub fn remove_files(root: &Path, paths: &[PathBuf]) -> (usize, Vec<String>) {
|
|||
}
|
||||
match std::fs::remove_file(p) {
|
||||
Ok(()) => removed += 1,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Already gone — treat as success so a duplicate-delete from
|
||||
// a stale UI doesn't look like an error.
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) => errors.push(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
(removed, errors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn is_within_accepts_inside_path() {
|
||||
let tmp = std::env::temp_dir().join("yt-offline-iw-test-1");
|
||||
let _ = fs::create_dir_all(&tmp);
|
||||
let inside = tmp.join("foo.txt");
|
||||
let _ = fs::write(&inside, "x");
|
||||
assert!(is_within(&tmp, &inside));
|
||||
let _ = fs::remove_file(&inside);
|
||||
let _ = fs::remove_dir(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_within_rejects_outside_path() {
|
||||
let tmp = std::env::temp_dir().join("yt-offline-iw-test-2");
|
||||
let _ = fs::create_dir_all(&tmp);
|
||||
// The target's parent canonicalises to /tmp, which doesn't start with tmp.
|
||||
let outside = std::env::temp_dir().join("not-our-dir-xyz.txt");
|
||||
assert!(!is_within(&tmp, &outside));
|
||||
let _ = fs::remove_dir(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_within_handles_missing_target() {
|
||||
// Target doesn't exist; parent dir does and is inside root.
|
||||
let tmp = std::env::temp_dir().join("yt-offline-iw-test-3");
|
||||
let _ = fs::create_dir_all(&tmp);
|
||||
let ghost = tmp.join("does-not-exist.txt");
|
||||
assert!(is_within(&tmp, &ghost));
|
||||
let _ = fs::remove_dir(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a video's directory and filename stem by ID, for repair targeting.
|
||||
/// Returns `(dir, stem)` of the first matching copy with a known location.
|
||||
pub fn locate(channels: &[Channel], id: &str) -> Option<(PathBuf, String)> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue