Files
mov-renamarr/src/config.rs
2025-12-30 10:52:59 -05:00

775 lines
24 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;
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 move_files: bool,
pub rename_in_place: bool,
pub interactive: bool,
pub verbose: 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,
}
}
}
#[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>,
}
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>,
}
#[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();
let output = resolve_output(cli)?;
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,
move_files: cli.move_files,
rename_in_place: cli.rename_in_place,
interactive: cli.interactive,
verbose: cli.verbose,
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) -> Result<PathBuf> {
match (cli.rename_in_place, cli.output.as_ref()) {
(true, None) => Ok(cli.input.clone()),
(true, Some(out)) => {
if out != &cli.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 == &cli.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",
"# 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",
"# 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(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(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_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_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.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 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.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))
}
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")),
}
}
}