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
302
src/plex.rs
Normal file
302
src/plex.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// 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 & b < c > 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue