feat: add Rust CLI with scan/map/tag/move/tvdb/audit, dry-run, config

- clap-based subcommands over the existing service layer
- map: Jellyfin SxxExx naming with auto episode numbering, --dry-run
- tag/untag: persisted to .moviemapper.json in the media directory
- move: relocates tagged files into Jellyfin extras folders, --dry-run
- tvdb: search/show via TheTVDB v4 API
- audit: pretty-prints the .audit JSON log
- config: ~/.config/movemapper/config.toml (flag > env > file precedence)
- fix: generate_jellyfin_filename missing dot before extension
- 10 new unit tests; workspace tests green
This commit is contained in:
Jarian Cottingham 2026-08-20 20:33:21 +00:00
parent 0447acd6c5
commit e3d130091a
10 changed files with 994 additions and 60 deletions

145
Cargo.lock generated
View File

@ -94,12 +94,56 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]] [[package]]
name = "anstyle" name = "anstyle"
version = "1.0.13" version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@ -404,6 +448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive",
] ]
[[package]] [[package]]
@ -412,8 +457,22 @@ version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [ dependencies = [
"anstream",
"anstyle", "anstyle",
"clap_lex", "clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
] ]
[[package]] [[package]]
@ -471,6 +530,12 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "colored" name = "colored"
version = "3.1.1" version = "3.1.1"
@ -1918,6 +1983,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.5" version = "0.10.5"
@ -2280,6 +2351,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"clap",
"criterion", "criterion",
"dirs", "dirs",
"dotenv", "dotenv",
@ -2294,6 +2366,7 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-test", "tokio-test",
"toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@ -2689,6 +2762,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "oorandom" name = "oorandom"
version = "11.1.5" version = "11.1.5"
@ -3035,7 +3114,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [ dependencies = [
"toml_edit", "toml_edit 0.23.10+spec-1.0.0",
] ]
[[package]] [[package]]
@ -3533,6 +3612,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -3770,6 +3858,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "svg_fmt" name = "svg_fmt"
version = "0.4.5" version = "0.4.5"
@ -4080,6 +4174,27 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.7.5+spec-1.1.0" version = "0.7.5+spec-1.1.0"
@ -4089,6 +4204,20 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.23.10+spec-1.0.0" version = "0.23.10+spec-1.0.0"
@ -4096,7 +4225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 0.7.5+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow",
] ]
@ -4110,6 +4239,12 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tower-service" name = "tower-service"
version = "0.3.3" version = "0.3.3"
@ -4280,6 +4415,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"

View File

@ -105,6 +105,45 @@ cargo build --workspace
cargo test --workspace cargo test --workspace
``` ```
### Rust CLI
The `movie_mapper` binary exposes the core as a command-line tool for
scripting and headless use:
```bash
# List media files with metadata
movie-mapper scan /path/to/season [--json]
# Rename files to Jellyfin naming (Show S01E01 - Quality.ext)
movie-mapper map /path/to/season --show "The Show" --season 1 [--episode-start 1] [--dry-run]
# Tag / untag files (persisted to .moviemapper.json)
movie-mapper tag /path/to/media "behind the scenes"
movie-mapper untag /path/to/media "behind the scenes"
# Move tagged files into the Jellyfin folder for that tag
movie-mapper move /path/to/media "behind the scenes" [--dry-run]
# TheTVDB (requires TVDB_API_KEY)
movie-mapper tvdb search "Breaking Bad"
movie-mapper tvdb show 81189
# Show the audit log for a directory
movie-mapper audit /path/to/season
```
All destructive commands support `--dry-run` for a filesystem-safe preview.
Every rename and move writes an entry to the directory's `.audit` log.
Configuration is read from `~/.config/movemapper/config.toml`:
```toml
tvdb_api_key = "your-api-key"
media_dir = "/path/to/media"
```
Precedence for the API key: `--tvdb-key` flag > `TVDB_API_KEY` env var > config file.
### Tests ### Tests
```bash ```bash

View File

@ -10,9 +10,11 @@ keywords = ["media", "ffmpeg", "tvdb", "jellyfin", "video"]
categories = ["command-line-utilities", "filesystem"] categories = ["command-line-utilities", "filesystem"]
[dependencies] [dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
toml = "0.8"
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
thiserror = "1.0" thiserror = "1.0"
anyhow = "1.0" anyhow = "1.0"

790
Rust/src/cli.rs Normal file
View File

@ -0,0 +1,790 @@
//! Command-line interface for MovieMapper.
//!
//! Subcommands: scan, map, tag, untag, move, tvdb, audit.
use crate::model::MediaFile;
use crate::service::audit_logger::AuditAction;
use crate::service::file_mapper::FileMapper;
use crate::service::tag_manager::TagManager;
use crate::service::tvdb_api::TVDBClient;
use crate::utils::{MovieMapperError, Result};
use crate::{AuditLogger, FileScanner};
use anyhow::{Context, Result as AnyResult};
use clap::{Parser, Subcommand};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// Organize movie and TV collections with Jellyfin-compatible naming.
#[derive(Parser, Debug)]
#[command(name = "movie-mapper", version, about)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
/// Path to config file (default: ~/.config/movemapper/config.toml)
#[arg(long, global = true)]
pub config: Option<PathBuf>,
/// Enable debug logging
#[arg(short, long, global = true)]
pub verbose: bool,
/// TheTVDB API key (overrides env and config file)
#[arg(long, global = true)]
pub tvdb_key: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Scan a directory and list media files with metadata
Scan {
/// Directory to scan
path: PathBuf,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Rename files to Jellyfin naming convention (Show SxxExx - Quality.ext)
Map {
/// Directory containing episode files
path: PathBuf,
/// Show name (used in filenames)
#[arg(long)]
show: String,
/// Season number
#[arg(long)]
season: i32,
/// First episode number (defaults to 1, increments per file)
#[arg(long, default_value_t = 1)]
episode_start: i32,
/// Preview changes without renaming
#[arg(long)]
dry_run: bool,
},
/// Tag media files in a directory (persisted to .moviemapper.json)
Tag {
/// Directory containing media files
path: PathBuf,
/// Tag to apply (e.g. extra, commentary, behind the scenes)
tag: String,
},
/// Remove a tag from media files in a directory
Untag {
/// Directory containing media files
path: PathBuf,
/// Tag to remove
tag: String,
},
/// Move tagged files into the Jellyfin folder for that tag
Move {
/// Directory containing tagged media files
path: PathBuf,
/// Tag whose files should be moved
tag: String,
/// Preview moves without changing the filesystem
#[arg(long)]
dry_run: bool,
},
/// TheTVDB operations
Tvdb {
#[command(subcommand)]
command: TvdbCommands,
},
/// Show the audit log for a directory
Audit {
/// Directory whose .audit file should be read
path: PathBuf,
},
}
#[derive(Subcommand, Debug)]
pub enum TvdbCommands {
/// Search for shows by name
Search {
/// Search query
query: String,
},
/// Show details (seasons and episodes) for a show ID
Show {
/// TheTVDB show ID
id: i64,
},
}
/// Jellyfin extras folder names (see docs.jellyfin.org).
pub const JELLYFIN_EXTRAS_FOLDERS: &[&str] = &[
"behind the scenes",
"deleted scenes",
"interviews",
"scenes",
"samples",
"shorts",
"featurettes",
"clips",
"other",
"extras",
"trailers",
"theme-music",
"backdrops",
];
/// Resolve the Jellyfin target folder for a tag.
///
/// `extra` maps to `extras`, `commentary` maps to `commentary`,
/// any other tag must be a valid Jellyfin extras folder name.
pub fn resolve_extras_folder(tag: &str) -> Result<String> {
match tag {
"extra" => Ok("extras".to_string()),
"commentary" => Ok("commentary".to_string()),
other if JELLYFIN_EXTRAS_FOLDERS.contains(&other) => Ok(other.to_string()),
other => Err(MovieMapperError::General(format!(
"unknown tag '{other}'; expected extra, commentary, or one of: {}",
JELLYFIN_EXTRAS_FOLDERS.join(", ")
))),
}
}
/// Configuration for the CLI.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct Config {
/// TheTVDB API key
pub tvdb_api_key: Option<String>,
/// Default media directory
pub media_dir: Option<PathBuf>,
}
impl Config {
/// Load configuration from a TOML file. Missing file yields defaults.
pub fn load(path: &Path) -> AnyResult<Config> {
if !path.exists() {
return Ok(Config::default());
}
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read config file {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("failed to parse config file {}", path.display()))?;
Ok(config)
}
/// Default config location: ~/.config/movemapper/config.toml
pub fn default_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".config/movemapper/config.toml"))
}
/// Resolve the TheTVDB API key: explicit flag > environment > config file.
pub fn resolve_api_key(&self, cli_key: Option<&str>) -> Option<String> {
if let Some(key) = cli_key {
if !key.is_empty() {
return Some(key.to_string());
}
}
if let Ok(key) = std::env::var("TVDB_API_KEY") {
if !key.is_empty() {
return Some(key);
}
}
self.tvdb_api_key.clone()
}
}
/// A planned rename operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MappingPlan {
/// Current path
pub from: PathBuf,
/// Target path
pub to: PathBuf,
}
/// Plan renames for a set of media files.
///
/// Files are numbered sequentially starting at `episode_start`, in the order
/// provided (typically sorted by name). Folders and files already matching
/// the target pattern are skipped.
pub fn plan_map(files: &[MediaFile], show_name: &str, season: i32, episode_start: i32) -> Vec<MappingPlan> {
let mapper = FileMapper::new();
let mut plans = Vec::new();
let mut episode = episode_start;
for file in files {
if file.is_folder {
continue;
}
let extension = file
.path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_string();
let quality = if file.quality.is_empty() || file.quality == "unknown" {
String::new()
} else {
file.quality.clone()
};
let target_name =
mapper.generate_jellyfin_filename(show_name, season, episode, episode, &quality, &extension);
let to = file.path.parent().map(|p| p.join(&target_name)).unwrap_or_else(|| {
PathBuf::from(target_name)
});
if to == file.path {
// Already correctly named
continue;
}
plans.push(MappingPlan {
from: file.path.clone(),
to,
});
episode += 1;
}
plans
}
/// Apply a mapping plan, returning (success_count, error_count).
pub fn apply_plan(plans: &[MappingPlan]) -> (u32, u32) {
let mut success = 0u32;
let mut errors = 0u32;
for plan in plans {
if plan.to.exists() {
eprintln!(
"skipping {}: target already exists ({})",
plan.from.display(),
plan.to.display()
);
errors += 1;
continue;
}
match fs::rename(&plan.from, &plan.to) {
Ok(()) => success += 1,
Err(e) => {
eprintln!("failed to rename {}: {}", plan.from.display(), e);
errors += 1;
}
}
}
(success, errors)
}
/// Read and pretty-print the audit log for a directory.
///
/// Entries are JSON lines written by `AuditLogger`. The action field is a
/// tagged object: `{"action": "<name>", ...fields}`.
pub fn print_audit(path: &Path) -> AnyResult<()> {
let audit_path = path.join(".audit");
if !audit_path.exists() {
println!("No audit log found at {}", audit_path.display());
return Ok(());
}
let content = fs::read_to_string(&audit_path)
.with_context(|| format!("failed to read {}", audit_path.display()))?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(line) {
Ok(entry) => {
let timestamp = entry
.get("timestamp")
.and_then(|t| t.as_str())
.unwrap_or("?");
let (action, details) = match entry.get("action") {
Some(serde_json::Value::Object(map)) => {
let name = map
.get("action")
.and_then(|a| a.as_str())
.unwrap_or("?")
.to_string();
let details = map
.iter()
.filter(|(k, _)| k.as_str() != "action")
.map(|(k, v)| format!("{k}={}", v.as_str().unwrap_or(&v.to_string())))
.collect::<Vec<_>>()
.join(" ");
(name, details)
}
_ => ("?".to_string(), String::new()),
};
if details.is_empty() {
println!("{timestamp:<32} {action}");
} else {
println!("{timestamp:<32} {:<20} {details}", action);
}
}
Err(_) => println!("(unparseable line: {line})"),
}
}
Ok(())
}
/// Tag store file name, persisted inside the media directory.
pub const TAG_STORE: &str = ".moviemapper.json";
/// Load the tag store from a media directory (empty if absent).
pub fn load_tag_store(dir: &Path) -> TagManager {
let mut manager = TagManager::new();
let store_path = dir.join(TAG_STORE);
if store_path.exists() {
if let Err(e) = manager.load_tags_from_file(&store_path) {
eprintln!("warning: could not load tag store: {e}");
}
}
manager
}
/// Save the tag store into a media directory.
pub fn save_tag_store(manager: &TagManager, dir: &Path) -> AnyResult<()> {
manager
.save_tags_to_file(&dir.join(TAG_STORE))
.map_err(|e| anyhow::anyhow!("failed to save tag store: {e}"))
}
/// Entry point for the CLI.
pub async fn run() -> AnyResult<()> {
let cli = Cli::parse();
let filter = if cli.verbose {
"movie_mapper=debug,tokio=debug"
} else {
"movie_mapper=warn"
};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()))
.with(tracing_subscriber::fmt::layer())
.init();
dotenv::dotenv().ok();
let config_path = cli.config.clone().or_else(Config::default_path);
let config = match &config_path {
Some(p) => Config::load(p)?,
None => Config::default(),
};
match &cli.command {
Commands::Scan { path, json } => cmd_scan(path, *json).await,
Commands::Map {
path,
show,
season,
episode_start,
dry_run,
} => cmd_map(path, show, *season, *episode_start, *dry_run).await,
Commands::Tag { path, tag } => cmd_tag(path, tag).await,
Commands::Untag { path, tag } => cmd_untag(path, tag).await,
Commands::Move { path, tag, dry_run } => cmd_move(path, tag, *dry_run).await,
Commands::Tvdb { command } => {
let api_key = config.resolve_api_key(cli.tvdb_key.as_deref()).ok_or_else(|| {
anyhow::anyhow!("TheTVDB API key required: set TVDB_API_KEY, --tvdb-key, or config file")
})?;
cmd_tvdb(command, &api_key).await
}
Commands::Audit { path } => print_audit(path),
}
}
async fn cmd_scan(path: &Path, json: bool) -> AnyResult<()> {
if !path.is_dir() {
anyhow::bail!("not a directory: {}", path.display());
}
let scanner = FileScanner::new();
let files = scanner
.scan_directory(path, None)
.await
.with_context(|| format!("failed to scan {}", path.display()))?;
println!("Scanned {}: {} entries", path.display(), files.len());
if json {
println!("{}", serde_json::to_string_pretty(&files)?);
} else {
for file in &files {
if file.is_folder {
println!("[dir] {}", file.name);
} else {
println!(
"[file] {:<40} {:>10} {} {}",
file.name,
human_size(file.size),
file.duration,
file.quality
);
}
}
}
Ok(())
}
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
format!("{size:.1} {}", UNITS[unit])
}
async fn cmd_map(
path: &Path,
show: &str,
season: i32,
episode_start: i32,
dry_run: bool,
) -> AnyResult<()> {
if !path.is_dir() {
anyhow::bail!("not a directory: {}", path.display());
}
let scanner = FileScanner::new();
let mut files = scanner
.scan_directory(path, None)
.await
.with_context(|| format!("failed to scan {}", path.display()))?;
files.sort_by(|a, b| a.name.cmp(&b.name));
let plans = plan_map(&files, show, season, episode_start);
if plans.is_empty() {
println!("Nothing to rename in {}", path.display());
return Ok(());
}
if dry_run {
println!("Dry run — planned renames in {}:", path.display());
for plan in &plans {
println!(" {} -> {}", plan.from.file_name().unwrap().to_string_lossy(), plan.to.file_name().unwrap().to_string_lossy());
}
println!("{} files would be renamed", plans.len());
return Ok(());
}
let (success, errors) = apply_plan(&plans);
for plan in &plans {
if plan.from.exists() {
// Not renamed (target existed) — skip audit
continue;
}
let logger = AuditLogger::new(path.to_str().unwrap_or("."));
let _ = logger.log_event(AuditAction::RenameFile {
old_path: plan.from.to_string_lossy().to_string(),
new_path: plan.to.to_string_lossy().to_string(),
old_name: plan.from.file_name().unwrap().to_string_lossy().to_string(),
new_name: plan.to.file_name().unwrap().to_string_lossy().to_string(),
});
}
let _ = AuditLogger::new(path.to_str().unwrap_or(".")).log_event(AuditAction::MapFiles {
directory: path.to_string_lossy().to_string(),
renamed_count: success,
error_count: errors,
});
println!("Renamed {} files, {} errors", success, errors);
if errors > 0 {
anyhow::bail!("completed with {errors} errors");
}
Ok(())
}
async fn cmd_tag(path: &Path, tag: &str) -> AnyResult<()> {
if !path.is_dir() {
anyhow::bail!("not a directory: {}", path.display());
}
resolve_extras_folder(tag)?;
let scanner = FileScanner::new();
let files = scanner
.scan_directory(path, None)
.await
.with_context(|| format!("failed to scan {}", path.display()))?;
let mut manager = load_tag_store(path);
let mut tagged = 0u32;
for file in &files {
if file.is_folder {
continue;
}
manager.add_tag(&file.path, tag)?;
tagged += 1;
let _ = AuditLogger::new(path.to_str().unwrap_or(".")).log_event(AuditAction::TagFile {
file_path: file.path.to_string_lossy().to_string(),
tag: tag.to_string(),
});
}
save_tag_store(&manager, path)?;
println!("Tagged {tagged} files in {} as '{tag}'", path.display());
Ok(())
}
async fn cmd_untag(path: &Path, tag: &str) -> AnyResult<()> {
if !path.is_dir() {
anyhow::bail!("not a directory: {}", path.display());
}
let mut manager = load_tag_store(path);
let mut untagged = 0u32;
let store: HashMap<PathBuf, Vec<String>> = manager.get_tags_all();
for file_path in store.keys() {
if manager.has_tag(file_path, tag) {
manager.remove_tag(file_path, tag)?;
untagged += 1;
}
}
save_tag_store(&manager, path)?;
println!("Removed '{tag}' tag from {untagged} files in {}", path.display());
Ok(())
}
async fn cmd_move(path: &Path, tag: &str, dry_run: bool) -> AnyResult<()> {
if !path.is_dir() {
anyhow::bail!("not a directory: {}", path.display());
}
let folder = resolve_extras_folder(tag)?;
let target = path.join(&folder);
let manager = load_tag_store(path);
let tagged_files = manager.get_tagged_files(tag);
let files: Vec<PathBuf> = tagged_files.into_iter().filter(|p| p.exists()).collect();
if files.is_empty() {
println!("No files tagged '{tag}' in {}", path.display());
return Ok(());
}
if dry_run {
println!("Dry run — files that would move to {}:", target.display());
for file in &files {
println!(" {}", file.file_name().unwrap().to_string_lossy());
}
println!("{} files would be moved", files.len());
return Ok(());
}
let moved = manager
.move_tagged_files(tag, &target)
.await
.with_context(|| "failed to move tagged files")?;
let mut manager = load_tag_store(path);
for file in &files {
manager.remove_tag(file, tag)?;
}
save_tag_store(&manager, path)?;
for file in &files {
let dest = target.join(file.file_name().unwrap());
if dest.exists() {
let _ = AuditLogger::new(path.to_str().unwrap_or(".")).log_event(AuditAction::MoveFile {
original_path: file.to_string_lossy().to_string(),
new_path: dest.to_string_lossy().to_string(),
folder: folder.clone(),
});
}
}
println!("Moved {moved} files to {}", target.display());
Ok(())
}
async fn cmd_tvdb(command: &TvdbCommands, api_key: &str) -> AnyResult<()> {
let mut tvdb = TVDBClient::new(api_key)
.with_context(|| "failed to initialize TVDB client")?;
tvdb
.authenticate()
.await
.with_context(|| "TVDB authentication failed")?;
match command {
TvdbCommands::Search { query } => {
let shows = tvdb.search(query).await?;
println!("Found {} shows for '{query}':", shows.len());
for show in &shows {
println!(" {:<6} {}", show.id, show.series_name);
}
}
TvdbCommands::Show { id } => {
let details = tvdb.get_show_details(*id).await?;
println!(
"Show {} (id {}): {}",
details.name, details.id, details.status
);
for season in &details.seasons {
println!(
" Season {}: {} episodes ({})",
season.number, season.episode_count, season.type_name
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn media_file(name: &str, quality: &str, is_folder: bool) -> MediaFile {
MediaFile {
path: PathBuf::from(format!("/media/{name}")),
name: name.to_string(),
size: 1024,
modified: Utc::now(),
duration: "00:00".to_string(),
quality: quality.to_string(),
fps: "unknown".to_string(),
is_folder,
is_problematic: false,
tags: Vec::new(),
}
}
#[test]
fn test_resolve_extras_folder() {
assert_eq!(resolve_extras_folder("extra").unwrap(), "extras");
assert_eq!(resolve_extras_folder("commentary").unwrap(), "commentary");
assert_eq!(
resolve_extras_folder("behind the scenes").unwrap(),
"behind the scenes"
);
assert!(resolve_extras_folder("bogus").is_err());
}
#[test]
fn test_plan_map_numbers_episodes() {
let files = vec![
media_file("ep1.mkv", "1080p", false),
media_file("ep2.mkv", "1080p", false),
media_file("subdir", "", true),
];
let plans = plan_map(&files, "Test Show", 1, 1);
assert_eq!(plans.len(), 2);
assert_eq!(
plans[0].to.file_name().unwrap(),
"Test Show S01E01 - 1080p.mkv"
);
assert_eq!(
plans[1].to.file_name().unwrap(),
"Test Show S01E02 - 1080p.mkv"
);
}
#[test]
fn test_plan_map_skips_correctly_named() {
let files = vec![media_file("Test Show S01E01.mkv", "unknown", false)];
let plans = plan_map(&files, "Test Show", 1, 1);
assert!(plans.is_empty());
}
#[test]
fn test_plan_map_episode_start() {
let files = vec![media_file("a.mkv", "720p", false)];
let plans = plan_map(&files, "Show", 2, 5);
assert_eq!(
plans[0].to.file_name().unwrap(),
"Show S02E05 - 720p.mkv"
);
}
#[test]
fn test_plan_map_unknown_quality_omitted() {
let files = vec![media_file("a.mkv", "unknown", false)];
let plans = plan_map(&files, "Show", 1, 1);
assert_eq!(plans[0].to.file_name().unwrap(), "Show S01E01.mkv");
}
#[test]
fn test_apply_plan_renames_and_counts_conflicts() {
let dir = tempfile::tempdir().unwrap();
let from1 = dir.path().join("a.mkv");
let from2 = dir.path().join("b.mkv");
let to1 = dir.path().join("Show S01E01.mkv");
let to2 = dir.path().join("Show S01E02.mkv");
fs::write(&from1, b"x").unwrap();
fs::write(&from2, b"y").unwrap();
fs::write(&to2, b"existing").unwrap();
let plans = vec![
MappingPlan { from: from1.clone(), to: to1.clone() },
MappingPlan { from: from2.clone(), to: to2.clone() },
];
let (success, errors) = apply_plan(&plans);
assert_eq!(success, 1);
assert_eq!(errors, 1);
assert!(!from1.exists());
assert!(to1.exists());
assert!(from2.exists());
}
#[test]
fn test_config_load_toml() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
"tvdb_api_key = \"secret\"\nmedia_dir = \"/media\"\n",
)
.unwrap();
let config = Config::load(&config_path).unwrap();
assert_eq!(config.tvdb_api_key.as_deref(), Some("secret"));
assert_eq!(config.media_dir, Some(PathBuf::from("/media")));
}
#[test]
fn test_config_missing_file_defaults() {
let config = Config::load(Path::new("/nonexistent/config.toml")).unwrap();
assert!(config.tvdb_api_key.is_none());
}
#[test]
fn test_resolve_api_key_precedence() {
let config = Config {
tvdb_api_key: Some("file-key".to_string()),
..Default::default()
};
// CLI flag wins over everything
assert_eq!(
config.resolve_api_key(Some("cli-key")).as_deref(),
Some("cli-key")
);
// Config file used when no env var
std::env::remove_var("TVDB_API_KEY");
assert_eq!(config.resolve_api_key(None).as_deref(), Some("file-key"));
}
#[test]
fn test_tag_store_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut manager = TagManager::new();
let file = dir.path().join("movie.mkv");
manager.add_tag(&file, "extra").unwrap();
save_tag_store(&manager, dir.path()).unwrap();
let reloaded = load_tag_store(dir.path());
assert!(reloaded.has_tag(&file, "extra"));
}
}

View File

@ -45,6 +45,7 @@
//! } //! }
//! ``` //! ```
pub mod cli;
pub mod model; pub mod model;
pub mod service; pub mod service;
pub mod utils; pub mod utils;

View File

@ -1,60 +1,9 @@
use movie_mapper::service::file_scanner::FileScanner; use movie_mapper::cli;
use movie_mapper::service::tvdb_api::TVDBClient;
use std::path::Path;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Initialize tracing subscriber for logging if let Err(e) = cli::run().await {
tracing_subscriber::registry() eprintln!("error: {e:#}");
.with( std::process::exit(1);
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "movie_mapper=debug,tokio=debug,tower=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Load environment variables
dotenv::dotenv().ok();
// Initialize file scanner
let scanner = FileScanner::new();
// Get directory from command line or use current directory
let args: Vec<String> = std::env::args().collect();
let directory = args.get(1).map_or_else(
|| std::env::current_dir().expect("Failed to get current directory"),
|arg| Path::new(arg).to_path_buf(),
);
println!("Scanning directory: {:?}", directory);
// Scan the directory
match scanner.scan_directory(&directory, None).await {
Ok(files) => {
println!("Found {} files/folders", files.len());
for file in &files {
println!(" - {} (folder: {})", file.name, file.is_folder);
}
}
Err(e) => {
eprintln!("Error scanning directory: {}", e);
}
}
// Test TVDB API if API key is available
if let Ok(api_key) = std::env::var("TVDB_API_KEY") {
println!("\nTesting TVDB API...");
match TVDBClient::new(&api_key) {
Ok(mut tvdb) => {
if (tvdb.authenticate().await).is_ok() {
println!("✅ TVDB API authentication successful");
}
}
Err(e) => {
eprintln!("Failed to initialize TVDB client: {}", e);
}
}
} else {
println!("\nTVDB_API_KEY not set, skipping TVDB API test");
} }
} }

View File

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use std::path::PathBuf; use std::path::PathBuf;
/// Represents a media file or folder in the scanned directory /// Represents a media file or folder in the scanned directory
#[derive(Debug, Clone)] #[derive(Debug, Clone, serde::Serialize)]
pub struct MediaFile { pub struct MediaFile {
pub path: PathBuf, pub path: PathBuf,
pub name: String, pub name: String,

View File

@ -106,7 +106,12 @@ impl FileMapper {
filename.push_str(&format!(" - {}", quality)); filename.push_str(&format!(" - {}", quality));
} }
filename.push_str(extension); if !extension.is_empty() {
if !extension.starts_with('.') {
filename.push('.');
}
filename.push_str(extension);
}
filename filename
} }

View File

@ -92,6 +92,11 @@ impl TagManager {
Ok(moved_count) Ok(moved_count)
} }
/// Get all stored tags as a map of path -> tags
pub fn get_tags_all(&self) -> HashMap<PathBuf, Vec<String>> {
self.tag_cache.clone()
}
/// Clear all tags /// Clear all tags
pub fn clear_tags(&mut self) { pub fn clear_tags(&mut self) {
self.tag_cache.clear(); self.tag_cache.clear();

View File

@ -2,4 +2,6 @@
pub mod error; pub mod error;
pub use error::{FileError, MappingError, MetadataError, Result, ScannerError, TVDBError}; pub use error::{
FileError, MappingError, MetadataError, MovieMapperError, Result, ScannerError, TVDBError,
};