yt-dlp already speaks ~1,800 sites; the changes here teach the rest of the app to route them through platform-specific output folders and remember where each creator was downloaded from. New `platform` module - `Platform` enum: YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee, Other. - `classify_url` returns both the source platform and a `UrlKind` (Channel / Playlist / Video / Unknown) — per-platform path parsing for each supported host. - `platform_root(channels_root, platform)` returns the on-disk folder per platform. YouTube keeps the legacy `channels/` for backward compat; the others live as siblings (`tiktok/`, `twitch/`, …). - `write_source_url` / `read_source_url` persist the originating URL in a `.source-url` sidecar at each creator folder so re-checks no longer rely on the YouTube-only "UC + 22 chars" heuristic. Downloader - `Downloader::start` now takes `&UrlInfo` instead of `&UrlKind` and routes the yt-dlp `-o` template into the right platform folder. - For channel downloads it also writes `.source-url` so future re-checks recover the exact URL. - Output template uses `%(uploader,channel,creator|Unknown)s` so non-YouTube sites that don't expose `channel` still get a sensible creator folder. - Per-platform `archive.txt` keeps cross-platform IDs (TikTok numeric vs YouTube base64) from colliding. - New `recheck_url(&Channel)` prefers `source_url` and falls back to the legacy YouTube heuristic for libraries created before this change. Library scanner - `scan_channels` now walks every platform's folder and tags each `Channel` with its `platform` + `source_url`. Sort key puts the platform first, then name, so the sidebar groups cleanly. - `Channel` gains `platform: Platform` and `source_url: Option<String>` fields, populated from the on-disk sidecar. Web UI - `WebState` gains `library_root` (= parent of channels_root). The `/files/` ServeDir is mounted at library_root so non-YouTube videos resolve at `/files/<platform>/<creator>/<file>`. Existing YouTube URLs still work — they're now `/files/channels/...` instead of `/files/...` and the server-rendered JSON updates accordingly. - Maintenance scan + remove use `library_root` so non-YouTube content is included. - `ChannelInfo` JSON exposes `platform`, `platform_label`, `platform_icon`, and `source_url`. - Sidebar groups channels by platform with each platform's icon. - "Check for new videos" now uses the stored `source_url` (with the YouTube heuristic as a last-resort fallback). - Download dialog preview shows the detected source + destination folder. Desktop UI - App gains a `library_root` field mirroring the web side; maintenance ops scan/remove against it. - Sidebar channel labels are prefixed with the platform icon for non-YouTube channels; tooltip carries the platform name. - Download URL field surfaces both source platform and folder path preview as you type. Plex - Non-YouTube creators get their show folder prefixed with the platform name (e.g., `TikTok - cooluser`) so a YouTube channel and a TikTok account with the same name don't collide. Backward compatibility - Existing YouTube libraries (`channels/<handle>/...`) are untouched. New platform folders are created on first run as siblings. - Channels without a `.source-url` (i.e., everything that pre-dates this commit) fall through to the YouTube heuristic, preserving the existing re-check behavior. Tests - 14 new `platform` tests cover URL classification per platform, channel handle extraction, and `platform_root` layout. The old `detect_url_kind` / `extract_after` tests move into platform.rs since the canonical implementation lives there now. - 41 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
310 lines
12 KiB
Rust
310 lines
12 KiB
Rust
//! 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; }
|
|
|
|
// Prefix the show folder with the platform name when it's not YouTube
|
|
// so multi-platform libraries don't collide on shared creator names
|
|
// (e.g. a YouTube and a TikTok account both called "MusicianName").
|
|
let base_name = sanitize(&ch.name);
|
|
let show_name = if ch.platform == crate::platform::Platform::YouTube {
|
|
base_name
|
|
} else {
|
|
format!("{} - {}", ch.platform.display_name(), base_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 }
|
|
}
|