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:
parent
0447acd6c5
commit
e3d130091a
145
Cargo.lock
generated
145
Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
39
README.md
39
README.md
@ -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
|
||||||
|
|||||||
@ -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
790
Rust/src/cli.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user