diff --git a/Cargo.lock b/Cargo.lock index 33e326c..db903c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.49" @@ -1314,6 +1323,7 @@ dependencies = [ "assert_cmd", "chrono", "clap", + "clap_complete", "csv", "directories", "httpmock", diff --git a/Cargo.toml b/Cargo.toml index 677b787..1b353a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ categories = ["command-line-utilities", "filesystem"] anyhow = "1.0" chrono = "0.4" clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" csv = "1.3" directories = "5.0" is-terminal = "0.4" diff --git a/README.md b/README.md index f66743e..7007996 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,26 @@ Common flags: - `--quality-tags resolution,codec,source` - `--min-score 0-100` (match threshold) - `--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: Default config location: diff --git a/completions/_mov-renamarr b/completions/_mov-renamarr new file mode 100644 index 0000000..4be33ff --- /dev/null +++ b/completions/_mov-renamarr @@ -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 diff --git a/completions/mov-renamarr.bash b/completions/mov-renamarr.bash new file mode 100644 index 0000000..4237c2b --- /dev/null +++ b/completions/mov-renamarr.bash @@ -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 diff --git a/completions/mov-renamarr.fish b/completions/mov-renamarr.fish new file mode 100644 index 0000000..4ef54b7 --- /dev/null +++ b/completions/mov-renamarr.fish @@ -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' diff --git a/docs/CONFIG.md b/docs/CONFIG.md index b7e6836..28cb05e 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -43,6 +43,7 @@ refresh_cache = false report_format = "text" # text|json|csv sidecar_notes = false # write per-file notes for skipped/failed sidecars = false # move/copy sidecar files (srt, nfo, etc) +dry_run_summary = false # suppress per-file output (use with --dry-run) ``` ## Matching and naming @@ -50,6 +51,7 @@ sidecars = false # move/copy sidecar files (srt, nfo, etc) min_score = 80 # 0-100 match threshold include_id = false # include tmdb/imdb id in filenames no_lookup = false # skip external providers (filename/LLM only) +explain = false # show top candidates when skipped ``` ## Quality tags diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9bfba17..8c4d92c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,6 +6,8 @@ This repo includes a Gitea Actions workflow that builds Linux binaries for ## One-time setup (Gitea Actions) 1) Create a personal access token with repo write access. 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 1) Update `CHANGELOG.md`. diff --git a/src/cli.rs b/src/cli.rs index b15f1ee..a3dfc43 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,13 +2,13 @@ use std::path::PathBuf; use std::str::FromStr; use clap::{Parser, ValueEnum}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[derive(Parser, Debug)] #[command(name = "mov-renamarr", version, about = "Rename movie files into Radarr-compatible naming")] pub struct Cli { - #[arg(long, value_name = "DIR")] - pub input: PathBuf, + #[arg(long, value_name = "DIR", required_unless_present_any = ["completions"])] + pub input: Option, #[arg(long, value_name = "DIR")] pub output: Option, @@ -34,6 +34,9 @@ pub struct Cli { #[arg(long)] pub dry_run: bool, + #[arg(long)] + pub dry_run_summary: bool, + #[arg(long = "move", conflicts_with = "rename_in_place")] pub move_files: bool, @@ -102,11 +105,20 @@ pub struct Cli { #[arg(long, alias = "offline")] pub no_lookup: bool, + #[arg(long)] + pub explain: bool, + + #[arg(long)] + pub print_config: bool, + + #[arg(long, value_enum)] + pub completions: Option, + #[arg(long)] pub verbose: bool, } -#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] +#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum ProviderChoice { Auto, @@ -115,7 +127,7 @@ pub enum ProviderChoice { Both, } -#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] +#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum ReportFormat { Text, @@ -123,7 +135,7 @@ pub enum ReportFormat { Csv, } -#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] +#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum ColorMode { Auto, @@ -131,7 +143,7 @@ pub enum ColorMode { Never, } -#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)] +#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum LlmMode { Off, @@ -139,6 +151,13 @@ pub enum LlmMode { Assist, } +#[derive(Clone, Debug, ValueEnum, Eq, PartialEq)] +pub enum CompletionShell { + Bash, + Zsh, + Fish, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum JobsArg { Auto, diff --git a/src/config.rs b/src/config.rs index 9cc9b99..4b651a7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use anyhow::{anyhow, Context, Result}; use directories::BaseDirs; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::cli::{Cli, ColorMode, JobsArg, LlmMode, ProviderChoice, ReportFormat}; @@ -34,10 +34,12 @@ pub struct Settings { pub net_jobs: usize, pub no_lookup: bool, pub dry_run: bool, + pub dry_run_summary: bool, pub move_files: bool, pub rename_in_place: bool, pub interactive: bool, pub verbose: bool, + pub explain: bool, pub omdb_base_url: String, pub tmdb_base_url: String, } @@ -59,6 +61,25 @@ impl Default for QualityTags { } } +impl QualityTags { + pub fn to_list(&self) -> Vec { + 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)] pub struct LlmSettings { pub mode: LlmMode, @@ -68,6 +89,50 @@ pub struct LlmSettings { pub max_tokens: Option, } +#[derive(Debug, Serialize)] +struct PrintableSettings { + input: String, + output: String, + provider: ProviderChoice, + api_key_omdb: Option, + api_key_tmdb: Option, + cache_path: String, + cache_ttl_days: u32, + refresh_cache: bool, + report_format: ReportFormat, + report_path: Option, + sidecar_notes: bool, + sidecars: bool, + dry_run_summary: bool, + overwrite: bool, + suffix: bool, + min_score: u8, + include_id: bool, + quality_tags: Vec, + 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, + timeout_seconds: u64, + max_tokens: Option, +} + impl Default for LlmSettings { fn default() -> Self { Self { @@ -103,6 +168,8 @@ struct FileConfig { omdb_base_url: Option, tmdb_base_url: Option, no_lookup: Option, + dry_run_summary: Option, + explain: Option, } #[derive(Debug, Deserialize, Default)] @@ -139,8 +206,11 @@ pub fn build_settings(cli: &Cli) -> Result { } let file_config = load_config_file(&config_path)?; - let input = cli.input.clone(); - let output = resolve_output(cli)?; + let input = cli + .input + .clone() + .ok_or_else(|| anyhow!("--input is required unless --completions is used"))?; + let output = resolve_output(cli, &input)?; let mut settings = Settings { input, @@ -166,10 +236,12 @@ pub fn build_settings(cli: &Cli) -> Result { net_jobs: default_net_jobs(default_jobs()), no_lookup: false, dry_run: cli.dry_run, + dry_run_summary: false, move_files: cli.move_files, rename_in_place: cli.rename_in_place, interactive: cli.interactive, verbose: cli.verbose, + explain: false, omdb_base_url: "https://www.omdbapi.com".to_string(), tmdb_base_url: "https://api.themoviedb.org/3".to_string(), }; @@ -189,11 +261,11 @@ pub fn init_default_config() -> Result { Ok(config_path) } -fn resolve_output(cli: &Cli) -> Result { +fn resolve_output(cli: &Cli, input: &Path) -> Result { 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)) => { - if out != &cli.input { + if out != input { Err(anyhow!( "--rename-in-place requires output to be omitted or the same as input" )) @@ -202,7 +274,7 @@ fn resolve_output(cli: &Cli) -> Result { } } (false, Some(out)) => { - if out == &cli.input { + if out == input { Err(anyhow!( "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", "# sidecars copies/moves subtitle/nfo/etc files with the movie file.", "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 = false", "suffix = false", "# Disable external lookups (use filename/LLM only).", "# When true, provider selection is ignored.", "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 = 80", "# 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 { 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 { 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 { settings.no_lookup = no_lookup; } + if let Some(explain) = file.explain { + settings.explain = explain; + } if let Some(llm) = &file.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_SIDECARS", |value| settings.sidecars = 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_SUFFIX", |value| settings.suffix = 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| { 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 { settings.sidecars = true; } + if cli.dry_run_summary { + settings.dry_run_summary = true; + } if cli.overwrite { settings.overwrite = true; } @@ -588,6 +675,9 @@ fn apply_cli_overrides(settings: &mut Settings, cli: &Cli) -> Result<()> { if cli.no_lookup { settings.no_lookup = true; } + if cli.explain { + settings.explain = true; + } if let Some(mode) = &cli.llm_mode { settings.llm.mode = mode.clone(); } @@ -616,6 +706,9 @@ fn validate_settings(settings: &mut Settings) -> Result<()> { if settings.min_score > 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 { 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)) } +pub fn format_settings(settings: &Settings) -> Result { + 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 { if raw.eq_ignore_ascii_case("auto") { return Ok(fallback); diff --git a/src/main.rs b/src/main.rs index a322d3d..bd8c3f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,9 +11,10 @@ mod report; mod utils; 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<()> { if std::env::args_os().len() == 1 { @@ -23,12 +24,38 @@ fn main() -> Result<()> { } 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)?; + if cli.print_config { + let rendered = config::format_settings(&settings)?; + println!("{rendered}"); + return Ok(()); + } + let report_format = settings.report_format.clone(); let report_path = settings.report_path.clone(); + let dry_run_summary = settings.dry_run_summary; 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(()) } diff --git a/src/output.rs b/src/output.rs index 23c8bdf..af952f1 100644 --- a/src/output.rs +++ b/src/output.rs @@ -16,11 +16,12 @@ pub enum StatusKind { pub struct Output { use_color: bool, verbose: bool, + summary_only: bool, lock: Mutex<()>, } 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 { ColorMode::Always => true, ColorMode::Never => false, @@ -29,6 +30,7 @@ impl Output { Self { use_color, verbose, + summary_only, lock: Mutex::new(()), } } @@ -43,6 +45,9 @@ impl Output { result: &str, output_name: Option<&str>, ) { + if self.summary_only && !matches!(status, StatusKind::Failed) { + return; + } let _guard = self.lock.lock().unwrap(); let prefix = format!("[{}/{}]", index, total); let status_label = match status { @@ -74,6 +79,11 @@ impl Output { eprintln!("{msg}"); } + pub fn detail(&self, message: &str) { + let _guard = self.lock.lock().unwrap(); + println!("{message}"); + } + pub fn info(&self, message: &str) { if self.verbose { diff --git a/src/parse.rs b/src/parse.rs index 48bcf29..dba8459 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -150,4 +150,31 @@ mod tests { assert_eq!(hints.title.as_deref(), Some("Zootopia Vlix")); 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)); + } } diff --git a/src/pipeline.rs b/src/pipeline.rs index deb689f..bd37ef8 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -19,7 +19,11 @@ use crate::utils::{sanitize_filename, Semaphore}; pub fn run(mut settings: Settings) -> Result { 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 { output.warn("No-lookup mode enabled: using filename/LLM only (no external providers)."); } @@ -245,6 +249,18 @@ fn process_file( "no match", 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 { input: path.display().to_string(), status: "skipped".to_string(), diff --git a/src/report.rs b/src/report.rs index 646c6a3..6a38e1d 100644 --- a/src/report.rs +++ b/src/report.rs @@ -48,6 +48,16 @@ impl Report { 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<()> { match format { ReportFormat::Text => self.write_text(path),