catacomb/src/plex.rs
Luna 12c32f642b Add multi-platform support: TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee
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>
2026-05-24 22:16:40 -07:00

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('&', "&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; }
// 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 }
}