923 lines
28 KiB
Rust
923 lines
28 KiB
Rust
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use directories::BaseDirs;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::cli::{Cli, ColorMode, JobsArg, LlmMode, ProviderChoice, ReportFormat};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Settings {
|
|
pub input: PathBuf,
|
|
pub output: PathBuf,
|
|
pub provider: ProviderChoice,
|
|
pub api_key_omdb: Option<String>,
|
|
pub api_key_tmdb: Option<String>,
|
|
pub cache_path: PathBuf,
|
|
pub cache_ttl_days: u32,
|
|
pub refresh_cache: bool,
|
|
pub report_format: ReportFormat,
|
|
pub report_path: Option<PathBuf>,
|
|
pub sidecar_notes: bool,
|
|
pub sidecars: bool,
|
|
pub overwrite: bool,
|
|
pub suffix: bool,
|
|
pub min_score: u8,
|
|
pub include_id: bool,
|
|
pub quality_tags: QualityTags,
|
|
pub color: ColorMode,
|
|
pub llm: LlmSettings,
|
|
pub jobs: usize,
|
|
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,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct QualityTags {
|
|
pub resolution: bool,
|
|
pub codec: bool,
|
|
pub source: bool,
|
|
}
|
|
|
|
impl Default for QualityTags {
|
|
fn default() -> Self {
|
|
Self {
|
|
resolution: true,
|
|
codec: false,
|
|
source: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
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)]
|
|
pub struct LlmSettings {
|
|
pub mode: LlmMode,
|
|
pub endpoint: String,
|
|
pub model: Option<String>,
|
|
pub timeout_seconds: u64,
|
|
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 {
|
|
fn default() -> Self {
|
|
Self {
|
|
mode: LlmMode::Off,
|
|
endpoint: "http://localhost:11434".to_string(),
|
|
model: None,
|
|
timeout_seconds: 30,
|
|
max_tokens: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
struct FileConfig {
|
|
provider: Option<ProviderChoice>,
|
|
api_key_omdb: Option<String>,
|
|
api_key_tmdb: Option<String>,
|
|
cache_path: Option<PathBuf>,
|
|
cache_ttl_days: Option<u32>,
|
|
refresh_cache: Option<bool>,
|
|
report_format: Option<ReportFormat>,
|
|
sidecar_notes: Option<bool>,
|
|
sidecars: Option<bool>,
|
|
overwrite: Option<bool>,
|
|
suffix: Option<bool>,
|
|
min_score: Option<u8>,
|
|
include_id: Option<bool>,
|
|
quality_tags: Option<QualityTagsValue>,
|
|
color: Option<ColorMode>,
|
|
jobs: Option<JobValue>,
|
|
net_jobs: Option<JobValue>,
|
|
llm: Option<FileLlmConfig>,
|
|
omdb_base_url: Option<String>,
|
|
tmdb_base_url: Option<String>,
|
|
no_lookup: Option<bool>,
|
|
dry_run_summary: Option<bool>,
|
|
explain: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
struct FileLlmConfig {
|
|
mode: Option<LlmMode>,
|
|
endpoint: Option<String>,
|
|
model: Option<String>,
|
|
timeout_seconds: Option<u64>,
|
|
max_tokens: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum QualityTagsValue {
|
|
List(Vec<String>),
|
|
Single(String),
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum JobValue {
|
|
String(String),
|
|
Number(u64),
|
|
}
|
|
|
|
pub fn build_settings(cli: &Cli) -> Result<Settings> {
|
|
let config_path = resolve_config_path(cli.config.as_deref())?;
|
|
if let Err(err) = ensure_default_config(&config_path) {
|
|
eprintln!(
|
|
"Warning: failed to create default config at {}: {}",
|
|
config_path.display(),
|
|
err
|
|
);
|
|
}
|
|
let file_config = load_config_file(&config_path)?;
|
|
|
|
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,
|
|
output,
|
|
provider: ProviderChoice::Auto,
|
|
api_key_omdb: None,
|
|
api_key_tmdb: None,
|
|
cache_path: default_cache_path()?,
|
|
cache_ttl_days: 30,
|
|
refresh_cache: false,
|
|
report_format: ReportFormat::Text,
|
|
report_path: resolve_report_path(cli)?,
|
|
sidecar_notes: false,
|
|
sidecars: false,
|
|
overwrite: false,
|
|
suffix: false,
|
|
min_score: 80,
|
|
include_id: false,
|
|
quality_tags: QualityTags::default(),
|
|
color: ColorMode::Auto,
|
|
llm: LlmSettings::default(),
|
|
jobs: default_jobs(),
|
|
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(),
|
|
};
|
|
|
|
apply_file_config(&mut settings, &file_config)?;
|
|
apply_env_overrides(&mut settings)?;
|
|
apply_cli_overrides(&mut settings, cli)?;
|
|
|
|
validate_settings(&mut settings)?;
|
|
|
|
Ok(settings)
|
|
}
|
|
|
|
pub fn init_default_config() -> Result<PathBuf> {
|
|
let config_path = resolve_config_path(None)?;
|
|
ensure_default_config(&config_path)?;
|
|
Ok(config_path)
|
|
}
|
|
|
|
fn resolve_output(cli: &Cli, input: &Path) -> Result<PathBuf> {
|
|
match (cli.rename_in_place, cli.output.as_ref()) {
|
|
(true, None) => Ok(input.to_path_buf()),
|
|
(true, Some(out)) => {
|
|
if out != input {
|
|
Err(anyhow!(
|
|
"--rename-in-place requires output to be omitted or the same as input"
|
|
))
|
|
} else {
|
|
Ok(out.clone())
|
|
}
|
|
}
|
|
(false, Some(out)) => {
|
|
if out == input {
|
|
Err(anyhow!(
|
|
"output directory must be different from input unless --rename-in-place is set"
|
|
))
|
|
} else {
|
|
Ok(out.clone())
|
|
}
|
|
}
|
|
(false, None) => Err(anyhow!("--output is required unless --rename-in-place is set")),
|
|
}
|
|
}
|
|
|
|
fn resolve_config_path(cli_path: Option<&Path>) -> Result<PathBuf> {
|
|
if let Some(path) = cli_path {
|
|
return Ok(path.to_path_buf());
|
|
}
|
|
let dirs = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve XDG config directory"))?;
|
|
Ok(dirs.config_dir().join("mov-renamarr").join("config.toml"))
|
|
}
|
|
|
|
fn default_cache_path() -> Result<PathBuf> {
|
|
let dirs = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve XDG cache directory"))?;
|
|
Ok(dirs.cache_dir().join("mov-renamarr").join("cache.db"))
|
|
}
|
|
|
|
fn load_config_file(path: &Path) -> Result<FileConfig> {
|
|
if !path.exists() {
|
|
return Ok(FileConfig::default());
|
|
}
|
|
let raw = fs::read_to_string(path)
|
|
.with_context(|| format!("failed to read config file: {}", path.display()))?;
|
|
let cfg: FileConfig = toml::from_str(&raw)
|
|
.with_context(|| format!("failed to parse config TOML: {}", path.display()))?;
|
|
Ok(cfg)
|
|
}
|
|
|
|
fn ensure_default_config(path: &Path) -> Result<bool> {
|
|
if path.exists() {
|
|
return Ok(false);
|
|
}
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create config dir: {}", parent.display()))?;
|
|
}
|
|
fs::write(path, default_config_template())
|
|
.with_context(|| format!("failed to write default config: {}", path.display()))?;
|
|
eprintln!(
|
|
"Created default config at {}. You can edit it to set API keys and preferences.",
|
|
path.display()
|
|
);
|
|
Ok(true)
|
|
}
|
|
|
|
fn default_config_template() -> String {
|
|
[
|
|
"# Mov Renamarr configuration (TOML)",
|
|
"# Edit this file to set API keys and defaults.",
|
|
"# Values here override built-in defaults and can be overridden by env/CLI.",
|
|
"",
|
|
"# Provider selection:",
|
|
"# - auto: pick based on available API keys (prefers TMDb if both set).",
|
|
"# - tmdb / omdb: force a single provider.",
|
|
"# - both: query both and choose best match.",
|
|
"provider = \"auto\"",
|
|
"",
|
|
"# API keys (set at least one).",
|
|
"# TMDb accepts either v3 API key or v4 Read Access Token (Bearer).",
|
|
"# api_key_tmdb = \"YOUR_TMDB_KEY_OR_READ_ACCESS_TOKEN\"",
|
|
"# api_key_omdb = \"YOUR_OMDB_KEY\"",
|
|
"",
|
|
"# Cache settings",
|
|
"# cache_path lets you override the default XDG cache location.",
|
|
"# cache_path = \"/home/user/.cache/mov-renamarr/cache.db\"",
|
|
"# cache_ttl_days controls how long cached API results are reused.",
|
|
"cache_ttl_days = 30",
|
|
"# refresh_cache forces new lookups on next run.",
|
|
"refresh_cache = false",
|
|
"",
|
|
"# Output and reporting",
|
|
"# report_format: text (default), json, or csv.",
|
|
"report_format = \"text\"",
|
|
"# sidecar_notes writes a per-file note when a file is skipped/failed.",
|
|
"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.",
|
|
"include_id = false",
|
|
"",
|
|
"# Quality tags: list or comma-separated string.",
|
|
"# Supported tags: resolution, codec, source, all, none.",
|
|
"quality_tags = [\"resolution\"]",
|
|
"",
|
|
"# Console colors: auto, always, never",
|
|
"color = \"auto\"",
|
|
"",
|
|
"# Concurrency: auto or a number",
|
|
"# jobs controls file processing threads.",
|
|
"# net_jobs controls concurrent API calls.",
|
|
"jobs = \"auto\"",
|
|
"net_jobs = \"auto\"",
|
|
"",
|
|
"# Optional: override provider base URLs (useful for testing).",
|
|
"# tmdb_base_url = \"https://api.themoviedb.org/3\"",
|
|
"# omdb_base_url = \"https://www.omdbapi.com\"",
|
|
"",
|
|
"[llm]",
|
|
"# LLM usage:",
|
|
"# - off: no LLM usage",
|
|
"# - parse: LLM can replace filename parsing hints",
|
|
"# - assist: LLM adds alternate hints but still verifies via providers",
|
|
"# Ollama expected at endpoint.",
|
|
"mode = \"off\"",
|
|
"endpoint = \"http://localhost:11434\"",
|
|
"model = \"Qwen2.5:latest\"",
|
|
"# For higher accuracy (more RAM/VRAM): \"Qwen2.5:14b\"",
|
|
"# timeout_seconds limits LLM request time.",
|
|
"timeout_seconds = 30",
|
|
"# max_tokens caps response length.",
|
|
"# max_tokens = 256",
|
|
"",
|
|
]
|
|
.join("\n")
|
|
}
|
|
|
|
fn apply_file_config(settings: &mut Settings, file: &FileConfig) -> Result<()> {
|
|
if let Some(provider) = &file.provider {
|
|
settings.provider = provider.clone();
|
|
}
|
|
if let Some(key) = &file.api_key_omdb {
|
|
settings.api_key_omdb = Some(key.clone());
|
|
}
|
|
if let Some(key) = &file.api_key_tmdb {
|
|
settings.api_key_tmdb = Some(key.clone());
|
|
}
|
|
if let Some(path) = &file.cache_path {
|
|
settings.cache_path = path.clone();
|
|
}
|
|
if let Some(ttl) = file.cache_ttl_days {
|
|
settings.cache_ttl_days = ttl;
|
|
}
|
|
if let Some(refresh) = file.refresh_cache {
|
|
settings.refresh_cache = refresh;
|
|
}
|
|
if let Some(format) = &file.report_format {
|
|
settings.report_format = format.clone();
|
|
}
|
|
if let Some(sidecar_notes) = file.sidecar_notes {
|
|
settings.sidecar_notes = sidecar_notes;
|
|
}
|
|
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;
|
|
}
|
|
if let Some(suffix) = file.suffix {
|
|
settings.suffix = suffix;
|
|
}
|
|
if let Some(min_score) = file.min_score {
|
|
settings.min_score = min_score;
|
|
}
|
|
if let Some(include_id) = file.include_id {
|
|
settings.include_id = include_id;
|
|
}
|
|
if let Some(tags) = &file.quality_tags {
|
|
let values = match tags {
|
|
QualityTagsValue::List(list) => list.clone(),
|
|
QualityTagsValue::Single(value) => split_list(value),
|
|
};
|
|
settings.quality_tags = parse_quality_tags(&values)?;
|
|
}
|
|
if let Some(color) = &file.color {
|
|
settings.color = color.clone();
|
|
}
|
|
if let Some(raw) = &file.jobs {
|
|
settings.jobs = parse_jobs_setting_value(raw, default_jobs())?;
|
|
}
|
|
if let Some(raw) = &file.net_jobs {
|
|
settings.net_jobs = parse_jobs_setting_value(raw, default_net_jobs(settings.jobs))?;
|
|
}
|
|
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);
|
|
}
|
|
if let Some(url) = &file.omdb_base_url {
|
|
settings.omdb_base_url = url.clone();
|
|
}
|
|
if let Some(url) = &file.tmdb_base_url {
|
|
settings.tmdb_base_url = url.clone();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_file_llm(settings: &mut Settings, llm: &FileLlmConfig) {
|
|
if let Some(mode) = &llm.mode {
|
|
settings.llm.mode = mode.clone();
|
|
}
|
|
if let Some(endpoint) = &llm.endpoint {
|
|
settings.llm.endpoint = endpoint.clone();
|
|
}
|
|
if let Some(model) = &llm.model {
|
|
settings.llm.model = Some(model.clone());
|
|
}
|
|
if let Some(timeout) = llm.timeout_seconds {
|
|
settings.llm.timeout_seconds = timeout;
|
|
}
|
|
if let Some(max_tokens) = llm.max_tokens {
|
|
settings.llm.max_tokens = Some(max_tokens);
|
|
}
|
|
}
|
|
|
|
fn apply_env_overrides(settings: &mut Settings) -> Result<()> {
|
|
apply_env_string("MOV_RENAMARR_PROVIDER", |value| {
|
|
if let Ok(provider) = ProviderChoice::from_str(&value.to_ascii_lowercase()) {
|
|
settings.provider = provider;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_OMDB_API_KEY", |value| {
|
|
settings.api_key_omdb = Some(value);
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_TMDB_API_KEY", |value| {
|
|
settings.api_key_tmdb = Some(value);
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_CACHE", |value| {
|
|
settings.cache_path = PathBuf::from(value);
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_REPORT_FORMAT", |value| {
|
|
if let Ok(format) = ReportFormat::from_str(&value.to_ascii_lowercase()) {
|
|
settings.report_format = format;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_JOBS", |value| {
|
|
if let Ok(jobs) = parse_jobs_setting(&value, default_jobs()) {
|
|
settings.jobs = jobs;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_NET_JOBS", |value| {
|
|
if let Ok(jobs) = parse_jobs_setting(&value, default_net_jobs(settings.jobs)) {
|
|
settings.net_jobs = jobs;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_MIN_SCORE", |value| {
|
|
if let Ok(min_score) = value.parse::<u8>() {
|
|
settings.min_score = min_score;
|
|
}
|
|
});
|
|
|
|
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)) {
|
|
settings.quality_tags = tags;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_COLOR", |value| {
|
|
if let Ok(mode) = ColorMode::from_str(&value.to_ascii_lowercase()) {
|
|
settings.color = mode;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_LLM_MODE", |value| {
|
|
if let Ok(mode) = LlmMode::from_str(&value.to_ascii_lowercase()) {
|
|
settings.llm.mode = mode;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_LLM_ENDPOINT", |value| settings.llm.endpoint = value);
|
|
apply_env_string("MOV_RENAMARR_LLM_MODEL", |value| settings.llm.model = Some(value));
|
|
|
|
apply_env_string("MOV_RENAMARR_LLM_TIMEOUT", |value| {
|
|
if let Ok(timeout) = value.parse::<u64>() {
|
|
settings.llm.timeout_seconds = timeout;
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_LLM_MAX_TOKENS", |value| {
|
|
if let Ok(max_tokens) = value.parse::<u32>() {
|
|
settings.llm.max_tokens = Some(max_tokens);
|
|
}
|
|
});
|
|
|
|
apply_env_string("MOV_RENAMARR_OMDB_BASE_URL", |value| settings.omdb_base_url = value);
|
|
apply_env_string("MOV_RENAMARR_TMDB_BASE_URL", |value| settings.tmdb_base_url = value);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_env_string<F: FnMut(String)>(key: &str, mut setter: F) {
|
|
if let Ok(value) = env::var(key) {
|
|
if !value.trim().is_empty() {
|
|
setter(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_env_bool<F: FnMut(bool)>(key: &str, mut setter: F) {
|
|
if let Ok(value) = env::var(key) {
|
|
if let Ok(parsed) = parse_bool(&value) {
|
|
setter(parsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_bool(value: &str) -> Result<bool> {
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
"1" | "true" | "yes" | "on" => Ok(true),
|
|
"0" | "false" | "no" | "off" => Ok(false),
|
|
_ => Err(anyhow!("invalid boolean value: {value}")),
|
|
}
|
|
}
|
|
|
|
fn apply_cli_overrides(settings: &mut Settings, cli: &Cli) -> Result<()> {
|
|
if let Some(provider) = &cli.provider {
|
|
settings.provider = provider.clone();
|
|
}
|
|
if let Some(key) = &cli.api_key_omdb {
|
|
settings.api_key_omdb = Some(key.clone());
|
|
}
|
|
if let Some(key) = &cli.api_key_tmdb {
|
|
settings.api_key_tmdb = Some(key.clone());
|
|
}
|
|
if let Some(path) = &cli.cache {
|
|
settings.cache_path = path.clone();
|
|
}
|
|
if cli.refresh_cache {
|
|
settings.refresh_cache = true;
|
|
}
|
|
if let Some(format) = &cli.report_format {
|
|
settings.report_format = format.clone();
|
|
}
|
|
if cli.sidecar_notes {
|
|
settings.sidecar_notes = true;
|
|
}
|
|
if cli.sidecars {
|
|
settings.sidecars = true;
|
|
}
|
|
if cli.dry_run_summary {
|
|
settings.dry_run_summary = true;
|
|
}
|
|
if cli.overwrite {
|
|
settings.overwrite = true;
|
|
}
|
|
if cli.suffix {
|
|
settings.suffix = true;
|
|
}
|
|
if let Some(min_score) = cli.min_score {
|
|
settings.min_score = min_score;
|
|
}
|
|
if cli.include_id {
|
|
settings.include_id = true;
|
|
}
|
|
if let Some(tags) = &cli.quality_tags {
|
|
settings.quality_tags = parse_quality_tags(&split_list(tags))?;
|
|
}
|
|
if let Some(color) = &cli.color {
|
|
settings.color = color.clone();
|
|
}
|
|
if let Some(jobs) = &cli.jobs {
|
|
settings.jobs = resolve_jobs_arg(jobs, default_jobs());
|
|
}
|
|
if let Some(net_jobs) = &cli.net_jobs {
|
|
settings.net_jobs = resolve_jobs_arg(net_jobs, default_net_jobs(settings.jobs));
|
|
}
|
|
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();
|
|
}
|
|
if let Some(endpoint) = &cli.llm_endpoint {
|
|
settings.llm.endpoint = endpoint.clone();
|
|
}
|
|
if let Some(model) = &cli.llm_model {
|
|
settings.llm.model = Some(model.clone());
|
|
}
|
|
if let Some(timeout) = cli.llm_timeout {
|
|
settings.llm.timeout_seconds = timeout;
|
|
}
|
|
if let Some(max_tokens) = cli.llm_max_tokens {
|
|
settings.llm.max_tokens = Some(max_tokens);
|
|
}
|
|
if cli.verbose {
|
|
settings.verbose = true;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_settings(settings: &mut Settings) -> Result<()> {
|
|
if settings.overwrite && settings.suffix {
|
|
return Err(anyhow!("--overwrite and --suffix cannot both be set"));
|
|
}
|
|
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;
|
|
}
|
|
if settings.net_jobs > settings.jobs {
|
|
settings.net_jobs = settings.jobs;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn default_jobs() -> usize {
|
|
let cores = num_cpus::get();
|
|
let half = std::cmp::max(1, cores / 2);
|
|
let limit = std::cmp::min(4, half);
|
|
if limit == 0 { 1 } else { limit }
|
|
}
|
|
|
|
pub fn default_net_jobs(jobs: usize) -> usize {
|
|
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> {
|
|
if raw.eq_ignore_ascii_case("auto") {
|
|
return Ok(fallback);
|
|
}
|
|
let parsed: usize = raw.parse().context("invalid jobs value")?;
|
|
if parsed == 0 {
|
|
return Err(anyhow!("jobs must be >= 1"));
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn parse_jobs_setting_value(raw: &JobValue, fallback: usize) -> Result<usize> {
|
|
match raw {
|
|
JobValue::String(value) => parse_jobs_setting(value, fallback),
|
|
JobValue::Number(value) => {
|
|
if *value == 0 {
|
|
return Err(anyhow!("jobs must be >= 1"));
|
|
}
|
|
Ok(*value as usize)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_jobs_arg(arg: &JobsArg, fallback: usize) -> usize {
|
|
match arg {
|
|
JobsArg::Auto => fallback,
|
|
JobsArg::Fixed(value) => *value,
|
|
}
|
|
}
|
|
|
|
fn parse_quality_tags(values: &[String]) -> Result<QualityTags> {
|
|
let mut tags = QualityTags::default();
|
|
tags.resolution = false;
|
|
for value in values {
|
|
let token = value.trim().to_ascii_lowercase();
|
|
match token.as_str() {
|
|
"resolution" => tags.resolution = true,
|
|
"codec" => tags.codec = true,
|
|
"source" => tags.source = true,
|
|
"all" => {
|
|
tags.resolution = true;
|
|
tags.codec = true;
|
|
tags.source = true;
|
|
}
|
|
"none" => {
|
|
tags.resolution = false;
|
|
tags.codec = false;
|
|
tags.source = false;
|
|
}
|
|
_ if token.is_empty() => {}
|
|
_ => return Err(anyhow!("unknown quality tag: {token}")),
|
|
}
|
|
}
|
|
Ok(tags)
|
|
}
|
|
|
|
fn split_list(raw: &str) -> Vec<String> {
|
|
raw.split([',', ';', ' '])
|
|
.filter(|token| !token.trim().is_empty())
|
|
.map(|token| token.trim().to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn resolve_report_path(cli: &Cli) -> Result<Option<PathBuf>> {
|
|
match &cli.report {
|
|
None => Ok(None),
|
|
Some(path) => {
|
|
if path.as_os_str() == "__DEFAULT__" {
|
|
let filename = default_report_filename();
|
|
Ok(Some(PathBuf::from(filename)))
|
|
} else {
|
|
Ok(Some(path.clone()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_report_filename() -> String {
|
|
let now = chrono::Local::now();
|
|
let timestamp = now.format("%Y%m%d-%H%M%S").to_string();
|
|
format!("mov-renamarr-report-{timestamp}.txt")
|
|
}
|
|
|
|
// Needed for ValueEnum parsing from env string
|
|
impl FromStr for ProviderChoice {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self> {
|
|
match s {
|
|
"auto" => Ok(ProviderChoice::Auto),
|
|
"omdb" => Ok(ProviderChoice::Omdb),
|
|
"tmdb" => Ok(ProviderChoice::Tmdb),
|
|
"both" => Ok(ProviderChoice::Both),
|
|
_ => Err(anyhow!("invalid provider choice")),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for ReportFormat {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self> {
|
|
match s {
|
|
"text" => Ok(ReportFormat::Text),
|
|
"json" => Ok(ReportFormat::Json),
|
|
"csv" => Ok(ReportFormat::Csv),
|
|
_ => Err(anyhow!("invalid report format")),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for ColorMode {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self> {
|
|
match s {
|
|
"auto" => Ok(ColorMode::Auto),
|
|
"always" => Ok(ColorMode::Always),
|
|
"never" => Ok(ColorMode::Never),
|
|
_ => Err(anyhow!("invalid color mode")),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for LlmMode {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self> {
|
|
match s {
|
|
"off" => Ok(LlmMode::Off),
|
|
"parse" => Ok(LlmMode::Parse),
|
|
"assist" => Ok(LlmMode::Assist),
|
|
_ => Err(anyhow!("invalid LLM mode")),
|
|
}
|
|
}
|
|
}
|