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:
Luna 2026-05-23 04:54:26 -07:00
parent 1d72069913
commit 59996735f3
10 changed files with 2300 additions and 156 deletions

302
src/plex.rs Normal file
View file

@ -0,0 +1,302 @@
//! Plex-compatible TV-show library generator.
//!
//! Creates a directory tree of symlinks under a configurable Plex library root.
//! Each channel becomes a show; videos are arranged as episodes numbered by
//! upload date, grouped into year-based seasons.
//!
//! Per-episode metadata (title, plot, aired date, runtime, thumbnail) is
//! written as Kodi-format `.nfo` sidecars that Plex's "Personal Media (TV
//! Shows)" agent (or the XBMC NFO agent) reads. A show-level `tvshow.nfo`
//! carries the channel title and uploader.
//!
//! # Output structure
//!
//! ```text
//! <plex_root>/
//! Channel Name/
//! .plexmatch ← legacy show-title hint
//! tvshow.nfo ← channel-level metadata
//! Season 2023/
//! Channel Name - S2023E001 - Video Title.mkv → symlink to real file
//! Channel Name - S2023E001 - Video Title.nfo ← episode metadata
//! Channel Name - S2023E001 - Video Title-thumb.jpg → symlink to thumb
//! Channel Name - S2023E001 - Video Title.en.srt
//! Season 2024/
//! …
//! ```
//!
//! Existing symlinks are left untouched; the function is safe to re-run after
//! new downloads. NFO files are rewritten on each run so updated metadata
//! (e.g. corrected titles) propagates.
use std::collections::BTreeMap;
use std::path::Path;
use crate::library::{Channel, Video};
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Replace characters that are invalid in filenames on common filesystems.
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c => c,
})
.collect::<String>()
.trim()
.to_string()
}
/// Extract the four-digit year from a `YYYYMMDD` upload_date, or 0 if unavailable.
fn year_of(date: &str) -> u32 {
if date.len() >= 4 { date[..4].parse().unwrap_or(0) } else { 0 }
}
/// Convert `YYYYMMDD` → `YYYY-MM-DD` for NFO `<aired>` tags. Returns empty string
/// for malformed input.
fn aired_date(date: &str) -> String {
if date.len() >= 8 {
format!("{}-{}-{}", &date[..4], &date[4..6], &date[6..8])
} else {
String::new()
}
}
/// Minimal XML escaping for text-node content in NFO files.
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Read the channel-level description and uploader from the first available
/// video's info.json for use in `tvshow.nfo`.
fn channel_meta_from_info(videos: &[&Video]) -> (String, String) {
for v in videos {
let Some(p) = v.info_path.as_ref() else { continue };
let Ok(text) = std::fs::read_to_string(p) else { continue };
let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
let plot = val.get("channel_description")
.or_else(|| val.get("uploader_description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let uploader = val.get("uploader")
.or_else(|| val.get("channel"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return (plot, uploader);
}
(String::new(), String::new())
}
/// Read a video's full description text. Prefers the `.description` sidecar
/// (full text including newlines); falls back to the `description` field of
/// info.json if the sidecar is missing.
fn read_description(v: &Video) -> String {
if let Some(p) = v.description_path.as_ref() {
if let Ok(s) = std::fs::read_to_string(p) {
return s;
}
}
v.info_path.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|val| val.get("description").and_then(|v| v.as_str()).map(String::from))
.unwrap_or_default()
}
/// Create a symlink at `link` pointing to `target`. Skips if the link already
/// exists (including broken symlinks). Returns an error only on creation failure.
fn make_symlink(target: &Path, link: &Path) -> Result<(), String> {
// symlink_metadata succeeds even for broken symlinks, unlike exists()
if link.symlink_metadata().is_ok() {
return Ok(());
}
#[cfg(unix)]
std::os::unix::fs::symlink(target, link)
.map_err(|e| format!("{}: {e}", link.display()))?;
#[cfg(not(unix))]
return Err("symlinks are not supported on this platform".to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_replaces_invalid_chars() {
assert_eq!(sanitize("a/b:c*d?e\"f<g>h|i\\j"), "a-b-c-d-e-f-g-h-i-j");
}
#[test]
fn year_of_extracts_year() {
assert_eq!(year_of("20240315"), 2024);
assert_eq!(year_of(""), 0);
assert_eq!(year_of("bad"), 0);
}
#[test]
fn aired_date_formats_yyyymmdd() {
assert_eq!(aired_date("20240315"), "2024-03-15");
assert_eq!(aired_date("bad"), "");
}
#[test]
fn xml_escape_escapes_special_chars() {
assert_eq!(xml_escape("a & b < c > d"), "a &amp; b &lt; c &gt; d");
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Result of a Plex library generation run.
pub struct GenerateResult {
pub links_created: usize,
pub errors: Vec<String>,
}
/// Generate (or refresh) the Plex symlink tree under `plex_root` from `channels`.
///
/// Safe to call repeatedly: existing symlinks are skipped, missing ones are added.
pub fn generate(channels: &[Channel], plex_root: &Path) -> GenerateResult {
let mut links_created = 0;
let mut errors = Vec::new();
for ch in channels {
let all_videos: Vec<&Video> = ch.videos.iter()
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
.collect();
if all_videos.is_empty() { continue; }
let show_name = sanitize(&ch.name);
let show_dir = plex_root.join(&show_name);
if let Err(e) = std::fs::create_dir_all(&show_dir) {
errors.push(format!("mkdir {}: {e}", show_dir.display()));
continue;
}
// .plexmatch: helps Plex identify the show by title
let _ = std::fs::write(
show_dir.join(".plexmatch"),
format!("title: {}\n", ch.name),
);
// tvshow.nfo: channel-level metadata (title, uploader, plot).
let (channel_plot, uploader) = channel_meta_from_info(&all_videos);
let tvshow_nfo = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<tvshow>\n \
<title>{}</title>\n \
<studio>{}</studio>\n \
<plot>{}</plot>\n\
</tvshow>\n",
xml_escape(&ch.name),
xml_escape(&uploader),
xml_escape(&channel_plot),
);
let _ = std::fs::write(show_dir.join("tvshow.nfo"), tvshow_nfo);
// Sort all videos by upload date for deterministic episode numbering
let mut dated: Vec<(&Video, String)> = all_videos.iter()
.map(|v| (*v, v.upload_date.clone().unwrap_or_default()))
.collect();
dated.sort_by(|a, b| a.1.cmp(&b.1));
// Group into year-based seasons
let mut by_year: BTreeMap<u32, Vec<&Video>> = BTreeMap::new();
for (v, date) in &dated {
by_year.entry(year_of(date)).or_default().push(v);
}
for (year, vids) in &by_year {
let season_label = if *year > 0 {
format!("Season {year}")
} else {
"Season 01".to_string()
};
let season_num = if *year > 0 { *year } else { 1 };
let season_dir = show_dir.join(&season_label);
if let Err(e) = std::fs::create_dir_all(&season_dir) {
errors.push(format!("mkdir {}: {e}", season_dir.display()));
continue;
}
for (ep_idx, v) in vids.iter().enumerate() {
let ep = ep_idx as u32 + 1;
let title = sanitize(&v.title);
let stem = format!("{show_name} - S{season_num:04}E{ep:03} - {title}");
// Video file symlink
if let Some(ref src) = v.video_path {
let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("mkv");
let link = season_dir.join(format!("{stem}.{ext}"));
match make_symlink(src, &link) {
Ok(()) => links_created += 1,
Err(e) => errors.push(e),
}
}
// Thumbnail symlink — Plex looks for `<stem>-thumb.jpg`.
if let Some(ref thumb) = v.thumb_path {
let ext = thumb.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
let link = season_dir.join(format!("{stem}-thumb.{ext}"));
match make_symlink(thumb, &link) {
Ok(()) => links_created += 1,
Err(e) => errors.push(e),
}
}
// Episode NFO sidecar — overwritten on every run so metadata
// updates (renames, re-fetched info.json) take effect.
let aired = v.upload_date.as_deref()
.map(aired_date)
.unwrap_or_default();
let runtime_min = v.duration_secs
.map(|s| (s / 60.0).round() as u64)
.unwrap_or(0);
let plot = read_description(v);
let nfo = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<episodedetails>\n \
<title>{}</title>\n \
<season>{}</season>\n \
<episode>{}</episode>\n \
<aired>{}</aired>\n \
<runtime>{}</runtime>\n \
<plot>{}</plot>\n\
</episodedetails>\n",
xml_escape(&v.title),
season_num,
ep,
xml_escape(&aired),
runtime_min,
xml_escape(&plot),
);
let nfo_path = season_dir.join(format!("{stem}.nfo"));
if let Err(e) = std::fs::write(&nfo_path, nfo) {
errors.push(format!("write {}: {e}", nfo_path.display()));
}
// Subtitle symlinks — Plex picks these up automatically when
// the stem matches: "Show - S01E01 - Title.en.srt"
for sub in &v.subtitles {
let ext = sub.path.extension().and_then(|e| e.to_str()).unwrap_or("srt");
let link = season_dir.join(format!("{stem}.{}.{ext}", sub.lang));
match make_symlink(&sub.path, &link) {
Ok(()) => links_created += 1,
Err(e) => errors.push(e),
}
}
}
}
}
GenerateResult { links_created, errors }
}