Compare commits

3 Commits
v0.1.0 ... main

16 changed files with 737 additions and 20 deletions

10
Cargo.lock generated
View File

@@ -446,6 +446,15 @@ dependencies = [
"strsim", "strsim",
] ]
[[package]]
name = "clap_complete"
version = "4.5.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.49" version = "4.5.49"
@@ -1314,6 +1323,7 @@ dependencies = [
"assert_cmd", "assert_cmd",
"chrono", "chrono",
"clap", "clap",
"clap_complete",
"csv", "csv",
"directories", "directories",
"httpmock", "httpmock",

View File

@@ -13,6 +13,7 @@ categories = ["command-line-utilities", "filesystem"]
anyhow = "1.0" anyhow = "1.0"
chrono = "0.4" chrono = "0.4"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
clap_complete = "4.5"
csv = "1.3" csv = "1.3"
directories = "5.0" directories = "5.0"
is-terminal = "0.4" is-terminal = "0.4"

View File

@@ -63,6 +63,26 @@ Common flags:
- `--quality-tags resolution,codec,source` - `--quality-tags resolution,codec,source`
- `--min-score 0-100` (match threshold) - `--min-score 0-100` (match threshold)
- `--jobs auto|N` and `--net-jobs auto|N` - `--jobs auto|N` and `--net-jobs auto|N`
- `--dry-run-summary` (suppress per-file output in dry-run)
- `--explain` (show top candidates when skipped)
- `--print-config` (print effective config and exit)
- `--completions bash|zsh|fish`
## Help & version :information_source:
```bash
mov-renamarr --help
mov-renamarr --version
```
## Shell completions :shell:
Generate completions:
```bash
mov-renamarr --completions bash
mov-renamarr --completions zsh
mov-renamarr --completions fish
```
Pre-generated scripts are also available in `completions/`.
## Configuration :gear: ## Configuration :gear:
Default config location: Default config location:

70
completions/_mov-renamarr Normal file
View File

@@ -0,0 +1,70 @@
#compdef mov-renamarr
autoload -U is-at-least
_mov-renamarr() {
typeset -A opt_args
typeset -a _arguments_options
local ret=1
if is-at-least 5.2; then
_arguments_options=(-s -S -C)
else
_arguments_options=(-s -C)
fi
local context curcontext="$curcontext" state line
_arguments "${_arguments_options[@]}" : \
'--input=[]:DIR:_files' \
'--output=[]:DIR:_files' \
'--config=[]:PATH:_files' \
'--provider=[]:PROVIDER:(auto omdb tmdb both)' \
'--api-key-omdb=[]:API_KEY_OMDB:_default' \
'--api-key-tmdb=[]:API_KEY_TMDB:_default' \
'--cache=[]:PATH:_files' \
'--report=[]' \
'--report-format=[]:REPORT_FORMAT:(text json csv)' \
'--min-score=[]:MIN_SCORE:_default' \
'--quality-tags=[]:LIST:_default' \
'--color=[]:COLOR:(auto always never)' \
'--llm-mode=[]:LLM_MODE:(off parse assist)' \
'--llm-endpoint=[]:URL:_default' \
'--llm-model=[]:NAME:_default' \
'--llm-timeout=[]:SECONDS:_default' \
'--llm-max-tokens=[]:N:_default' \
'--jobs=[]:JOBS:_default' \
'--net-jobs=[]:NET_JOBS:_default' \
'--completions=[]:COMPLETIONS:(bash zsh fish)' \
'--refresh-cache[]' \
'--dry-run[]' \
'--dry-run-summary[]' \
'(--rename-in-place)--move[]' \
'(--move)--rename-in-place[]' \
'--interactive[]' \
'--sidecar-notes[]' \
'--sidecars[]' \
'--overwrite[]' \
'--suffix[]' \
'--include-id[]' \
'--no-lookup[]' \
'--explain[]' \
'--print-config[]' \
'--verbose[]' \
'-h[Print help]' \
'--help[Print help]' \
'-V[Print version]' \
'--version[Print version]' \
&& ret=0
}
(( $+functions[_mov-renamarr_commands] )) ||
_mov-renamarr_commands() {
local commands; commands=()
_describe -t commands 'mov-renamarr commands' commands "$@"
}
if [ "$funcstack[1]" = "_mov-renamarr" ]; then
_mov-renamarr "$@"
else
compdef _mov-renamarr mov-renamarr
fi

View File

@@ -0,0 +1,126 @@
_mov-renamarr() {
local i cur prev opts cmd
COMPREPLY=()
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
cur="$2"
else
cur="${COMP_WORDS[COMP_CWORD]}"
fi
prev="$3"
cmd=""
opts=""
for i in "${COMP_WORDS[@]:0:COMP_CWORD}"
do
case "${cmd},${i}" in
",$1")
cmd="mov__renamarr"
;;
*)
;;
esac
done
case "${cmd}" in
mov__renamarr)
opts="-h -V --input --output --config --provider --api-key-omdb --api-key-tmdb --cache --refresh-cache --dry-run --dry-run-summary --move --rename-in-place --interactive --report --report-format --sidecar-notes --sidecars --overwrite --suffix --min-score --include-id --quality-tags --color --llm-mode --llm-endpoint --llm-model --llm-timeout --llm-max-tokens --jobs --net-jobs --no-lookup --explain --print-config --completions --verbose --help --version"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--input)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--output)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--config)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "auto omdb tmdb both" -- "${cur}"))
return 0
;;
--api-key-omdb)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--api-key-tmdb)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--cache)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--report)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--report-format)
COMPREPLY=($(compgen -W "text json csv" -- "${cur}"))
return 0
;;
--min-score)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--quality-tags)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--color)
COMPREPLY=($(compgen -W "auto always never" -- "${cur}"))
return 0
;;
--llm-mode)
COMPREPLY=($(compgen -W "off parse assist" -- "${cur}"))
return 0
;;
--llm-endpoint)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--llm-model)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--llm-timeout)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--llm-max-tokens)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--jobs)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--net-jobs)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--completions)
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
esac
}
if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
complete -F _mov-renamarr -o nosort -o bashdefault -o default mov-renamarr
else
complete -F _mov-renamarr -o bashdefault -o default mov-renamarr
fi

View File

@@ -0,0 +1,48 @@
complete -c mov-renamarr -l input -r -F
complete -c mov-renamarr -l output -r -F
complete -c mov-renamarr -l config -r -F
complete -c mov-renamarr -l provider -r -f -a "auto\t''
omdb\t''
tmdb\t''
both\t''"
complete -c mov-renamarr -l api-key-omdb -r
complete -c mov-renamarr -l api-key-tmdb -r
complete -c mov-renamarr -l cache -r -F
complete -c mov-renamarr -l report -r -F
complete -c mov-renamarr -l report-format -r -f -a "text\t''
json\t''
csv\t''"
complete -c mov-renamarr -l min-score -r
complete -c mov-renamarr -l quality-tags -r
complete -c mov-renamarr -l color -r -f -a "auto\t''
always\t''
never\t''"
complete -c mov-renamarr -l llm-mode -r -f -a "off\t''
parse\t''
assist\t''"
complete -c mov-renamarr -l llm-endpoint -r
complete -c mov-renamarr -l llm-model -r
complete -c mov-renamarr -l llm-timeout -r
complete -c mov-renamarr -l llm-max-tokens -r
complete -c mov-renamarr -l jobs -r
complete -c mov-renamarr -l net-jobs -r
complete -c mov-renamarr -l completions -r -f -a "bash\t''
zsh\t''
fish\t''"
complete -c mov-renamarr -l refresh-cache
complete -c mov-renamarr -l dry-run
complete -c mov-renamarr -l dry-run-summary
complete -c mov-renamarr -l move
complete -c mov-renamarr -l rename-in-place
complete -c mov-renamarr -l interactive
complete -c mov-renamarr -l sidecar-notes
complete -c mov-renamarr -l sidecars
complete -c mov-renamarr -l overwrite
complete -c mov-renamarr -l suffix
complete -c mov-renamarr -l include-id
complete -c mov-renamarr -l no-lookup
complete -c mov-renamarr -l explain
complete -c mov-renamarr -l print-config
complete -c mov-renamarr -l verbose
complete -c mov-renamarr -s h -l help -d 'Print help'
complete -c mov-renamarr -s V -l version -d 'Print version'

View File

@@ -43,6 +43,7 @@ refresh_cache = false
report_format = "text" # text|json|csv report_format = "text" # text|json|csv
sidecar_notes = false # write per-file notes for skipped/failed sidecar_notes = false # write per-file notes for skipped/failed
sidecars = false # move/copy sidecar files (srt, nfo, etc) sidecars = false # move/copy sidecar files (srt, nfo, etc)
dry_run_summary = false # suppress per-file output (use with --dry-run)
``` ```
## Matching and naming ## Matching and naming
@@ -50,6 +51,7 @@ sidecars = false # move/copy sidecar files (srt, nfo, etc)
min_score = 80 # 0-100 match threshold min_score = 80 # 0-100 match threshold
include_id = false # include tmdb/imdb id in filenames include_id = false # include tmdb/imdb id in filenames
no_lookup = false # skip external providers (filename/LLM only) no_lookup = false # skip external providers (filename/LLM only)
explain = false # show top candidates when skipped
``` ```
## Quality tags ## Quality tags

View File

@@ -6,6 +6,8 @@ This repo includes a Gitea Actions workflow that builds Linux binaries for
## One-time setup (Gitea Actions) ## One-time setup (Gitea Actions)
1) Create a personal access token with repo write access. 1) Create a personal access token with repo write access.
2) Add it to the repo secrets as `RELEASE_TOKEN`. 2) Add it to the repo secrets as `RELEASE_TOKEN`.
3) Ensure at least one Gitea Actions runner is online.
4) If uploads fail with `413 Request Entity Too Large`, increase your reverse proxy upload limit (e.g., nginx `client_max_body_size`).
## Release steps ## Release steps
1) Update `CHANGELOG.md`. 1) Update `CHANGELOG.md`.

View File

@@ -2,13 +2,13 @@ use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use serde::Deserialize; use serde::{Deserialize, Serialize};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "mov-renamarr", version, about = "Rename movie files into Radarr-compatible naming")] #[command(name = "mov-renamarr", version, about = "Rename movie files into Radarr-compatible naming")]
pub struct Cli { pub struct Cli {
#[arg(long, value_name = "DIR")] #[arg(long, value_name = "DIR", required_unless_present_any = ["completions"])]
pub input: PathBuf, pub input: Option<PathBuf>,
#[arg(long, value_name = "DIR")] #[arg(long, value_name = "DIR")]
pub output: Option<PathBuf>, pub output: Option<PathBuf>,
@@ -34,6 +34,9 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub dry_run: bool, pub dry_run: bool,
#[arg(long)]
pub dry_run_summary: bool,
#[arg(long = "move", conflicts_with = "rename_in_place")] #[arg(long = "move", conflicts_with = "rename_in_place")]
pub move_files: bool, pub move_files: bool,
@@ -102,11 +105,20 @@ pub struct Cli {
#[arg(long, alias = "offline")] #[arg(long, alias = "offline")]
pub no_lookup: bool, pub no_lookup: bool,
#[arg(long)]
pub explain: bool,
#[arg(long)]
pub print_config: bool,
#[arg(long, value_enum)]
pub completions: Option<CompletionShell>,
#[arg(long)] #[arg(long)]
pub verbose: bool, pub verbose: bool,
} }
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] #[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ProviderChoice { pub enum ProviderChoice {
Auto, Auto,
@@ -115,7 +127,7 @@ pub enum ProviderChoice {
Both, Both,
} }
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] #[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ReportFormat { pub enum ReportFormat {
Text, Text,
@@ -123,7 +135,7 @@ pub enum ReportFormat {
Csv, Csv,
} }
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] #[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ColorMode { pub enum ColorMode {
Auto, Auto,
@@ -131,7 +143,7 @@ pub enum ColorMode {
Never, Never,
} }
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] #[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum LlmMode { pub enum LlmMode {
Off, Off,
@@ -139,6 +151,13 @@ pub enum LlmMode {
Assist, Assist,
} }
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq)]
pub enum CompletionShell {
Bash,
Zsh,
Fish,
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum JobsArg { pub enum JobsArg {
Auto, Auto,

View File

@@ -5,7 +5,7 @@ use std::str::FromStr;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use directories::BaseDirs; use directories::BaseDirs;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::cli::{Cli, ColorMode, JobsArg, LlmMode, ProviderChoice, ReportFormat}; use crate::cli::{Cli, ColorMode, JobsArg, LlmMode, ProviderChoice, ReportFormat};
@@ -34,10 +34,12 @@ pub struct Settings {
pub net_jobs: usize, pub net_jobs: usize,
pub no_lookup: bool, pub no_lookup: bool,
pub dry_run: bool, pub dry_run: bool,
pub dry_run_summary: bool,
pub move_files: bool, pub move_files: bool,
pub rename_in_place: bool, pub rename_in_place: bool,
pub interactive: bool, pub interactive: bool,
pub verbose: bool, pub verbose: bool,
pub explain: bool,
pub omdb_base_url: String, pub omdb_base_url: String,
pub tmdb_base_url: String, pub tmdb_base_url: String,
} }
@@ -59,6 +61,25 @@ impl Default for QualityTags {
} }
} }
impl QualityTags {
pub fn to_list(&self) -> Vec<String> {
let mut tags = Vec::new();
if self.resolution {
tags.push("resolution".to_string());
}
if self.codec {
tags.push("codec".to_string());
}
if self.source {
tags.push("source".to_string());
}
if tags.is_empty() {
tags.push("none".to_string());
}
tags
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct LlmSettings { pub struct LlmSettings {
pub mode: LlmMode, pub mode: LlmMode,
@@ -68,6 +89,50 @@ pub struct LlmSettings {
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
} }
#[derive(Debug, Serialize)]
struct PrintableSettings {
input: String,
output: String,
provider: ProviderChoice,
api_key_omdb: Option<String>,
api_key_tmdb: Option<String>,
cache_path: String,
cache_ttl_days: u32,
refresh_cache: bool,
report_format: ReportFormat,
report_path: Option<String>,
sidecar_notes: bool,
sidecars: bool,
dry_run_summary: bool,
overwrite: bool,
suffix: bool,
min_score: u8,
include_id: bool,
quality_tags: Vec<String>,
color: ColorMode,
jobs: usize,
net_jobs: usize,
no_lookup: bool,
dry_run: bool,
move_files: bool,
rename_in_place: bool,
interactive: bool,
verbose: bool,
explain: bool,
omdb_base_url: String,
tmdb_base_url: String,
llm: PrintableLlm,
}
#[derive(Debug, Serialize)]
struct PrintableLlm {
mode: LlmMode,
endpoint: String,
model: Option<String>,
timeout_seconds: u64,
max_tokens: Option<u32>,
}
impl Default for LlmSettings { impl Default for LlmSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -103,6 +168,8 @@ struct FileConfig {
omdb_base_url: Option<String>, omdb_base_url: Option<String>,
tmdb_base_url: Option<String>, tmdb_base_url: Option<String>,
no_lookup: Option<bool>, no_lookup: Option<bool>,
dry_run_summary: Option<bool>,
explain: Option<bool>,
} }
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default)]
@@ -139,8 +206,11 @@ pub fn build_settings(cli: &Cli) -> Result<Settings> {
} }
let file_config = load_config_file(&config_path)?; let file_config = load_config_file(&config_path)?;
let input = cli.input.clone(); let input = cli
let output = resolve_output(cli)?; .input
.clone()
.ok_or_else(|| anyhow!("--input is required unless --completions is used"))?;
let output = resolve_output(cli, &input)?;
let mut settings = Settings { let mut settings = Settings {
input, input,
@@ -166,10 +236,12 @@ pub fn build_settings(cli: &Cli) -> Result<Settings> {
net_jobs: default_net_jobs(default_jobs()), net_jobs: default_net_jobs(default_jobs()),
no_lookup: false, no_lookup: false,
dry_run: cli.dry_run, dry_run: cli.dry_run,
dry_run_summary: false,
move_files: cli.move_files, move_files: cli.move_files,
rename_in_place: cli.rename_in_place, rename_in_place: cli.rename_in_place,
interactive: cli.interactive, interactive: cli.interactive,
verbose: cli.verbose, verbose: cli.verbose,
explain: false,
omdb_base_url: "https://www.omdbapi.com".to_string(), omdb_base_url: "https://www.omdbapi.com".to_string(),
tmdb_base_url: "https://api.themoviedb.org/3".to_string(), tmdb_base_url: "https://api.themoviedb.org/3".to_string(),
}; };
@@ -189,11 +261,11 @@ pub fn init_default_config() -> Result<PathBuf> {
Ok(config_path) Ok(config_path)
} }
fn resolve_output(cli: &Cli) -> Result<PathBuf> { fn resolve_output(cli: &Cli, input: &Path) -> Result<PathBuf> {
match (cli.rename_in_place, cli.output.as_ref()) { match (cli.rename_in_place, cli.output.as_ref()) {
(true, None) => Ok(cli.input.clone()), (true, None) => Ok(input.to_path_buf()),
(true, Some(out)) => { (true, Some(out)) => {
if out != &cli.input { if out != input {
Err(anyhow!( Err(anyhow!(
"--rename-in-place requires output to be omitted or the same as input" "--rename-in-place requires output to be omitted or the same as input"
)) ))
@@ -202,7 +274,7 @@ fn resolve_output(cli: &Cli) -> Result<PathBuf> {
} }
} }
(false, Some(out)) => { (false, Some(out)) => {
if out == &cli.input { if out == input {
Err(anyhow!( Err(anyhow!(
"output directory must be different from input unless --rename-in-place is set" "output directory must be different from input unless --rename-in-place is set"
)) ))
@@ -287,12 +359,16 @@ fn default_config_template() -> String {
"sidecar_notes = false", "sidecar_notes = false",
"# sidecars copies/moves subtitle/nfo/etc files with the movie file.", "# sidecars copies/moves subtitle/nfo/etc files with the movie file.",
"sidecars = false", "sidecars = false",
"# dry_run_summary suppresses per-file output (use with --dry-run for large batches).",
"dry_run_summary = false",
"# overwrite replaces existing files; suffix adds \" (1)\", \" (2)\", etc.", "# overwrite replaces existing files; suffix adds \" (1)\", \" (2)\", etc.",
"overwrite = false", "overwrite = false",
"suffix = false", "suffix = false",
"# Disable external lookups (use filename/LLM only).", "# Disable external lookups (use filename/LLM only).",
"# When true, provider selection is ignored.", "# When true, provider selection is ignored.",
"no_lookup = false", "no_lookup = false",
"# explain prints top candidate matches when a file is skipped.",
"explain = false",
"# min_score is 0-100 (match confidence threshold).", "# min_score is 0-100 (match confidence threshold).",
"min_score = 80", "min_score = 80",
"# include_id adds tmdb-XXXX or imdb-ttXXXX in the filename.", "# include_id adds tmdb-XXXX or imdb-ttXXXX in the filename.",
@@ -362,6 +438,9 @@ fn apply_file_config(settings: &mut Settings, file: &FileConfig) -> Result<()> {
if let Some(sidecars) = file.sidecars { if let Some(sidecars) = file.sidecars {
settings.sidecars = sidecars; settings.sidecars = sidecars;
} }
if let Some(dry_run_summary) = file.dry_run_summary {
settings.dry_run_summary = dry_run_summary;
}
if let Some(overwrite) = file.overwrite { if let Some(overwrite) = file.overwrite {
settings.overwrite = overwrite; settings.overwrite = overwrite;
} }
@@ -393,6 +472,9 @@ fn apply_file_config(settings: &mut Settings, file: &FileConfig) -> Result<()> {
if let Some(no_lookup) = file.no_lookup { if let Some(no_lookup) = file.no_lookup {
settings.no_lookup = no_lookup; settings.no_lookup = no_lookup;
} }
if let Some(explain) = file.explain {
settings.explain = explain;
}
if let Some(llm) = &file.llm { if let Some(llm) = &file.llm {
apply_file_llm(settings, llm); apply_file_llm(settings, llm);
} }
@@ -469,9 +551,11 @@ fn apply_env_overrides(settings: &mut Settings) -> Result<()> {
apply_env_bool("MOV_RENAMARR_INCLUDE_ID", |value| settings.include_id = value); apply_env_bool("MOV_RENAMARR_INCLUDE_ID", |value| settings.include_id = value);
apply_env_bool("MOV_RENAMARR_SIDECARS", |value| settings.sidecars = value); apply_env_bool("MOV_RENAMARR_SIDECARS", |value| settings.sidecars = value);
apply_env_bool("MOV_RENAMARR_SIDECAR_NOTES", |value| settings.sidecar_notes = value); apply_env_bool("MOV_RENAMARR_SIDECAR_NOTES", |value| settings.sidecar_notes = value);
apply_env_bool("MOV_RENAMARR_DRY_RUN_SUMMARY", |value| settings.dry_run_summary = value);
apply_env_bool("MOV_RENAMARR_OVERWRITE", |value| settings.overwrite = value); apply_env_bool("MOV_RENAMARR_OVERWRITE", |value| settings.overwrite = value);
apply_env_bool("MOV_RENAMARR_SUFFIX", |value| settings.suffix = value); apply_env_bool("MOV_RENAMARR_SUFFIX", |value| settings.suffix = value);
apply_env_bool("MOV_RENAMARR_NO_LOOKUP", |value| settings.no_lookup = value); apply_env_bool("MOV_RENAMARR_NO_LOOKUP", |value| settings.no_lookup = value);
apply_env_bool("MOV_RENAMARR_EXPLAIN", |value| settings.explain = value);
apply_env_string("MOV_RENAMARR_QUALITY_TAGS", |value| { apply_env_string("MOV_RENAMARR_QUALITY_TAGS", |value| {
if let Ok(tags) = parse_quality_tags(&split_list(&value)) { if let Ok(tags) = parse_quality_tags(&split_list(&value)) {
@@ -561,6 +645,9 @@ fn apply_cli_overrides(settings: &mut Settings, cli: &Cli) -> Result<()> {
if cli.sidecars { if cli.sidecars {
settings.sidecars = true; settings.sidecars = true;
} }
if cli.dry_run_summary {
settings.dry_run_summary = true;
}
if cli.overwrite { if cli.overwrite {
settings.overwrite = true; settings.overwrite = true;
} }
@@ -588,6 +675,9 @@ fn apply_cli_overrides(settings: &mut Settings, cli: &Cli) -> Result<()> {
if cli.no_lookup { if cli.no_lookup {
settings.no_lookup = true; settings.no_lookup = true;
} }
if cli.explain {
settings.explain = true;
}
if let Some(mode) = &cli.llm_mode { if let Some(mode) = &cli.llm_mode {
settings.llm.mode = mode.clone(); settings.llm.mode = mode.clone();
} }
@@ -616,6 +706,9 @@ fn validate_settings(settings: &mut Settings) -> Result<()> {
if settings.min_score > 100 { if settings.min_score > 100 {
return Err(anyhow!("min-score must be between 0 and 100")); return Err(anyhow!("min-score must be between 0 and 100"));
} }
if settings.dry_run_summary && !settings.dry_run {
return Err(anyhow!("--dry-run-summary requires --dry-run"));
}
if settings.net_jobs == 0 { if settings.net_jobs == 0 {
settings.net_jobs = 1; settings.net_jobs = 1;
} }
@@ -636,6 +729,61 @@ pub fn default_net_jobs(jobs: usize) -> usize {
std::cmp::max(1, std::cmp::min(2, jobs)) std::cmp::max(1, std::cmp::min(2, jobs))
} }
pub fn format_settings(settings: &Settings) -> Result<String> {
let printable = PrintableSettings::from(settings);
toml::to_string_pretty(&printable).context("failed to serialize settings")
}
impl From<&Settings> for PrintableSettings {
fn from(settings: &Settings) -> Self {
Self {
input: settings.input.display().to_string(),
output: settings.output.display().to_string(),
provider: settings.provider.clone(),
api_key_omdb: settings.api_key_omdb.clone(),
api_key_tmdb: settings.api_key_tmdb.clone(),
cache_path: settings.cache_path.display().to_string(),
cache_ttl_days: settings.cache_ttl_days,
refresh_cache: settings.refresh_cache,
report_format: settings.report_format.clone(),
report_path: settings.report_path.as_ref().map(|p| p.display().to_string()),
sidecar_notes: settings.sidecar_notes,
sidecars: settings.sidecars,
dry_run_summary: settings.dry_run_summary,
overwrite: settings.overwrite,
suffix: settings.suffix,
min_score: settings.min_score,
include_id: settings.include_id,
quality_tags: settings.quality_tags.to_list(),
color: settings.color.clone(),
jobs: settings.jobs,
net_jobs: settings.net_jobs,
no_lookup: settings.no_lookup,
dry_run: settings.dry_run,
move_files: settings.move_files,
rename_in_place: settings.rename_in_place,
interactive: settings.interactive,
verbose: settings.verbose,
explain: settings.explain,
omdb_base_url: settings.omdb_base_url.clone(),
tmdb_base_url: settings.tmdb_base_url.clone(),
llm: PrintableLlm::from(&settings.llm),
}
}
}
impl From<&LlmSettings> for PrintableLlm {
fn from(settings: &LlmSettings) -> Self {
Self {
mode: settings.mode.clone(),
endpoint: settings.endpoint.clone(),
model: settings.model.clone(),
timeout_seconds: settings.timeout_seconds,
max_tokens: settings.max_tokens,
}
}
}
fn parse_jobs_setting(raw: &str, fallback: usize) -> Result<usize> { fn parse_jobs_setting(raw: &str, fallback: usize) -> Result<usize> {
if raw.eq_ignore_ascii_case("auto") { if raw.eq_ignore_ascii_case("auto") {
return Ok(fallback); return Ok(fallback);

View File

@@ -11,9 +11,10 @@ mod report;
mod utils; mod utils;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::{CommandFactory, Parser};
use clap_complete::{generate, Shell};
use crate::cli::Cli; use crate::cli::{Cli, CompletionShell};
fn main() -> Result<()> { fn main() -> Result<()> {
if std::env::args_os().len() == 1 { if std::env::args_os().len() == 1 {
@@ -23,12 +24,38 @@ fn main() -> Result<()> {
} }
let cli = Cli::parse(); let cli = Cli::parse();
if let Some(shell) = cli.completions.clone() {
let mut cmd = Cli::command();
let shell = match shell {
CompletionShell::Bash => Shell::Bash,
CompletionShell::Zsh => Shell::Zsh,
CompletionShell::Fish => Shell::Fish,
};
generate(shell, &mut cmd, "mov-renamarr", &mut std::io::stdout());
return Ok(());
}
let settings = config::build_settings(&cli)?; let settings = config::build_settings(&cli)?;
if cli.print_config {
let rendered = config::format_settings(&settings)?;
println!("{rendered}");
return Ok(());
}
let report_format = settings.report_format.clone(); let report_format = settings.report_format.clone();
let report_path = settings.report_path.clone(); let report_path = settings.report_path.clone();
let dry_run_summary = settings.dry_run_summary;
let report = pipeline::run(settings)?; let report = pipeline::run(settings)?;
report.write(&report_format, report_path.as_deref())?; if dry_run_summary
&& report_path.is_none()
&& matches!(report_format, crate::cli::ReportFormat::Text)
{
report.write_summary(None)?;
} else {
report.write(&report_format, report_path.as_deref())?;
}
Ok(()) Ok(())
} }

View File

@@ -16,11 +16,12 @@ pub enum StatusKind {
pub struct Output { pub struct Output {
use_color: bool, use_color: bool,
verbose: bool, verbose: bool,
summary_only: bool,
lock: Mutex<()>, lock: Mutex<()>,
} }
impl Output { impl Output {
pub fn new(color_mode: &ColorMode, verbose: bool) -> Self { pub fn new(color_mode: &ColorMode, verbose: bool, summary_only: bool) -> Self {
let use_color = match color_mode { let use_color = match color_mode {
ColorMode::Always => true, ColorMode::Always => true,
ColorMode::Never => false, ColorMode::Never => false,
@@ -29,6 +30,7 @@ impl Output {
Self { Self {
use_color, use_color,
verbose, verbose,
summary_only,
lock: Mutex::new(()), lock: Mutex::new(()),
} }
} }
@@ -43,6 +45,9 @@ impl Output {
result: &str, result: &str,
output_name: Option<&str>, output_name: Option<&str>,
) { ) {
if self.summary_only && !matches!(status, StatusKind::Failed) {
return;
}
let _guard = self.lock.lock().unwrap(); let _guard = self.lock.lock().unwrap();
let prefix = format!("[{}/{}]", index, total); let prefix = format!("[{}/{}]", index, total);
let status_label = match status { let status_label = match status {
@@ -74,6 +79,11 @@ impl Output {
eprintln!("{msg}"); eprintln!("{msg}");
} }
pub fn detail(&self, message: &str) {
let _guard = self.lock.lock().unwrap();
println!("{message}");
}
pub fn info(&self, message: &str) { pub fn info(&self, message: &str) {
if self.verbose { if self.verbose {

View File

@@ -40,8 +40,9 @@ pub fn parse_filename(path: &Path) -> FileHints {
let year = extract_year(&stem); let year = extract_year(&stem);
let cleaned = strip_bracketed(&stem); let cleaned = strip_bracketed(&stem);
let cleaned_for_tokens = strip_dash_suffix(&cleaned);
let alt_titles = extract_alt_titles(&cleaned, year); let alt_titles = extract_alt_titles(&cleaned, year);
let tokens = tokenize(&cleaned, year); let tokens = strip_noise_tokens(tokenize(&cleaned_for_tokens, year));
let title = if tokens.is_empty() { let title = if tokens.is_empty() {
let mut fallback = cleaned.clone(); let mut fallback = cleaned.clone();
@@ -79,6 +80,32 @@ fn strip_bracketed(raw: &str) -> String {
without_round.to_string() without_round.to_string()
} }
fn strip_dash_suffix(raw: &str) -> String {
let Some((left, right)) = raw.split_once(" - ") else {
return raw.to_string();
};
if should_strip_dash_suffix(right) {
left.trim().to_string()
} else {
raw.to_string()
}
}
fn should_strip_dash_suffix(right: &str) -> bool {
let trimmed = right.trim();
if trimmed.is_empty() {
return false;
}
let lower = trimmed.to_ascii_lowercase();
if lower.contains("http://") || lower.contains("https://") || lower.contains("www.") {
return true;
}
if trimmed.contains('.') && !trimmed.contains(' ') {
return true;
}
false
}
fn extract_alt_titles(raw: &str, year: Option<i32>) -> Vec<String> { fn extract_alt_titles(raw: &str, year: Option<i32>) -> Vec<String> {
let mut alt_titles = Vec::new(); let mut alt_titles = Vec::new();
if let Some((left, right)) = raw.split_once(" - ") { if let Some((left, right)) = raw.split_once(" - ") {
@@ -122,6 +149,37 @@ fn tokenize(raw: &str, year: Option<i32>) -> Vec<String> {
tokens tokens
} }
fn strip_noise_tokens(mut tokens: Vec<String>) -> Vec<String> {
if tokens.is_empty() {
return tokens;
}
if let Some(first) = tokens.first() {
let lower = first.to_ascii_lowercase();
if matches!(lower.as_str(), "watch" | "download") {
tokens.remove(0);
}
}
if tokens.len() >= 2 {
let last = tokens[tokens.len() - 1].to_ascii_lowercase();
let prev = tokens[tokens.len() - 2].to_ascii_lowercase();
if prev == "for" && last == "free" {
tokens.pop();
tokens.pop();
}
}
if let Some(last) = tokens.last() {
let lower = last.to_ascii_lowercase();
if matches!(lower.as_str(), "online" | "free" | "download") {
tokens.pop();
}
}
tokens
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::parse_filename; use super::parse_filename;
@@ -150,4 +208,39 @@ mod tests {
assert_eq!(hints.title.as_deref(), Some("Zootopia Vlix")); assert_eq!(hints.title.as_deref(), Some("Zootopia Vlix"));
assert!(hints.alt_titles.iter().any(|t| t == "Zootopia")); assert!(hints.alt_titles.iter().any(|t| t == "Zootopia"));
} }
#[test]
fn handles_foreign_title_ascii() {
let path = Path::new("Cidade.de.Deus.2002.1080p.BluRay.x264.mkv");
let hints = parse_filename(path);
assert_eq!(hints.title.as_deref(), Some("Cidade de Deus"));
assert_eq!(hints.year, Some(2002));
}
#[test]
fn strips_release_tags() {
let path = Path::new("Movie.Title.2019.1080p.HDRip.x264.AAC.mkv");
let hints = parse_filename(path);
assert_eq!(hints.title.as_deref(), Some("Movie Title"));
assert_eq!(hints.year, Some(2019));
}
#[test]
fn handles_subtitle_separator() {
let path = Path::new("Doctor.Strange.In.The.Multiverse.of.Madness.2022.2160p.mkv");
let hints = parse_filename(path);
assert_eq!(
hints.title.as_deref(),
Some("Doctor Strange In The Multiverse of Madness")
);
assert_eq!(hints.year, Some(2022));
}
#[test]
fn strips_watch_for_free_domain_suffix() {
let path = Path::new("Watch VideoName 2025 HD for free - website.tld.mp4");
let hints = parse_filename(path);
assert_eq!(hints.title.as_deref(), Some("VideoName"));
assert_eq!(hints.year, Some(2025));
}
} }

View File

@@ -19,7 +19,11 @@ use crate::utils::{sanitize_filename, Semaphore};
pub fn run(mut settings: Settings) -> Result<Report> { pub fn run(mut settings: Settings) -> Result<Report> {
ensure_ffprobe()?; ensure_ffprobe()?;
let output = Arc::new(Output::new(&settings.color, settings.verbose)); let output = Arc::new(Output::new(
&settings.color,
settings.verbose,
settings.dry_run_summary,
));
if settings.no_lookup { if settings.no_lookup {
output.warn("No-lookup mode enabled: using filename/LLM only (no external providers)."); output.warn("No-lookup mode enabled: using filename/LLM only (no external providers).");
} }
@@ -245,6 +249,18 @@ fn process_file(
"no match", "no match",
None, None,
); );
if settings.explain && !outcome.candidates.is_empty() {
output.detail(" Candidates:");
for candidate in summarize_candidates(&outcome.candidates, 5) {
output.detail(&format!(
" - {} ({}) [{}] score {:.1}",
candidate.title,
candidate.year.map(|y| y.to_string()).unwrap_or_else(|| "?".into()),
candidate.provider,
candidate.score * 100.0
));
}
}
let entry = ReportEntry { let entry = ReportEntry {
input: path.display().to_string(), input: path.display().to_string(),
status: "skipped".to_string(), status: "skipped".to_string(),

View File

@@ -48,6 +48,16 @@ impl Report {
self.entries.push(entry); self.entries.push(entry);
} }
pub fn write_summary(&self, path: Option<&Path>) -> Result<()> {
let mut writer = open_writer(path)?;
writeln!(
writer,
"Processed: {} | Renamed: {} | Skipped: {} | Failed: {}",
self.processed, self.renamed, self.skipped, self.failed
)?;
Ok(())
}
pub fn write(&self, format: &ReportFormat, path: Option<&Path>) -> Result<()> { pub fn write(&self, format: &ReportFormat, path: Option<&Path>) -> Result<()> {
match format { match format {
ReportFormat::Text => self.write_text(path), ReportFormat::Text => self.write_text(path),

View File

@@ -4,6 +4,7 @@ use std::path::Path;
use assert_cmd::Command; use assert_cmd::Command;
use httpmock::Method::{GET, POST}; use httpmock::Method::{GET, POST};
use httpmock::MockServer; use httpmock::MockServer;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains; use predicates::str::contains;
use tempfile::TempDir; use tempfile::TempDir;
@@ -296,3 +297,117 @@ fn rename_in_place_uses_input_as_output() {
assert!(renamed.exists()); assert!(renamed.exists());
assert!(!input.join("Alien.1979.1080p.mkv").exists()); assert!(!input.join("Alien.1979.1080p.mkv").exists());
} }
#[test]
fn completions_generate_output() {
let mut cmd = Command::new(assert_cmd::cargo_bin!("mov-renamarr"));
cmd.arg("--completions").arg("bash");
cmd.assert().success().stdout(contains("mov-renamarr"));
}
#[test]
fn print_config_outputs_toml() {
let temp = TempDir::new().unwrap();
let input = temp.path().join("input");
let output = temp.path().join("output");
fs::create_dir_all(&input).unwrap();
fs::create_dir_all(&output).unwrap();
let mut cmd = Command::new(assert_cmd::cargo_bin!("mov-renamarr"));
cmd.arg("--input").arg(&input)
.arg("--output").arg(&output)
.arg("--print-config")
.env("XDG_CONFIG_HOME", temp.path().join("config"))
.env("XDG_CACHE_HOME", temp.path().join("cache"));
cmd.assert()
.success()
.stdout(contains("provider = \"auto\""))
.stdout(contains("cache_ttl_days"));
}
#[test]
fn dry_run_summary_suppresses_per_file_output() {
let temp = TempDir::new().unwrap();
let input = temp.path().join("input");
let output = temp.path().join("output");
fs::create_dir_all(&input).unwrap();
fs::create_dir_all(&output).unwrap();
fs::write(input.join("Film.2020.mkv"), b"stub").unwrap();
let ffprobe = make_ffprobe_stub(temp.path());
let mut cmd = Command::new(assert_cmd::cargo_bin!("mov-renamarr"));
cmd.arg("--input").arg(&input)
.arg("--output").arg(&output)
.arg("--dry-run")
.arg("--dry-run-summary")
.arg("--no-lookup")
.arg("--color").arg("never")
.env("XDG_CONFIG_HOME", temp.path().join("config"))
.env("XDG_CACHE_HOME", temp.path().join("cache"))
.env("PATH", prepend_path(ffprobe.parent().unwrap()));
cmd.assert()
.success()
.stdout(contains("Processed: 1"))
.stdout(predicates::str::contains("[1/1]").not());
}
#[test]
fn explain_prints_candidates_on_skip() {
let server = MockServer::start();
let search_mock = server.mock(|when, then| {
when.method(GET)
.path("/search/movie")
.query_param("api_key", "test")
.query_param("query", "Zootopia")
.query_param("year", "2016");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"results":[{"id":55,"title":"Totally Different","release_date":"1990-01-01"}]}"#);
});
let details_mock = server.mock(|when, then| {
when.method(GET)
.path("/movie/55")
.query_param("api_key", "test");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"id":55,"title":"Totally Different","release_date":"1990-01-01","runtime":120,"imdb_id":"tt055"}"#);
});
let temp = TempDir::new().unwrap();
let input = temp.path().join("input");
let output = temp.path().join("output");
fs::create_dir_all(&input).unwrap();
fs::create_dir_all(&output).unwrap();
fs::write(input.join("Zootopia.2016.mkv"), b"stub").unwrap();
let ffprobe = make_ffprobe_stub(temp.path());
let mut cmd = Command::new(assert_cmd::cargo_bin!("mov-renamarr"));
cmd.arg("--input").arg(&input)
.arg("--output").arg(&output)
.arg("--dry-run")
.arg("--provider").arg("tmdb")
.arg("--min-score").arg("99")
.arg("--explain")
.arg("--color").arg("never")
.env("MOV_RENAMARR_TMDB_API_KEY", "test")
.env("MOV_RENAMARR_TMDB_BASE_URL", server.url(""))
.env("XDG_CONFIG_HOME", temp.path().join("config"))
.env("XDG_CACHE_HOME", temp.path().join("cache"))
.env("PATH", prepend_path(ffprobe.parent().unwrap()));
cmd.assert()
.success()
.stdout(contains("skipped"))
.stdout(contains("Candidates:"))
.stdout(contains("Totally Different"));
search_mock.assert_hits(1);
details_mock.assert_hits(1);
}