Initial commit
This commit is contained in:
2948
Cargo.lock
generated
Normal file
2948
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "mov-renamarr"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
chrono = "0.4"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
csv = "1.3"
|
||||
directories = "5.0"
|
||||
is-terminal = "0.4"
|
||||
libc = "0.2"
|
||||
num_cpus = "1.16"
|
||||
owo-colors = "4.1"
|
||||
rayon = "1.10"
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json", "rustls-tls"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
strsim = "0.11"
|
||||
thiserror = "1.0"
|
||||
toml = "0.8"
|
||||
walkdir = "2.5"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
httpmock = "0.7"
|
||||
predicates = "3.1"
|
||||
tempfile = "3.10"
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 44r0n7
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
96
PLAN.md
Normal file
96
PLAN.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Mov Renamarr CLI - Project Plan
|
||||
|
||||
## Goal
|
||||
Build a Linux CLI that scans a directory of movie files, queries online metadata (OMDb/TMDb), and writes Radarr-compatible folder and file names to an output directory. Project name: Mov Renamarr.
|
||||
|
||||
## Core Requirements
|
||||
- Input: directory tree containing video files.
|
||||
- Output: `Movie Title (Year)/Movie Title (Year) [Quality] [id].ext`.
|
||||
- Uses `ffprobe` for media info and filename parsing for hints.
|
||||
- Queries OMDb/TMDb, with caching to avoid repeat lookups.
|
||||
- Non-interactive by default: skip ambiguous/unmatched files, report them at end.
|
||||
- Optional `--interactive` to confirm matches.
|
||||
- Linux support only (for now).
|
||||
|
||||
## Non-Goals (for MVP)
|
||||
- No Radarr API integration.
|
||||
- No TV/series handling.
|
||||
- No transcoding or media repair.
|
||||
|
||||
## Decisions to Lock In
|
||||
- Default action: copy (safe default).
|
||||
- Optional flags: `--move` and `--rename-in-place`.
|
||||
- Config file support (XDG by default) + CLI overrides. Config format: TOML.
|
||||
- Provider selection: auto based on available API keys, with optional user preference. Default auto priority: TMDb.
|
||||
- Match scoring and minimum confidence threshold.
|
||||
- Cache storage format (SQLite vs JSON) + TTL + `--refresh-cache`.
|
||||
- Quality tags default to resolution only; configurable via CLI/config.
|
||||
- Optional local LLM integration (Ollama) for filename parsing and lookup assist, disabled by default.
|
||||
- Default report format: text.
|
||||
- Sidecar notes: off by default; opt-in only.
|
||||
- Include top-3 candidates in unresolved items by default.
|
||||
- Emphasize performance, broad Linux compatibility, and robust error handling.
|
||||
- UX: per-file status line (file/provider/result/new name), progress counts, color when TTY, `--verbose` for debug details.
|
||||
- Collision policy: default skip if destination exists; optional `--overwrite` or `--suffix` to avoid data loss.
|
||||
- Sidecar files: optionally move/copy all sidecar files with `--sidecars` flag (off by default).
|
||||
- Concurrency: default jobs = min(4, max(1, floor(cores/2))); default net-jobs = min(2, jobs); allow overrides.
|
||||
- ffprobe required (no native parsing fallback).
|
||||
- Reports: stdout by default; optional report file name pattern `mov-renamarr-report-YYYYMMDD-HHMMSS.txt` when `--report` is set without a path.
|
||||
- Config precedence: defaults -> config TOML -> env -> CLI flags.
|
||||
- Config path: `$XDG_CONFIG_HOME/mov-renamarr/config.toml` (fallback `~/.config/mov-renamarr/config.toml`).
|
||||
- Cache path: `$XDG_CACHE_HOME/mov-renamarr/cache.db` (fallback `~/.cache/mov-renamarr/cache.db`).
|
||||
- Report file default location: current working directory when `--report` is set without a path.
|
||||
- Provider base URLs configurable in config/env to support testing/mocking.
|
||||
- Create a commented default config file on first run and notify the user.
|
||||
|
||||
## Proposed CLI (Draft)
|
||||
- `mov-renamarr --input <dir> --output <dir>`
|
||||
- `--config <path>` (default: XDG config)
|
||||
- `--provider auto|omdb|tmdb|both`
|
||||
- `--api-key-omdb <key>` / `--api-key-tmdb <key>` (override config/env)
|
||||
- `--cache <path>` (default: `~/.cache/mov-renamarr.db`)
|
||||
- `--refresh-cache` (bypass cache)
|
||||
- `--dry-run`
|
||||
- `--move` / `--rename-in-place`
|
||||
- `--interactive`
|
||||
- `--report <path>` + `--report-format text|json|csv`
|
||||
- `--sidecar-notes` (write per-file skip notes)
|
||||
- `--min-score <0-100>`
|
||||
- `--include-id` (tmdb/omdb/imdb if available)
|
||||
- `--quality-tags resolution|resolution,codec,source`
|
||||
- `--color auto|always|never`
|
||||
- `--jobs <n|auto>`
|
||||
- `--net-jobs <n|auto>`
|
||||
- `--no-lookup` (skip external providers; use filename/LLM only)
|
||||
- `--llm-mode off|parse|assist` (default: off)
|
||||
- `--llm-endpoint <url>` (Ollama, default `http://localhost:11434`)
|
||||
- `--llm-model <name>` (Ollama model name)
|
||||
- `--llm-timeout <seconds>` / `--llm-max-tokens <n>`
|
||||
|
||||
## Matching Heuristics (Draft)
|
||||
- Parse filename for title/year hints; strip extra release metadata.
|
||||
- Use `ffprobe` for duration and resolution.
|
||||
- Prefer exact year match; allow +/- 1 year when missing.
|
||||
- Use string similarity + runtime delta to choose best match.
|
||||
|
||||
## Pipeline (Draft)
|
||||
1. Load config (XDG) + merge CLI overrides.
|
||||
2. Discover files and filter by extension; skip output subtree when output != input to avoid reprocessing.
|
||||
3. Parse filename hints (title/year) and strip release metadata (optionally via LLM parse).
|
||||
4. Run `ffprobe` for duration/resolution/codec.
|
||||
5. Select provider(s) based on available API keys and user preference.
|
||||
6. Query provider(s) with hints (LLM assist may propose candidates but must be verified).
|
||||
7. Score and select match; if below threshold, mark as unresolved.
|
||||
8. Build Radarr-compatible output path.
|
||||
9. Copy/move/rename-in-place file to output directory.
|
||||
10. Write summary report of successes and unresolved items.
|
||||
|
||||
## Milestones
|
||||
- M0: Project scaffold and plan (done).
|
||||
- M1: CLI skeleton and config parsing.
|
||||
- M2: `ffprobe` integration and media metadata model.
|
||||
- M3: OMDb/TMDb client + caching.
|
||||
- M4: Matching, naming, and file move/copy.
|
||||
- M5: Reporting, tests, and polish.
|
||||
- M6: Automated test harness and fixtures.
|
||||
- M7: Performance pass and profiling.
|
||||
106
README.md
106
README.md
@@ -1,2 +1,106 @@
|
||||
# mov-renamarr
|
||||
# mov-renamarr :clapper:
|
||||
|
||||
Fast, safe CLI to rename movie files into Radarr-compatible folders and filenames on Linux. It uses `ffprobe` for media details, filename parsing for hints, and optional online metadata (TMDb/OMDb) with caching. Default action is copy; move/rename-in-place are opt-in.
|
||||
|
||||
## Features :sparkles:
|
||||
- Radarr-style output: `Title (Year)/Title (Year) [quality] [id].ext`
|
||||
- Safe defaults: copy by default, skip on collision (opt-in overwrite/suffix)
|
||||
- Metadata providers: TMDb, OMDb, or both (auto picks TMDb if available)
|
||||
- Optional local LLM (Ollama) for filename parsing and lookup assist
|
||||
- SQLite cache to reduce repeated lookups
|
||||
- Reports in text/json/csv (stdout by default)
|
||||
- Concurrency controls with sensible defaults
|
||||
|
||||
## Requirements :clipboard:
|
||||
- Linux
|
||||
- `ffprobe` in `PATH` (install via ffmpeg)
|
||||
|
||||
## Install :package:
|
||||
From source:
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
Binary will be at `target/release/mov-renamarr`.
|
||||
|
||||
Install with Cargo:
|
||||
```bash
|
||||
# From a git repo
|
||||
cargo install --git <repo-url> --locked
|
||||
|
||||
# From a local checkout
|
||||
cargo install --path . --locked
|
||||
```
|
||||
|
||||
## Quick start :rocket:
|
||||
Create a default config (with comments) and see the config path:
|
||||
```bash
|
||||
mov-renamarr
|
||||
```
|
||||
|
||||
Dry-run with TMDb:
|
||||
```bash
|
||||
mov-renamarr --input /path/to/in --output /path/to/out --dry-run --provider tmdb
|
||||
```
|
||||
|
||||
Rename in place (no network lookups):
|
||||
```bash
|
||||
mov-renamarr --input /path/to/in --rename-in-place --no-lookup
|
||||
```
|
||||
|
||||
## Configuration :gear:
|
||||
Default config location:
|
||||
`$XDG_CONFIG_HOME/mov-renamarr/config.toml` (fallback `~/.config/mov-renamarr/config.toml`)
|
||||
|
||||
Cache location:
|
||||
`$XDG_CACHE_HOME/mov-renamarr/cache.db` (fallback `~/.cache/mov-renamarr/cache.db`)
|
||||
|
||||
The app creates a commented default config on first run and prints the path.
|
||||
|
||||
Key options (TOML):
|
||||
- `provider = "auto"|"tmdb"|"omdb"|"both"`
|
||||
- `tmdb.api_key` or `tmdb.bearer_token` (TMDb read access token supported)
|
||||
- `omdb.api_key`
|
||||
- `quality_tags = ["resolution"]` (or add `codec`, `source`)
|
||||
- `llm.mode = "off"|"parse"|"assist"`
|
||||
- `llm.endpoint = "http://localhost:11434"`
|
||||
- `llm.model = "Qwen2.5:latest"` (recommended for accuracy: `Qwen2.5:14b`)
|
||||
- `jobs = "auto"|N`, `net_jobs = "auto"|N`
|
||||
- `sidecars = false` (copy/move sidecars when true)
|
||||
|
||||
CLI flags override config, and env vars override config as well.
|
||||
|
||||
## Providers :globe_with_meridians:
|
||||
- **TMDb**: preferred when available. Supports API key or read-access bearer token.
|
||||
- **OMDb**: optional, API key required.
|
||||
- **Auto**: uses TMDb if configured, else OMDb.
|
||||
- **No-lookup**: `--no-lookup` (or `--offline`) uses filename/LLM only.
|
||||
|
||||
## LLM (optional) :robot:
|
||||
If enabled, Ollama is used for:
|
||||
- filename parsing (`llm.mode = "parse"`)
|
||||
- lookup assistance (`llm.mode = "assist"`)
|
||||
|
||||
LLM output is treated as hints; provider results (when enabled) remain the source of truth.
|
||||
|
||||
## Reports :memo:
|
||||
By default, output is printed to stdout.
|
||||
To write a report file:
|
||||
```bash
|
||||
mov-renamarr --input ... --output ... --report
|
||||
```
|
||||
This creates `mov-renamarr-report-YYYYMMDD-HHMMSS.txt` in the current directory.
|
||||
|
||||
Formats: `--report-format text|json|csv`
|
||||
|
||||
## Safety and collisions :shield:
|
||||
Default is **skip** if the destination exists. Options:
|
||||
- `--overwrite` to overwrite
|
||||
- `--suffix` to append ` (1)`, ` (2)`, ...
|
||||
|
||||
## Testing :test_tube:
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
## License :scroll:
|
||||
MIT (see `LICENSE`).
|
||||
|
||||
157
src/cli.rs
Normal file
157
src/cli.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[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")]
|
||||
pub output: Option<PathBuf>,
|
||||
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
pub provider: Option<ProviderChoice>,
|
||||
|
||||
#[arg(long = "api-key-omdb")]
|
||||
pub api_key_omdb: Option<String>,
|
||||
|
||||
#[arg(long = "api-key-tmdb")]
|
||||
pub api_key_tmdb: Option<String>,
|
||||
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub cache: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
pub refresh_cache: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
|
||||
#[arg(long = "move", conflicts_with = "rename_in_place")]
|
||||
pub move_files: bool,
|
||||
|
||||
#[arg(long = "rename-in-place", conflicts_with = "move_files")]
|
||||
pub rename_in_place: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub interactive: bool,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PATH",
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "__DEFAULT__"
|
||||
)]
|
||||
pub report: Option<PathBuf>,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
pub report_format: Option<ReportFormat>,
|
||||
|
||||
#[arg(long)]
|
||||
pub sidecar_notes: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub sidecars: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub overwrite: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub suffix: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub min_score: Option<u8>,
|
||||
|
||||
#[arg(long)]
|
||||
pub include_id: bool,
|
||||
|
||||
#[arg(long, value_name = "LIST")]
|
||||
pub quality_tags: Option<String>,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
pub color: Option<ColorMode>,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
pub llm_mode: Option<LlmMode>,
|
||||
|
||||
#[arg(long, value_name = "URL")]
|
||||
pub llm_endpoint: Option<String>,
|
||||
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub llm_model: Option<String>,
|
||||
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
pub llm_timeout: Option<u64>,
|
||||
|
||||
#[arg(long, value_name = "N")]
|
||||
pub llm_max_tokens: Option<u32>,
|
||||
|
||||
#[arg(long, value_parser = parse_jobs_arg)]
|
||||
pub jobs: Option<JobsArg>,
|
||||
|
||||
#[arg(long, value_parser = parse_jobs_arg)]
|
||||
pub net_jobs: Option<JobsArg>,
|
||||
|
||||
#[arg(long, alias = "offline")]
|
||||
pub no_lookup: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProviderChoice {
|
||||
Auto,
|
||||
Omdb,
|
||||
Tmdb,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReportFormat {
|
||||
Text,
|
||||
Json,
|
||||
Csv,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ColorMode {
|
||||
Auto,
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LlmMode {
|
||||
Off,
|
||||
Parse,
|
||||
Assist,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum JobsArg {
|
||||
Auto,
|
||||
Fixed(usize),
|
||||
}
|
||||
|
||||
fn parse_jobs_arg(value: &str) -> Result<JobsArg, String> {
|
||||
if value.eq_ignore_ascii_case("auto") {
|
||||
return Ok(JobsArg::Auto);
|
||||
}
|
||||
let parsed = usize::from_str(value).map_err(|_| "jobs must be an integer or 'auto'".to_string())?;
|
||||
if parsed == 0 {
|
||||
return Err("jobs must be >= 1".to_string());
|
||||
}
|
||||
Ok(JobsArg::Fixed(parsed))
|
||||
}
|
||||
774
src/config.rs
Normal file
774
src/config.rs
Normal file
@@ -0,0 +1,774 @@
|
||||
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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
161
src/fsops.rs
Normal file
161
src/fsops.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum OpMode {
|
||||
Copy,
|
||||
Move,
|
||||
RenameInPlace,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CollisionPolicy {
|
||||
Skip,
|
||||
Overwrite,
|
||||
Suffix,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OperationOutcome {
|
||||
pub final_path: Option<PathBuf>,
|
||||
pub skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
src: &Path,
|
||||
dest: &Path,
|
||||
mode: OpMode,
|
||||
policy: CollisionPolicy,
|
||||
sidecars: bool,
|
||||
) -> Result<OperationOutcome> {
|
||||
let dest = resolve_collision(dest, policy)?;
|
||||
if dest.is_none() {
|
||||
return Ok(OperationOutcome {
|
||||
final_path: None,
|
||||
skipped_reason: Some("destination exists".to_string()),
|
||||
});
|
||||
}
|
||||
let dest = dest.unwrap();
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create output dir: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
match mode {
|
||||
OpMode::Copy => copy_file(src, &dest)?,
|
||||
OpMode::Move | OpMode::RenameInPlace => move_file(src, &dest)?,
|
||||
}
|
||||
|
||||
if sidecars {
|
||||
process_sidecars(src, &dest, mode, policy)?;
|
||||
}
|
||||
|
||||
Ok(OperationOutcome {
|
||||
final_path: Some(dest),
|
||||
skipped_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_collision(dest: &Path, policy: CollisionPolicy) -> Result<Option<PathBuf>> {
|
||||
if !dest.exists() {
|
||||
return Ok(Some(dest.to_path_buf()));
|
||||
}
|
||||
|
||||
match policy {
|
||||
CollisionPolicy::Skip => Ok(None),
|
||||
CollisionPolicy::Overwrite => Ok(Some(dest.to_path_buf())),
|
||||
CollisionPolicy::Suffix => Ok(Some(append_suffix(dest)?)),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_suffix(dest: &Path) -> Result<PathBuf> {
|
||||
let parent = dest.parent().ok_or_else(|| anyhow!("invalid destination path"))?;
|
||||
let stem = dest
|
||||
.file_stem()
|
||||
.ok_or_else(|| anyhow!("invalid destination filename"))?
|
||||
.to_string_lossy();
|
||||
let ext = dest.extension().map(|e| e.to_string_lossy());
|
||||
|
||||
for idx in 1..=999 {
|
||||
let candidate_name = if let Some(ext) = ext.as_ref() {
|
||||
format!("{} ({}).{}", stem, idx, ext)
|
||||
} else {
|
||||
format!("{} ({})", stem, idx)
|
||||
};
|
||||
let candidate = parent.join(candidate_name);
|
||||
if !candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(anyhow!("unable to find available suffix for {}", dest.display()))
|
||||
}
|
||||
|
||||
fn copy_file(src: &Path, dest: &Path) -> Result<()> {
|
||||
fs::copy(src, dest)
|
||||
.with_context(|| format!("failed to copy {} -> {}", src.display(), dest.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_file(src: &Path, dest: &Path) -> Result<()> {
|
||||
match fs::rename(src, dest) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.raw_os_error() == Some(libc::EXDEV) => {
|
||||
copy_file(src, dest)?;
|
||||
fs::remove_file(src)
|
||||
.with_context(|| format!("failed to remove source after copy: {}", src.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(anyhow!("failed to move {} -> {}: {}", src.display(), dest.display(), err)),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_sidecars(src: &Path, dest: &Path, mode: OpMode, policy: CollisionPolicy) -> Result<usize> {
|
||||
let src_dir = src.parent().ok_or_else(|| anyhow!("source has no parent"))?;
|
||||
let src_stem = src.file_stem().ok_or_else(|| anyhow!("source has no stem"))?;
|
||||
let dest_dir = dest.parent().ok_or_else(|| anyhow!("destination has no parent"))?;
|
||||
let dest_stem = dest.file_stem().ok_or_else(|| anyhow!("destination has no stem"))?;
|
||||
|
||||
let mut processed = 0;
|
||||
for entry in fs::read_dir(src_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path == src {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let stem = match path.file_stem() {
|
||||
Some(stem) => stem,
|
||||
None => continue,
|
||||
};
|
||||
if stem != src_stem {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_string());
|
||||
let mut dest_name = dest_stem.to_string_lossy().to_string();
|
||||
if let Some(ext) = ext {
|
||||
dest_name.push('.');
|
||||
dest_name.push_str(&ext);
|
||||
}
|
||||
let dest_path = dest_dir.join(dest_name);
|
||||
let dest_path = resolve_collision(&dest_path, policy)?;
|
||||
if let Some(dest_path) = dest_path {
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create sidecar output dir: {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
match mode {
|
||||
OpMode::Copy => copy_file(&path, &dest_path)?,
|
||||
OpMode::Move | OpMode::RenameInPlace => move_file(&path, &dest_path)?,
|
||||
}
|
||||
processed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(processed)
|
||||
}
|
||||
118
src/llm.rs
Normal file
118
src/llm.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LlmHints {
|
||||
pub title: Option<String>,
|
||||
pub year: Option<i32>,
|
||||
pub alt_titles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LlmClient {
|
||||
endpoint: String,
|
||||
model: String,
|
||||
max_tokens: Option<u32>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl LlmClient {
|
||||
pub fn new(endpoint: String, model: String, timeout_seconds: u64, max_tokens: Option<u32>) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_seconds))
|
||||
.build()
|
||||
.context("failed to build HTTP client for LLM")?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
model,
|
||||
max_tokens,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_filename(&self, raw: &str) -> Result<LlmHints> {
|
||||
let prompt = build_prompt(raw);
|
||||
let request = OllamaRequest {
|
||||
model: self.model.clone(),
|
||||
prompt,
|
||||
stream: false,
|
||||
format: Some("json".to_string()),
|
||||
options: Some(OllamaOptions {
|
||||
num_predict: self.max_tokens,
|
||||
temperature: 0.0,
|
||||
}),
|
||||
};
|
||||
|
||||
let url = format!("{}/api/generate", self.endpoint.trim_end_matches('/'));
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.context("LLM request failed")?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow::anyhow!("LLM returned HTTP {status}"));
|
||||
}
|
||||
|
||||
let body: OllamaResponse = response.json().context("failed to parse LLM response")?;
|
||||
let hints = parse_hints(&body.response).unwrap_or_default();
|
||||
Ok(hints)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_prompt(raw: &str) -> String {
|
||||
format!(
|
||||
"You are a strict parser. Extract the full movie title and year from the filename below.\n\nRules:\n- Output JSON only.\n- Title must include all words of the movie name in order (no partial tokens).\n- Strip release metadata (resolution, codec, source, group tags).\n- Year must be a 4-digit number if present.\n- If unsure, use null for fields and empty array for alt_titles.\n- Do NOT invent data.\n\nReturn JSON with keys: title, year, alt_titles.\n\nFilename: {raw}\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OllamaRequest {
|
||||
model: String,
|
||||
prompt: String,
|
||||
stream: bool,
|
||||
format: Option<String>,
|
||||
options: Option<OllamaOptions>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OllamaOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
num_predict: Option<u32>,
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaResponse {
|
||||
response: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct LlmHintsRaw {
|
||||
title: Option<String>,
|
||||
year: Option<YearValue>,
|
||||
alt_titles: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum YearValue {
|
||||
Number(i32),
|
||||
String(String),
|
||||
}
|
||||
|
||||
fn parse_hints(raw: &str) -> Option<LlmHints> {
|
||||
let parsed: LlmHintsRaw = serde_json::from_str(raw).ok()?;
|
||||
let year = parsed.year.and_then(|value| match value {
|
||||
YearValue::Number(num) => Some(num),
|
||||
YearValue::String(s) => s.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().ok(),
|
||||
});
|
||||
Some(LlmHints {
|
||||
title: parsed.title,
|
||||
year,
|
||||
alt_titles: parsed.alt_titles.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
34
src/main.rs
Normal file
34
src/main.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod fsops;
|
||||
mod llm;
|
||||
mod media;
|
||||
mod metadata;
|
||||
mod output;
|
||||
mod parse;
|
||||
mod pipeline;
|
||||
mod report;
|
||||
mod utils;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::Cli;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
if std::env::args_os().len() == 1 {
|
||||
let path = config::init_default_config()?;
|
||||
eprintln!("Config file: {} (edit to set API keys and defaults)", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let cli = Cli::parse();
|
||||
let settings = config::build_settings(&cli)?;
|
||||
|
||||
let report_format = settings.report_format.clone();
|
||||
let report_path = settings.report_path.clone();
|
||||
|
||||
let report = pipeline::run(settings)?;
|
||||
report.write(&report_format, report_path.as_deref())?;
|
||||
Ok(())
|
||||
}
|
||||
154
src/media.rs
Normal file
154
src/media.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::QualityTags;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaInfo {
|
||||
pub duration_seconds: Option<f64>,
|
||||
pub height: Option<u32>,
|
||||
pub codec: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FfprobeOutput {
|
||||
format: Option<FfprobeFormat>,
|
||||
streams: Option<Vec<FfprobeStream>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FfprobeFormat {
|
||||
duration: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FfprobeStream {
|
||||
codec_type: Option<String>,
|
||||
codec_name: Option<String>,
|
||||
height: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn probe(path: &Path) -> Result<MediaInfo> {
|
||||
let output = Command::new("ffprobe")
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-print_format")
|
||||
.arg("json")
|
||||
.arg("-show_format")
|
||||
.arg("-show_streams")
|
||||
.arg(path)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run ffprobe on {}", path.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"ffprobe failed for {}: {}",
|
||||
path.display(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout)
|
||||
.with_context(|| "failed to parse ffprobe JSON")?;
|
||||
|
||||
let duration_seconds = parsed
|
||||
.format
|
||||
.and_then(|fmt| fmt.duration)
|
||||
.and_then(|dur| dur.parse::<f64>().ok());
|
||||
|
||||
let video_stream = parsed
|
||||
.streams
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|stream| stream.codec_type.as_deref() == Some("video"));
|
||||
|
||||
let (height, codec) = if let Some(stream) = video_stream {
|
||||
(stream.height, stream.codec_name)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
Ok(MediaInfo {
|
||||
duration_seconds,
|
||||
height,
|
||||
codec,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn quality_tag(info: &MediaInfo, tags: &QualityTags) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if tags.resolution {
|
||||
if let Some(res) = resolution_tag(info.height) {
|
||||
parts.push(res);
|
||||
}
|
||||
}
|
||||
if tags.codec {
|
||||
if let Some(codec) = codec_tag(info.codec.as_deref()) {
|
||||
parts.push(codec);
|
||||
}
|
||||
}
|
||||
if tags.source {
|
||||
// Source tagging not implemented yet; placeholder for future expansion.
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolution_tag(height: Option<u32>) -> Option<String> {
|
||||
let height = height?;
|
||||
let tag = if height >= 2160 {
|
||||
"2160p"
|
||||
} else if height >= 1080 {
|
||||
"1080p"
|
||||
} else if height >= 720 {
|
||||
"720p"
|
||||
} else if height >= 480 {
|
||||
"480p"
|
||||
} else {
|
||||
"360p"
|
||||
};
|
||||
Some(tag.to_string())
|
||||
}
|
||||
|
||||
pub fn codec_tag(codec: Option<&str>) -> Option<String> {
|
||||
let codec = codec?.to_ascii_lowercase();
|
||||
let tag = if codec.contains("hevc") || codec.contains("h265") || codec.contains("x265") {
|
||||
"x265"
|
||||
} else if codec.contains("h264") || codec.contains("x264") {
|
||||
"x264"
|
||||
} else if codec.contains("av1") {
|
||||
"av1"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(tag.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{codec_tag, resolution_tag};
|
||||
|
||||
#[test]
|
||||
fn resolution_tags() {
|
||||
assert_eq!(resolution_tag(Some(2160)).as_deref(), Some("2160p"));
|
||||
assert_eq!(resolution_tag(Some(1080)).as_deref(), Some("1080p"));
|
||||
assert_eq!(resolution_tag(Some(720)).as_deref(), Some("720p"));
|
||||
assert_eq!(resolution_tag(Some(480)).as_deref(), Some("480p"));
|
||||
assert_eq!(resolution_tag(Some(360)).as_deref(), Some("360p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_tags() {
|
||||
assert_eq!(codec_tag(Some("h264")).as_deref(), Some("x264"));
|
||||
assert_eq!(codec_tag(Some("hevc")).as_deref(), Some("x265"));
|
||||
assert_eq!(codec_tag(Some("av1")).as_deref(), Some("av1"));
|
||||
assert_eq!(codec_tag(Some("vp9")), None);
|
||||
}
|
||||
}
|
||||
80
src/metadata/cache.rs
Normal file
80
src/metadata/cache.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
pub struct Cache {
|
||||
path: PathBuf,
|
||||
ttl_days: u32,
|
||||
refresh: bool,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn new(path: PathBuf, ttl_days: u32, refresh: bool) -> Self {
|
||||
Self {
|
||||
path,
|
||||
ttl_days,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, namespace: &str, key: &str) -> Result<Option<String>> {
|
||||
if self.refresh {
|
||||
return Ok(None);
|
||||
}
|
||||
let conn = self.open()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT value, fetched_at FROM cache WHERE namespace = ?1 AND key = ?2 LIMIT 1",
|
||||
)?;
|
||||
let row = stmt.query_row(params![namespace, key], |row| {
|
||||
let value: String = row.get(0)?;
|
||||
let fetched_at: i64 = row.get(1)?;
|
||||
Ok((value, fetched_at))
|
||||
});
|
||||
let (value, fetched_at) = match row {
|
||||
Ok(row) => row,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let now = current_timestamp();
|
||||
let age_days = (now - fetched_at) as f64 / 86_400.0;
|
||||
if age_days > self.ttl_days as f64 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
pub fn set(&self, namespace: &str, key: &str, value: &str) -> Result<()> {
|
||||
let conn = self.open()?;
|
||||
conn.execute(
|
||||
"INSERT INTO cache (namespace, key, value, fetched_at) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value, fetched_at = excluded.fetched_at",
|
||||
params![namespace, key, value, current_timestamp()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open(&self) -> Result<Connection> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create cache dir: {}", parent.display()))?;
|
||||
}
|
||||
let conn = Connection::open(&self.path)
|
||||
.with_context(|| format!("failed to open cache db: {}", self.path.display()))?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS cache (
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(namespace, key)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn current_timestamp() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
336
src/metadata/mod.rs
Normal file
336
src/metadata/mod.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::blocking::Client;
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::metadata::cache::Cache;
|
||||
use crate::parse::FileHints;
|
||||
use crate::utils::{normalize_title, Semaphore};
|
||||
|
||||
mod cache;
|
||||
mod omdb;
|
||||
mod tmdb;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub enum Provider {
|
||||
Omdb,
|
||||
Tmdb,
|
||||
Parsed,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Omdb => "omdb",
|
||||
Provider::Tmdb => "tmdb",
|
||||
Provider::Parsed => "parsed",
|
||||
Provider::Manual => "manual",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
pub provider: Provider,
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub year: Option<i32>,
|
||||
pub runtime_minutes: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScoredCandidate {
|
||||
pub candidate: Candidate,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MovieMetadata {
|
||||
pub title: String,
|
||||
pub year: i32,
|
||||
pub tmdb_id: Option<u32>,
|
||||
pub imdb_id: Option<String>,
|
||||
pub provider: Provider,
|
||||
pub runtime_minutes: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MatchOutcome {
|
||||
pub best: Option<MovieMetadata>,
|
||||
pub candidates: Vec<ScoredCandidate>,
|
||||
}
|
||||
|
||||
pub struct MetadataClient {
|
||||
settings: Arc<Settings>,
|
||||
cache: Arc<Cache>,
|
||||
client: Client,
|
||||
net_sem: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl MetadataClient {
|
||||
pub fn new(settings: Arc<Settings>, net_sem: Arc<Semaphore>) -> Result<Self> {
|
||||
let client = Client::builder().build()?;
|
||||
let cache = Arc::new(Cache::new(
|
||||
settings.cache_path.clone(),
|
||||
settings.cache_ttl_days,
|
||||
settings.refresh_cache,
|
||||
));
|
||||
Ok(Self {
|
||||
settings,
|
||||
cache,
|
||||
client,
|
||||
net_sem,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.selected_providers().map(|_| ())
|
||||
}
|
||||
|
||||
pub fn match_movie(&self, hints: &FileHints, runtime_minutes: Option<u32>) -> Result<MatchOutcome> {
|
||||
let providers = self.selected_providers()?;
|
||||
let queries = build_queries(hints);
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for provider in providers {
|
||||
for query in &queries {
|
||||
let mut results = match provider {
|
||||
Provider::Omdb => omdb::search(
|
||||
&self.client,
|
||||
&self.settings.omdb_base_url,
|
||||
self.settings.api_key_omdb.as_deref().ok_or_else(|| anyhow!("OMDb API key missing"))?,
|
||||
query,
|
||||
&self.cache,
|
||||
&self.net_sem,
|
||||
)?,
|
||||
Provider::Tmdb => tmdb::search(
|
||||
&self.client,
|
||||
&self.settings.tmdb_base_url,
|
||||
self.settings.api_key_tmdb.as_deref().ok_or_else(|| anyhow!("TMDb API key missing"))?,
|
||||
query,
|
||||
&self.cache,
|
||||
&self.net_sem,
|
||||
)?,
|
||||
Provider::Parsed | Provider::Manual => Vec::new(),
|
||||
};
|
||||
candidates.append(&mut results);
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = dedupe_candidates(candidates);
|
||||
let mut scored = score_candidates(hints, runtime_minutes, candidates);
|
||||
scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
if runtime_minutes.is_some() && !scored.is_empty() {
|
||||
self.enrich_runtime(&mut scored)?;
|
||||
for entry in &mut scored {
|
||||
entry.score = score_candidate(hints, runtime_minutes, &entry.candidate);
|
||||
}
|
||||
scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
}
|
||||
|
||||
let best = if let Some(best) = scored.first() {
|
||||
if best.score * 100.0 >= self.settings.min_score as f64 {
|
||||
Some(self.fetch_details(&best.candidate)?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(MatchOutcome { best, candidates: scored })
|
||||
}
|
||||
|
||||
pub fn resolve_candidate(&self, candidate: &Candidate) -> Result<MovieMetadata> {
|
||||
self.fetch_details(candidate)
|
||||
}
|
||||
|
||||
fn fetch_details(&self, candidate: &Candidate) -> Result<MovieMetadata> {
|
||||
match candidate.provider {
|
||||
Provider::Omdb => {
|
||||
let key = self.settings.api_key_omdb.as_deref().ok_or_else(|| anyhow!("OMDb API key missing"))?;
|
||||
omdb::details(
|
||||
&self.client,
|
||||
&self.settings.omdb_base_url,
|
||||
key,
|
||||
&candidate.id,
|
||||
&self.cache,
|
||||
&self.net_sem,
|
||||
)
|
||||
}
|
||||
Provider::Tmdb => {
|
||||
let key = self.settings.api_key_tmdb.as_deref().ok_or_else(|| anyhow!("TMDb API key missing"))?;
|
||||
tmdb::details(
|
||||
&self.client,
|
||||
&self.settings.tmdb_base_url,
|
||||
key,
|
||||
&candidate.id,
|
||||
&self.cache,
|
||||
&self.net_sem,
|
||||
)
|
||||
}
|
||||
Provider::Parsed | Provider::Manual => {
|
||||
Err(anyhow!("parsed/manual provider has no metadata lookup"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_runtime(&self, candidates: &mut [ScoredCandidate]) -> Result<()> {
|
||||
let top_n = 3.min(candidates.len());
|
||||
for entry in candidates.iter_mut().take(top_n) {
|
||||
if entry.candidate.runtime_minutes.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(details) = self.fetch_details(&entry.candidate) {
|
||||
entry.candidate.runtime_minutes = details.runtime_minutes;
|
||||
entry.candidate.year = Some(details.year);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn selected_providers(&self) -> Result<Vec<Provider>> {
|
||||
use crate::cli::ProviderChoice;
|
||||
match self.settings.provider {
|
||||
ProviderChoice::Auto => {
|
||||
if self.settings.api_key_tmdb.is_some() {
|
||||
Ok(vec![Provider::Tmdb])
|
||||
} else if self.settings.api_key_omdb.is_some() {
|
||||
Ok(vec![Provider::Omdb])
|
||||
} else {
|
||||
Err(anyhow!("no API keys available for provider selection"))
|
||||
}
|
||||
}
|
||||
ProviderChoice::Omdb => {
|
||||
if self.settings.api_key_omdb.is_none() {
|
||||
Err(anyhow!("OMDb provider selected but API key missing"))
|
||||
} else {
|
||||
Ok(vec![Provider::Omdb])
|
||||
}
|
||||
}
|
||||
ProviderChoice::Tmdb => {
|
||||
if self.settings.api_key_tmdb.is_none() {
|
||||
Err(anyhow!("TMDb provider selected but API key missing"))
|
||||
} else {
|
||||
Ok(vec![Provider::Tmdb])
|
||||
}
|
||||
}
|
||||
ProviderChoice::Both => {
|
||||
if self.settings.api_key_tmdb.is_none() || self.settings.api_key_omdb.is_none() {
|
||||
Err(anyhow!("both providers requested but one or more API keys missing"))
|
||||
} else {
|
||||
Ok(vec![Provider::Tmdb, Provider::Omdb])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SearchQuery {
|
||||
title: String,
|
||||
year: Option<i32>,
|
||||
}
|
||||
|
||||
fn build_queries(hints: &FileHints) -> Vec<SearchQuery> {
|
||||
let mut queries = Vec::new();
|
||||
if let Some(title) = &hints.title {
|
||||
queries.push(SearchQuery {
|
||||
title: title.clone(),
|
||||
year: hints.year,
|
||||
});
|
||||
}
|
||||
for alt in &hints.alt_titles {
|
||||
queries.push(SearchQuery {
|
||||
title: alt.clone(),
|
||||
year: hints.year,
|
||||
});
|
||||
}
|
||||
dedupe_queries(queries)
|
||||
}
|
||||
|
||||
fn dedupe_queries(queries: Vec<SearchQuery>) -> Vec<SearchQuery> {
|
||||
let mut seen = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
for query in queries {
|
||||
let key = format!("{}:{}", normalize_title(&query.title), query.year.unwrap_or(0));
|
||||
if seen.insert(key, true).is_none() {
|
||||
out.push(query);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn score_candidates(
|
||||
hints: &FileHints,
|
||||
runtime_minutes: Option<u32>,
|
||||
candidates: Vec<Candidate>,
|
||||
) -> Vec<ScoredCandidate> {
|
||||
let mut scored = Vec::new();
|
||||
for candidate in candidates {
|
||||
let score = score_candidate(hints, runtime_minutes, &candidate);
|
||||
scored.push(ScoredCandidate { candidate, score });
|
||||
}
|
||||
scored
|
||||
}
|
||||
|
||||
fn dedupe_candidates(candidates: Vec<Candidate>) -> Vec<Candidate> {
|
||||
let mut seen = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
for candidate in candidates {
|
||||
let key = format!("{}:{}", candidate.provider.as_str(), candidate.id);
|
||||
if seen.insert(key, true).is_none() {
|
||||
out.push(candidate);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn score_candidate(hints: &FileHints, runtime_minutes: Option<u32>, candidate: &Candidate) -> f64 {
|
||||
let title_score = best_title_score(hints, &candidate.title);
|
||||
let mut score = title_score;
|
||||
|
||||
if let (Some(target_year), Some(candidate_year)) = (hints.year, candidate.year) {
|
||||
let diff = (target_year - candidate_year).abs();
|
||||
if diff == 0 {
|
||||
score += 0.10;
|
||||
} else if diff == 1 {
|
||||
score += 0.05;
|
||||
} else {
|
||||
score -= 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(target_runtime), Some(candidate_runtime)) = (runtime_minutes, candidate.runtime_minutes) {
|
||||
let diff = target_runtime.abs_diff(candidate_runtime);
|
||||
if diff <= 2 {
|
||||
score += 0.05;
|
||||
} else if diff <= 5 {
|
||||
score += 0.02;
|
||||
}
|
||||
}
|
||||
|
||||
score.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn best_title_score(hints: &FileHints, candidate_title: &str) -> f64 {
|
||||
let candidate_norm = normalize_title(candidate_title);
|
||||
let mut best = 0.0;
|
||||
if let Some(title) = &hints.title {
|
||||
let score = strsim::jaro_winkler(&normalize_title(title), &candidate_norm);
|
||||
if score > best {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
for alt in &hints.alt_titles {
|
||||
let score = strsim::jaro_winkler(&normalize_title(alt), &candidate_norm);
|
||||
if score > best {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
161
src/metadata/omdb.rs
Normal file
161
src/metadata/omdb.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::metadata::{Candidate, MovieMetadata, Provider};
|
||||
use crate::metadata::cache::Cache;
|
||||
use crate::metadata::SearchQuery;
|
||||
use crate::utils::{normalize_title, Semaphore};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OmdbSearchResponse {
|
||||
#[serde(rename = "Search")]
|
||||
search: Option<Vec<OmdbSearchItem>>,
|
||||
#[serde(rename = "Response")]
|
||||
response: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OmdbSearchItem {
|
||||
#[serde(rename = "Title")]
|
||||
title: String,
|
||||
#[serde(rename = "Year")]
|
||||
year: String,
|
||||
#[serde(rename = "imdbID")]
|
||||
imdb_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OmdbDetailResponse {
|
||||
#[serde(rename = "Title")]
|
||||
title: Option<String>,
|
||||
#[serde(rename = "Year")]
|
||||
year: Option<String>,
|
||||
#[serde(rename = "imdbID")]
|
||||
imdb_id: Option<String>,
|
||||
#[serde(rename = "Runtime")]
|
||||
runtime: Option<String>,
|
||||
#[serde(rename = "Response")]
|
||||
response: Option<String>,
|
||||
#[serde(rename = "Error")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn search(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
query: &SearchQuery,
|
||||
cache: &Cache,
|
||||
net_sem: &Semaphore,
|
||||
) -> Result<Vec<Candidate>> {
|
||||
let key = format!("{}:{}", normalize_title(&query.title), query.year.unwrap_or(0));
|
||||
if let Some(cached) = cache.get("omdb_search", &key)? {
|
||||
return parse_search(&cached);
|
||||
}
|
||||
|
||||
let _permit = net_sem.acquire();
|
||||
let mut req = client
|
||||
.get(base_url)
|
||||
.query(&[("apikey", api_key), ("s", &query.title), ("type", "movie")]);
|
||||
if let Some(year) = query.year {
|
||||
req = req.query(&[("y", year.to_string())]);
|
||||
}
|
||||
|
||||
let resp = req.send().context("OMDb search request failed")?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("OMDb search failed with HTTP {status}"));
|
||||
}
|
||||
let text = resp.text().context("failed to read OMDb response")?;
|
||||
cache.set("omdb_search", &key, &text)?;
|
||||
parse_search(&text)
|
||||
}
|
||||
|
||||
fn parse_search(raw: &str) -> Result<Vec<Candidate>> {
|
||||
let parsed: OmdbSearchResponse = serde_json::from_str(raw)
|
||||
.with_context(|| "failed to parse OMDb search JSON")?;
|
||||
if parsed.response.as_deref() == Some("False") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(items) = parsed.search {
|
||||
for item in items {
|
||||
let year = parse_year(&item.year);
|
||||
candidates.push(Candidate {
|
||||
provider: Provider::Omdb,
|
||||
id: item.imdb_id,
|
||||
title: item.title,
|
||||
year,
|
||||
runtime_minutes: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub fn details(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
imdb_id: &str,
|
||||
cache: &Cache,
|
||||
net_sem: &Semaphore,
|
||||
) -> Result<MovieMetadata> {
|
||||
if let Some(cached) = cache.get("omdb_details", imdb_id)? {
|
||||
return parse_details(&cached);
|
||||
}
|
||||
|
||||
let _permit = net_sem.acquire();
|
||||
let resp = client
|
||||
.get(base_url)
|
||||
.query(&[("apikey", api_key), ("i", imdb_id), ("plot", "short")])
|
||||
.send()
|
||||
.context("OMDb details request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("OMDb details failed with HTTP {status}"));
|
||||
}
|
||||
let text = resp.text().context("failed to read OMDb details")?;
|
||||
cache.set("omdb_details", imdb_id, &text)?;
|
||||
parse_details(&text)
|
||||
}
|
||||
|
||||
fn parse_details(raw: &str) -> Result<MovieMetadata> {
|
||||
let parsed: OmdbDetailResponse = serde_json::from_str(raw)
|
||||
.with_context(|| "failed to parse OMDb details JSON")?;
|
||||
if parsed.response.as_deref() == Some("False") {
|
||||
let msg = parsed.error.unwrap_or_else(|| "OMDb details not found".to_string());
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
let title = parsed.title.unwrap_or_else(|| "Unknown Title".to_string());
|
||||
let year = parsed
|
||||
.year
|
||||
.and_then(|y| parse_year(&y))
|
||||
.unwrap_or(0);
|
||||
let imdb_id = parsed.imdb_id;
|
||||
let runtime_minutes = parsed.runtime.as_deref().and_then(parse_runtime);
|
||||
|
||||
Ok(MovieMetadata {
|
||||
title,
|
||||
year,
|
||||
tmdb_id: None,
|
||||
imdb_id,
|
||||
provider: Provider::Omdb,
|
||||
runtime_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_year(raw: &str) -> Option<i32> {
|
||||
raw.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.get(0..4)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
fn parse_runtime(raw: &str) -> Option<u32> {
|
||||
let digits: String = raw.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
142
src/metadata/tmdb.rs
Normal file
142
src/metadata/tmdb.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::blocking::{Client, RequestBuilder};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::metadata::{Candidate, MovieMetadata, Provider};
|
||||
use crate::metadata::cache::Cache;
|
||||
use crate::metadata::SearchQuery;
|
||||
use crate::utils::{normalize_title, Semaphore};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TmdbSearchResponse {
|
||||
results: Option<Vec<TmdbSearchItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TmdbSearchItem {
|
||||
id: u32,
|
||||
title: String,
|
||||
release_date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TmdbDetailResponse {
|
||||
id: u32,
|
||||
title: Option<String>,
|
||||
release_date: Option<String>,
|
||||
runtime: Option<u32>,
|
||||
imdb_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn search(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
query: &SearchQuery,
|
||||
cache: &Cache,
|
||||
net_sem: &Semaphore,
|
||||
) -> Result<Vec<Candidate>> {
|
||||
let key = format!("{}:{}", normalize_title(&query.title), query.year.unwrap_or(0));
|
||||
if let Some(cached) = cache.get("tmdb_search", &key)? {
|
||||
return parse_search(&cached);
|
||||
}
|
||||
|
||||
let _permit = net_sem.acquire();
|
||||
let url = format!("{}/search/movie", base_url.trim_end_matches('/'));
|
||||
let mut req = apply_auth(client.get(url), api_key)
|
||||
.query(&[("query", &query.title)]);
|
||||
if let Some(year) = query.year {
|
||||
req = req.query(&[("year", year.to_string())]);
|
||||
}
|
||||
let resp = req.send().context("TMDb search request failed")?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("TMDb search failed with HTTP {status}"));
|
||||
}
|
||||
let text = resp.text().context("failed to read TMDb response")?;
|
||||
cache.set("tmdb_search", &key, &text)?;
|
||||
parse_search(&text)
|
||||
}
|
||||
|
||||
fn parse_search(raw: &str) -> Result<Vec<Candidate>> {
|
||||
let parsed: TmdbSearchResponse = serde_json::from_str(raw)
|
||||
.with_context(|| "failed to parse TMDb search JSON")?;
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(items) = parsed.results {
|
||||
for item in items {
|
||||
let year = item.release_date.as_deref().and_then(parse_year);
|
||||
candidates.push(Candidate {
|
||||
provider: Provider::Tmdb,
|
||||
id: item.id.to_string(),
|
||||
title: item.title,
|
||||
year,
|
||||
runtime_minutes: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub fn details(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
id: &str,
|
||||
cache: &Cache,
|
||||
net_sem: &Semaphore,
|
||||
) -> Result<MovieMetadata> {
|
||||
if let Some(cached) = cache.get("tmdb_details", id)? {
|
||||
return parse_details(&cached);
|
||||
}
|
||||
|
||||
let _permit = net_sem.acquire();
|
||||
let url = format!("{}/movie/{}", base_url.trim_end_matches('/'), id);
|
||||
let resp = apply_auth(client.get(url), api_key).send()
|
||||
.context("TMDb details request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("TMDb details failed with HTTP {status}"));
|
||||
}
|
||||
let text = resp.text().context("failed to read TMDb details")?;
|
||||
cache.set("tmdb_details", id, &text)?;
|
||||
parse_details(&text)
|
||||
}
|
||||
|
||||
fn parse_details(raw: &str) -> Result<MovieMetadata> {
|
||||
let parsed: TmdbDetailResponse = serde_json::from_str(raw)
|
||||
.with_context(|| "failed to parse TMDb details JSON")?;
|
||||
let title = parsed.title.unwrap_or_else(|| "Unknown Title".to_string());
|
||||
let year = parsed
|
||||
.release_date
|
||||
.as_deref()
|
||||
.and_then(parse_year)
|
||||
.unwrap_or(0);
|
||||
|
||||
let tmdb_id = Some(parsed.id);
|
||||
|
||||
Ok(MovieMetadata {
|
||||
title,
|
||||
year,
|
||||
tmdb_id,
|
||||
imdb_id: parsed.imdb_id,
|
||||
provider: Provider::Tmdb,
|
||||
runtime_minutes: parsed.runtime,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_auth(req: RequestBuilder, api_key: &str) -> RequestBuilder {
|
||||
if looks_like_bearer(api_key) {
|
||||
req.bearer_auth(api_key)
|
||||
} else {
|
||||
req.query(&[("api_key", api_key)])
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_bearer(value: &str) -> bool {
|
||||
value.contains('.') && value.len() > 30
|
||||
}
|
||||
|
||||
fn parse_year(raw: &str) -> Option<i32> {
|
||||
raw.get(0..4).and_then(|s| s.parse::<i32>().ok())
|
||||
}
|
||||
95
src/output.rs
Normal file
95
src/output.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use std::io;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use is_terminal::IsTerminal;
|
||||
use owo_colors::OwoColorize;
|
||||
|
||||
use crate::cli::ColorMode;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum StatusKind {
|
||||
Renamed,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct Output {
|
||||
use_color: bool,
|
||||
verbose: bool,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
pub fn new(color_mode: &ColorMode, verbose: bool) -> Self {
|
||||
let use_color = match color_mode {
|
||||
ColorMode::Always => true,
|
||||
ColorMode::Never => false,
|
||||
ColorMode::Auto => io::stdout().is_terminal(),
|
||||
};
|
||||
Self {
|
||||
use_color,
|
||||
verbose,
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_line(
|
||||
&self,
|
||||
index: usize,
|
||||
total: usize,
|
||||
status: StatusKind,
|
||||
filename: &str,
|
||||
provider: Option<&str>,
|
||||
result: &str,
|
||||
output_name: Option<&str>,
|
||||
) {
|
||||
let _guard = self.lock.lock().unwrap();
|
||||
let prefix = format!("[{}/{}]", index, total);
|
||||
let status_label = match status {
|
||||
StatusKind::Renamed => "renamed",
|
||||
StatusKind::Skipped => "skipped",
|
||||
StatusKind::Failed => "failed",
|
||||
};
|
||||
let status_label = self.colorize_status(status_label, status);
|
||||
let provider_label = provider.map(|p| format!("{p}"));
|
||||
|
||||
let mut line = format!("{prefix} {status_label} {filename}");
|
||||
if let Some(provider) = provider_label {
|
||||
line.push_str(&format!(" | {provider}"));
|
||||
}
|
||||
line.push_str(&format!(" | {result}"));
|
||||
if let Some(output_name) = output_name {
|
||||
line.push_str(&format!(" -> {output_name}"));
|
||||
}
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
pub fn warn(&self, message: &str) {
|
||||
let _guard = self.lock.lock().unwrap();
|
||||
let msg = if self.use_color {
|
||||
message.yellow().to_string()
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
|
||||
|
||||
pub fn info(&self, message: &str) {
|
||||
if self.verbose {
|
||||
let _guard = self.lock.lock().unwrap();
|
||||
println!("{message}");
|
||||
}
|
||||
}
|
||||
|
||||
fn colorize_status(&self, text: &str, status: StatusKind) -> String {
|
||||
if !self.use_color {
|
||||
return text.to_string();
|
||||
}
|
||||
match status {
|
||||
StatusKind::Renamed => text.green().to_string(),
|
||||
StatusKind::Skipped => text.yellow().to_string(),
|
||||
StatusKind::Failed => text.red().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/parse.rs
Normal file
150
src/parse.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::utils::{collapse_whitespace, normalize_title};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileHints {
|
||||
pub title: Option<String>,
|
||||
pub normalized_title: Option<String>,
|
||||
pub year: Option<i32>,
|
||||
pub alt_titles: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse_filename(path: &Path) -> FileHints {
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let year = extract_year(&stem);
|
||||
let cleaned = strip_bracketed(&stem);
|
||||
let alt_titles = extract_alt_titles(&cleaned, year);
|
||||
let tokens = tokenize(&cleaned, year);
|
||||
|
||||
let title = if tokens.is_empty() {
|
||||
let mut fallback = cleaned.clone();
|
||||
if let Some(year) = year {
|
||||
fallback = fallback.replace(&year.to_string(), "");
|
||||
}
|
||||
let fallback = collapse_whitespace(&fallback);
|
||||
if fallback.is_empty() { None } else { Some(fallback) }
|
||||
} else {
|
||||
Some(collapse_whitespace(&tokens.join(" ")))
|
||||
};
|
||||
let normalized_title = title.as_deref().map(normalize_title);
|
||||
|
||||
FileHints {
|
||||
title,
|
||||
normalized_title,
|
||||
year,
|
||||
alt_titles,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_year(raw: &str) -> Option<i32> {
|
||||
let re = Regex::new(r"(19|20)\d{2}").ok()?;
|
||||
let mut year: Option<i32> = None;
|
||||
for mat in re.find_iter(raw) {
|
||||
if let Ok(parsed) = mat.as_str().parse::<i32>() {
|
||||
year = Some(parsed);
|
||||
}
|
||||
}
|
||||
year
|
||||
}
|
||||
|
||||
fn strip_bracketed(raw: &str) -> String {
|
||||
let re_square = Regex::new(r"\[[^\]]*\]").unwrap();
|
||||
let re_round = Regex::new(r"\([^\)]*\)").unwrap();
|
||||
let without_square = re_square.replace_all(raw, " ");
|
||||
let without_round = re_round.replace_all(&without_square, " ");
|
||||
without_round.to_string()
|
||||
}
|
||||
|
||||
fn extract_alt_titles(raw: &str, year: Option<i32>) -> Vec<String> {
|
||||
let mut alt_titles = Vec::new();
|
||||
if let Some((left, right)) = raw.split_once(" - ") {
|
||||
let left = clean_title_fragment(left, year);
|
||||
let right = collapse_whitespace(right);
|
||||
if !left.is_empty() && !right.is_empty() {
|
||||
alt_titles.push(left);
|
||||
}
|
||||
}
|
||||
alt_titles
|
||||
}
|
||||
|
||||
fn clean_title_fragment(fragment: &str, year: Option<i32>) -> String {
|
||||
let mut cleaned = fragment.to_string();
|
||||
if let Some(year) = year {
|
||||
cleaned = cleaned.replace(&year.to_string(), " ");
|
||||
}
|
||||
collapse_whitespace(&cleaned)
|
||||
}
|
||||
|
||||
fn tokenize(raw: &str, year: Option<i32>) -> Vec<String> {
|
||||
let stopwords = stopwords();
|
||||
let mut tokens = Vec::new();
|
||||
for token in raw.split(|c: char| !c.is_alphanumeric()) {
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let lower = token.to_ascii_lowercase();
|
||||
if let Some(year) = year {
|
||||
if lower == year.to_string() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if stopwords.contains(lower.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if token.chars().all(|c| c.is_ascii_uppercase()) && token.len() <= 8 {
|
||||
continue;
|
||||
}
|
||||
tokens.push(token.to_string());
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
fn stopwords() -> std::collections::HashSet<&'static str> {
|
||||
[
|
||||
"1080p", "720p", "2160p", "480p", "360p", "4k", "uhd", "hdr", "dvdrip",
|
||||
"bdrip", "brrip", "bluray", "blu", "webdl", "web-dl", "webrip", "hdrip",
|
||||
"remux", "x264", "x265", "h264", "h265", "hevc", "aac", "dts", "ac3",
|
||||
"proper", "repack", "limited", "extended", "uncut", "remastered", "subbed",
|
||||
"subs", "multi", "dubbed", "dub", "yts", "yify", "rarbg", "web", "hd",
|
||||
"hq", "cam", "ts", "dvdscr", "r5", "r6",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_filename;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn parses_basic_title_and_year() {
|
||||
let path = Path::new("Some.Movie.2020.1080p.BluRay.x264-GROUP.mkv");
|
||||
let hints = parse_filename(path);
|
||||
assert_eq!(hints.title.as_deref(), Some("Some Movie"));
|
||||
assert_eq!(hints.year, Some(2020));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_brackets_and_stopwords() {
|
||||
let path = Path::new("[YTS] The.Matrix.(1999).1080p.BluRay.mkv");
|
||||
let hints = parse_filename(path);
|
||||
assert_eq!(hints.title.as_deref(), Some("The Matrix"));
|
||||
assert_eq!(hints.year, Some(1999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_alt_title_for_dash_suffix() {
|
||||
let path = Path::new("Zootopia - Vlix.mp4");
|
||||
let hints = parse_filename(path);
|
||||
assert_eq!(hints.title.as_deref(), Some("Zootopia Vlix"));
|
||||
assert!(hints.alt_titles.iter().any(|t| t == "Zootopia"));
|
||||
}
|
||||
}
|
||||
563
src/pipeline.rs
Normal file
563
src/pipeline.rs
Normal file
@@ -0,0 +1,563 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::io;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rayon::prelude::*;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::fsops::{self, CollisionPolicy, OpMode};
|
||||
use crate::llm::{LlmClient, LlmHints};
|
||||
use crate::media;
|
||||
use crate::metadata::{MatchOutcome, MetadataClient, MovieMetadata, Provider, ScoredCandidate};
|
||||
use crate::output::{Output, StatusKind};
|
||||
use crate::parse::{parse_filename, FileHints};
|
||||
use crate::report::{summarize_candidates, Report, ReportEntry};
|
||||
use crate::utils::{sanitize_filename, Semaphore};
|
||||
|
||||
pub fn run(mut settings: Settings) -> Result<Report> {
|
||||
ensure_ffprobe()?;
|
||||
|
||||
let output = Arc::new(Output::new(&settings.color, settings.verbose));
|
||||
if settings.no_lookup {
|
||||
output.warn("No-lookup mode enabled: using filename/LLM only (no external providers).");
|
||||
}
|
||||
if settings.verbose {
|
||||
output.info(&format!(
|
||||
"jobs: {} | net-jobs: {} | report format: {:?}",
|
||||
settings.jobs, settings.net_jobs, settings.report_format
|
||||
));
|
||||
}
|
||||
|
||||
if settings.interactive {
|
||||
settings.jobs = 1;
|
||||
settings.net_jobs = settings.net_jobs.max(1);
|
||||
}
|
||||
|
||||
let files = discover_files(&settings.input, &settings.output)?;
|
||||
let total = files.len();
|
||||
if total == 0 {
|
||||
output.warn("no video files found");
|
||||
return Ok(Report::default());
|
||||
}
|
||||
|
||||
let settings = Arc::new(settings);
|
||||
let net_sem = Arc::new(Semaphore::new(settings.net_jobs));
|
||||
let metadata = if settings.no_lookup {
|
||||
None
|
||||
} else {
|
||||
let client = Arc::new(MetadataClient::new(settings.clone(), net_sem)?);
|
||||
client.validate()?;
|
||||
Some(client)
|
||||
};
|
||||
|
||||
let llm = build_llm_client(&settings, &output)?;
|
||||
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(settings.jobs)
|
||||
.build()
|
||||
.context("failed to build thread pool")?;
|
||||
|
||||
let results: Vec<ReportEntry> = pool.install(|| {
|
||||
files
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, path)| {
|
||||
process_file(
|
||||
idx + 1,
|
||||
total,
|
||||
path,
|
||||
settings.clone(),
|
||||
metadata.clone(),
|
||||
llm.clone(),
|
||||
output.clone(),
|
||||
)
|
||||
.unwrap_or_else(|err| ReportEntry {
|
||||
input: path.display().to_string(),
|
||||
status: "failed".to_string(),
|
||||
provider: None,
|
||||
result: None,
|
||||
output: None,
|
||||
reason: Some(err.to_string()),
|
||||
candidates: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let mut report = Report::default();
|
||||
for entry in results {
|
||||
report.record(entry);
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn ensure_ffprobe() -> Result<()> {
|
||||
let output = std::process::Command::new("ffprobe")
|
||||
.arg("-version")
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
_ => Err(anyhow!(
|
||||
"ffprobe not found. Please install ffmpeg/ffprobe and ensure it is in PATH."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_files(input: &Path, output: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
for entry in WalkDir::new(input).follow_links(true) {
|
||||
let entry = entry?;
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if output != input && path.starts_with(output) {
|
||||
continue;
|
||||
}
|
||||
if is_video_file(path) {
|
||||
files.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn is_video_file(path: &Path) -> bool {
|
||||
let ext = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(ext) => ext.to_ascii_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
matches!(
|
||||
ext.as_str(),
|
||||
"mkv" | "mp4" | "avi" | "mov" | "m4v" | "mpg" | "mpeg" | "wmv" | "webm" | "ts" | "m2ts"
|
||||
)
|
||||
}
|
||||
|
||||
fn process_file(
|
||||
index: usize,
|
||||
total: usize,
|
||||
path: &Path,
|
||||
settings: Arc<Settings>,
|
||||
metadata: Option<Arc<MetadataClient>>,
|
||||
llm: Option<Arc<LlmClient>>,
|
||||
output: Arc<Output>,
|
||||
) -> Result<ReportEntry> {
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let mut hints = parse_filename(path);
|
||||
|
||||
if let Some(llm) = &llm {
|
||||
if settings.llm.mode != crate::cli::LlmMode::Off {
|
||||
if let Ok(llm_hints) = llm.parse_filename(&filename) {
|
||||
merge_llm_hints(&mut hints, llm_hints, settings.llm.mode.clone());
|
||||
} else {
|
||||
output.warn(&format!("LLM parse failed for {filename}, using heuristic parse"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let media = match media::probe(path) {
|
||||
Ok(info) => info,
|
||||
Err(err) => {
|
||||
output.status_line(
|
||||
index,
|
||||
total,
|
||||
StatusKind::Failed,
|
||||
&filename,
|
||||
None,
|
||||
"ffprobe failed",
|
||||
None,
|
||||
);
|
||||
return Ok(ReportEntry {
|
||||
input: path.display().to_string(),
|
||||
status: "failed".to_string(),
|
||||
provider: None,
|
||||
result: None,
|
||||
output: None,
|
||||
reason: Some(err.to_string()),
|
||||
candidates: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_minutes = media
|
||||
.duration_seconds
|
||||
.map(|seconds| (seconds / 60.0).round() as u32);
|
||||
|
||||
let outcome = if settings.no_lookup {
|
||||
MatchOutcome {
|
||||
best: match_offline(&hints, settings.interactive)?,
|
||||
candidates: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
match metadata
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("metadata client unavailable"))?
|
||||
.match_movie(&hints, runtime_minutes)
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
output.status_line(
|
||||
index,
|
||||
total,
|
||||
StatusKind::Failed,
|
||||
&filename,
|
||||
None,
|
||||
"metadata lookup failed",
|
||||
None,
|
||||
);
|
||||
return Ok(ReportEntry {
|
||||
input: path.display().to_string(),
|
||||
status: "failed".to_string(),
|
||||
provider: None,
|
||||
result: None,
|
||||
output: None,
|
||||
reason: Some(err.to_string()),
|
||||
candidates: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut chosen = outcome.best.clone();
|
||||
if settings.interactive && !settings.no_lookup {
|
||||
let client = metadata.as_ref().ok_or_else(|| anyhow!("metadata client unavailable"))?;
|
||||
chosen = interactive_choice(&outcome, path, client)?;
|
||||
}
|
||||
|
||||
if chosen.is_none() {
|
||||
let reason = if settings.no_lookup {
|
||||
if hints.title.is_none() || hints.year.is_none() {
|
||||
"no-lookup missing title/year".to_string()
|
||||
} else {
|
||||
"no-lookup skipped".to_string()
|
||||
}
|
||||
} else {
|
||||
"no match above threshold".to_string()
|
||||
};
|
||||
output.status_line(
|
||||
index,
|
||||
total,
|
||||
StatusKind::Skipped,
|
||||
&filename,
|
||||
None,
|
||||
"no match",
|
||||
None,
|
||||
);
|
||||
let entry = ReportEntry {
|
||||
input: path.display().to_string(),
|
||||
status: "skipped".to_string(),
|
||||
provider: None,
|
||||
result: None,
|
||||
output: None,
|
||||
reason: Some(reason),
|
||||
candidates: summarize_candidates(&outcome.candidates, 3),
|
||||
};
|
||||
if settings.sidecar_notes {
|
||||
write_sidecar_note(path, &entry)?;
|
||||
}
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
let metadata = chosen.unwrap();
|
||||
let quality = media::quality_tag(&media, &settings.quality_tags);
|
||||
let output_path = build_output_path(&metadata, &settings, path, quality.as_deref());
|
||||
|
||||
if settings.dry_run {
|
||||
output.status_line(
|
||||
index,
|
||||
total,
|
||||
StatusKind::Renamed,
|
||||
&filename,
|
||||
Some(metadata.provider.as_str()),
|
||||
"dry-run",
|
||||
Some(&output_path.display().to_string()),
|
||||
);
|
||||
return Ok(ReportEntry {
|
||||
input: path.display().to_string(),
|
||||
status: "renamed".to_string(),
|
||||
provider: Some(metadata.provider.as_str().to_string()),
|
||||
result: Some(format!("{} ({})", metadata.title, metadata.year)),
|
||||
output: Some(output_path.display().to_string()),
|
||||
reason: Some("dry-run".to_string()),
|
||||
candidates: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let op_mode = if settings.move_files {
|
||||
OpMode::Move
|
||||
} else if settings.rename_in_place {
|
||||
OpMode::RenameInPlace
|
||||
} else {
|
||||
OpMode::Copy
|
||||
};
|
||||
let policy = if settings.overwrite {
|
||||
CollisionPolicy::Overwrite
|
||||
} else if settings.suffix {
|
||||
CollisionPolicy::Suffix
|
||||
} else {
|
||||
CollisionPolicy::Skip
|
||||
};
|
||||
|
||||
let outcome = fsops::execute(path, &output_path, op_mode, policy, settings.sidecars)?;
|
||||
if outcome.final_path.is_none() {
|
||||
output.status_line(
|
||||
index,
|
||||
total,
|
||||
StatusKind::Skipped,
|
||||
&filename,
|
||||
Some(metadata.provider.as_str()),
|
||||
"destination exists",
|
||||
None,
|
||||
);
|
||||
let entry = ReportEntry {
|
||||
input: path.display().to_string(),
|
||||
status: "skipped".to_string(),
|
||||
provider: Some(metadata.provider.as_str().to_string()),
|
||||
result: Some(format!("{} ({})", metadata.title, metadata.year)),
|
||||
output: None,
|
||||
reason: outcome.skipped_reason,
|
||||
candidates: Vec::new(),
|
||||
};
|
||||
if settings.sidecar_notes {
|
||||
write_sidecar_note(path, &entry)?;
|
||||
}
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
let final_path = outcome.final_path.unwrap();
|
||||
output.status_line(
|
||||
index,
|
||||
total,
|
||||
StatusKind::Renamed,
|
||||
&filename,
|
||||
Some(metadata.provider.as_str()),
|
||||
"renamed",
|
||||
Some(&final_path.display().to_string()),
|
||||
);
|
||||
|
||||
Ok(ReportEntry {
|
||||
input: path.display().to_string(),
|
||||
status: "renamed".to_string(),
|
||||
provider: Some(metadata.provider.as_str().to_string()),
|
||||
result: Some(format!("{} ({})", metadata.title, metadata.year)),
|
||||
output: Some(final_path.display().to_string()),
|
||||
reason: None,
|
||||
candidates: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_llm_hints(hints: &mut FileHints, llm_hints: LlmHints, mode: crate::cli::LlmMode) {
|
||||
if let Some(title) = llm_hints.title {
|
||||
if hints.title.is_none() || mode == crate::cli::LlmMode::Parse {
|
||||
hints.title = Some(title.clone());
|
||||
hints.normalized_title = Some(crate::utils::normalize_title(&title));
|
||||
} else if hints.title.as_deref() != Some(title.as_str()) {
|
||||
hints.alt_titles.push(title);
|
||||
}
|
||||
}
|
||||
if let Some(year) = llm_hints.year {
|
||||
if hints.year.is_none() || mode == crate::cli::LlmMode::Parse {
|
||||
hints.year = Some(year);
|
||||
}
|
||||
}
|
||||
if !llm_hints.alt_titles.is_empty() {
|
||||
hints.alt_titles.extend(llm_hints.alt_titles);
|
||||
}
|
||||
}
|
||||
|
||||
fn match_offline(hints: &FileHints, interactive: bool) -> Result<Option<MovieMetadata>> {
|
||||
if let (Some(title), Some(year)) = (&hints.title, hints.year) {
|
||||
return Ok(Some(MovieMetadata {
|
||||
title: title.clone(),
|
||||
year,
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
provider: Provider::Parsed,
|
||||
runtime_minutes: None,
|
||||
}));
|
||||
}
|
||||
|
||||
if interactive {
|
||||
let title = prompt("Title")?;
|
||||
let year = prompt("Year")?;
|
||||
if let Ok(year) = year.parse::<i32>() {
|
||||
return Ok(Some(MovieMetadata {
|
||||
title,
|
||||
year,
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
provider: Provider::Manual,
|
||||
runtime_minutes: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn build_output_path(
|
||||
metadata: &MovieMetadata,
|
||||
settings: &Settings,
|
||||
source: &Path,
|
||||
quality: Option<&str>,
|
||||
) -> PathBuf {
|
||||
let mut folder = format!("{} ({})", metadata.title, metadata.year);
|
||||
folder = sanitize_filename(&folder);
|
||||
|
||||
let mut filename = folder.clone();
|
||||
if let Some(quality) = quality {
|
||||
filename.push_str(&format!(" [{}]", quality));
|
||||
}
|
||||
if settings.include_id {
|
||||
if let Some(id) = id_tag(metadata) {
|
||||
filename.push_str(&format!(" [{}]", id));
|
||||
}
|
||||
}
|
||||
|
||||
let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if !ext.is_empty() {
|
||||
filename.push('.');
|
||||
filename.push_str(ext);
|
||||
}
|
||||
|
||||
settings.output.join(folder).join(filename)
|
||||
}
|
||||
|
||||
fn id_tag(metadata: &MovieMetadata) -> Option<String> {
|
||||
match metadata.provider {
|
||||
Provider::Tmdb => metadata.tmdb_id.map(|id| format!("tmdb-{id}")),
|
||||
Provider::Omdb => metadata.imdb_id.as_ref().map(|id| format!("imdb-{id}")),
|
||||
Provider::Parsed | Provider::Manual => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn interactive_choice(
|
||||
outcome: &MatchOutcome,
|
||||
path: &Path,
|
||||
metadata: &MetadataClient,
|
||||
) -> Result<Option<MovieMetadata>> {
|
||||
if outcome.candidates.is_empty() {
|
||||
return Ok(outcome.best.clone());
|
||||
}
|
||||
|
||||
let ambiguous = is_ambiguous(&outcome.candidates);
|
||||
if !ambiguous && outcome.best.is_some() {
|
||||
return Ok(outcome.best.clone());
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
println!("Ambiguous match for {filename}");
|
||||
for (idx, candidate) in outcome.candidates.iter().take(3).enumerate() {
|
||||
let label = format!(
|
||||
" {}) {} ({}) [{}] score {:.1}",
|
||||
idx + 1,
|
||||
candidate.candidate.title,
|
||||
candidate.candidate.year.unwrap_or(0),
|
||||
candidate.candidate.provider.as_str(),
|
||||
candidate.score * 100.0
|
||||
);
|
||||
println!("{label}");
|
||||
}
|
||||
println!(" s) skip");
|
||||
println!(" m) manual title/year");
|
||||
print!("Choose: ");
|
||||
io::Write::flush(&mut std::io::stdout())?;
|
||||
|
||||
let mut choice = String::new();
|
||||
std::io::stdin().read_line(&mut choice)?;
|
||||
let choice = choice.trim();
|
||||
if choice.eq_ignore_ascii_case("s") {
|
||||
return Ok(None);
|
||||
}
|
||||
if choice.eq_ignore_ascii_case("m") {
|
||||
let title = prompt("Title")?;
|
||||
let year = prompt("Year")?;
|
||||
if let Ok(year) = year.parse::<i32>() {
|
||||
return Ok(Some(MovieMetadata {
|
||||
title,
|
||||
year,
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
provider: Provider::Manual,
|
||||
runtime_minutes: None,
|
||||
}));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(index) = choice.parse::<usize>() {
|
||||
if let Some(candidate) = outcome.candidates.get(index - 1) {
|
||||
if let Ok(details) = metadata.resolve_candidate(&candidate.candidate) {
|
||||
return Ok(Some(details));
|
||||
}
|
||||
return Ok(Some(MovieMetadata {
|
||||
title: candidate.candidate.title.clone(),
|
||||
year: candidate.candidate.year.unwrap_or(0),
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
provider: candidate.candidate.provider.clone(),
|
||||
runtime_minutes: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(outcome.best.clone())
|
||||
}
|
||||
|
||||
fn is_ambiguous(candidates: &[ScoredCandidate]) -> bool {
|
||||
if candidates.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
(candidates[0].score - candidates[1].score).abs() < 0.02
|
||||
}
|
||||
|
||||
fn prompt(label: &str) -> Result<String> {
|
||||
print!("{label}: ");
|
||||
io::Write::flush(&mut std::io::stdout())?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn build_llm_client(settings: &Settings, output: &Output) -> Result<Option<Arc<LlmClient>>> {
|
||||
if settings.llm.mode == crate::cli::LlmMode::Off {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = match &settings.llm.model {
|
||||
Some(model) => model.clone(),
|
||||
None => {
|
||||
output.warn("LLM mode enabled but no model provided; disabling LLM");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let client = LlmClient::new(
|
||||
settings.llm.endpoint.clone(),
|
||||
model,
|
||||
settings.llm.timeout_seconds,
|
||||
settings.llm.max_tokens,
|
||||
)?;
|
||||
Ok(Some(Arc::new(client)))
|
||||
}
|
||||
|
||||
fn write_sidecar_note(path: &Path, entry: &ReportEntry) -> Result<()> {
|
||||
let note_path = path.with_extension("mov-renamarr.txt");
|
||||
let mut note = String::new();
|
||||
note.push_str(&format!("Status: {}\n", entry.status));
|
||||
if let Some(reason) = &entry.reason {
|
||||
note.push_str(&format!("Reason: {}\n", reason));
|
||||
}
|
||||
if !entry.candidates.is_empty() {
|
||||
note.push_str("Candidates:\n");
|
||||
for candidate in &entry.candidates {
|
||||
note.push_str(&format!(
|
||||
" - {} ({}) [{}] {:.1}\n",
|
||||
candidate.title,
|
||||
candidate.year.unwrap_or(0),
|
||||
candidate.provider,
|
||||
candidate.score * 100.0
|
||||
));
|
||||
}
|
||||
}
|
||||
std::fs::write(¬e_path, note)
|
||||
.with_context(|| format!("failed to write sidecar note: {}", note_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
165
src/report.rs
Normal file
165
src/report.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::ReportFormat;
|
||||
use crate::metadata::ScoredCandidate;
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct Report {
|
||||
pub processed: usize,
|
||||
pub renamed: usize,
|
||||
pub skipped: usize,
|
||||
pub failed: usize,
|
||||
pub entries: Vec<ReportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReportEntry {
|
||||
pub input: String,
|
||||
pub status: String,
|
||||
pub provider: Option<String>,
|
||||
pub result: Option<String>,
|
||||
pub output: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub candidates: Vec<CandidateSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CandidateSummary {
|
||||
pub title: String,
|
||||
pub year: Option<i32>,
|
||||
pub provider: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn record(&mut self, entry: ReportEntry) {
|
||||
self.processed += 1;
|
||||
match entry.status.as_str() {
|
||||
"renamed" => self.renamed += 1,
|
||||
"skipped" => self.skipped += 1,
|
||||
"failed" => self.failed += 1,
|
||||
_ => {}
|
||||
}
|
||||
self.entries.push(entry);
|
||||
}
|
||||
|
||||
pub fn write(&self, format: &ReportFormat, path: Option<&Path>) -> Result<()> {
|
||||
match format {
|
||||
ReportFormat::Text => self.write_text(path),
|
||||
ReportFormat::Json => self.write_json(path),
|
||||
ReportFormat::Csv => self.write_csv(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_text(&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
|
||||
)?;
|
||||
|
||||
for entry in &self.entries {
|
||||
writeln!(writer, "\n[{}] {}", entry.status, entry.input)?;
|
||||
if let Some(provider) = &entry.provider {
|
||||
writeln!(writer, " Provider: {}", provider)?;
|
||||
}
|
||||
if let Some(result) = &entry.result {
|
||||
writeln!(writer, " Result: {}", result)?;
|
||||
}
|
||||
if let Some(output) = &entry.output {
|
||||
writeln!(writer, " Output: {}", output)?;
|
||||
}
|
||||
if let Some(reason) = &entry.reason {
|
||||
writeln!(writer, " Reason: {}", reason)?;
|
||||
}
|
||||
if !entry.candidates.is_empty() {
|
||||
writeln!(writer, " Candidates:")?;
|
||||
for candidate in &entry.candidates {
|
||||
writeln!(
|
||||
writer,
|
||||
" - {} ({}) [{}] score {:.1}",
|
||||
candidate.title,
|
||||
candidate.year.map(|y| y.to_string()).unwrap_or_else(|| "?".into()),
|
||||
candidate.provider,
|
||||
candidate.score * 100.0
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(&self, path: Option<&Path>) -> Result<()> {
|
||||
let writer = open_writer(path)?;
|
||||
serde_json::to_writer_pretty(writer, self).context("failed to write JSON report")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_csv(&self, path: Option<&Path>) -> Result<()> {
|
||||
let mut writer = csv::Writer::from_writer(open_writer(path)?);
|
||||
writer.write_record([
|
||||
"input",
|
||||
"status",
|
||||
"provider",
|
||||
"result",
|
||||
"output",
|
||||
"reason",
|
||||
"candidates",
|
||||
])?;
|
||||
for entry in &self.entries {
|
||||
let candidates = entry
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"{} ({}) [{}] {:.1}",
|
||||
c.title,
|
||||
c.year.map(|y| y.to_string()).unwrap_or_else(|| "?".into()),
|
||||
c.provider,
|
||||
c.score * 100.0
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
writer.write_record([
|
||||
&entry.input,
|
||||
&entry.status,
|
||||
entry.provider.as_deref().unwrap_or(""),
|
||||
entry.result.as_deref().unwrap_or(""),
|
||||
entry.output.as_deref().unwrap_or(""),
|
||||
entry.reason.as_deref().unwrap_or(""),
|
||||
&candidates,
|
||||
])?;
|
||||
}
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn open_writer(path: Option<&Path>) -> Result<Box<dyn Write>> {
|
||||
if let Some(path) = path {
|
||||
let file = File::create(path).with_context(|| format!("failed to create report: {}", path.display()))?;
|
||||
Ok(Box::new(file))
|
||||
} else {
|
||||
Ok(Box::new(io::stdout()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summarize_candidates(candidates: &[ScoredCandidate], limit: usize) -> Vec<CandidateSummary> {
|
||||
candidates
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|entry| CandidateSummary {
|
||||
title: entry.candidate.title.clone(),
|
||||
year: entry.candidate.year,
|
||||
provider: entry.candidate.provider.as_str().to_string(),
|
||||
score: entry.score,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
99
src/utils.rs
Normal file
99
src/utils.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use std::sync::{Condvar, Mutex};
|
||||
|
||||
pub fn normalize_title(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
} else if ch.is_whitespace() {
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
collapse_whitespace(&out)
|
||||
}
|
||||
|
||||
pub fn sanitize_filename(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
collapse_whitespace(&out)
|
||||
}
|
||||
|
||||
pub fn collapse_whitespace(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut last_space = false;
|
||||
for ch in input.chars() {
|
||||
if ch.is_whitespace() {
|
||||
if !last_space {
|
||||
out.push(' ');
|
||||
last_space = true;
|
||||
}
|
||||
} else {
|
||||
last_space = false;
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
pub struct Semaphore {
|
||||
state: Mutex<usize>,
|
||||
cvar: Condvar,
|
||||
}
|
||||
|
||||
impl Semaphore {
|
||||
pub fn new(count: usize) -> Self {
|
||||
Self {
|
||||
state: Mutex::new(count),
|
||||
cvar: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acquire(&self) -> SemaphoreGuard<'_> {
|
||||
let mut count = self.state.lock().unwrap();
|
||||
while *count == 0 {
|
||||
count = self.cvar.wait(count).unwrap();
|
||||
}
|
||||
*count -= 1;
|
||||
SemaphoreGuard { sem: self }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SemaphoreGuard<'a> {
|
||||
sem: &'a Semaphore,
|
||||
}
|
||||
|
||||
impl Drop for SemaphoreGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut count = self.sem.state.lock().unwrap();
|
||||
*count += 1;
|
||||
self.sem.cvar.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{collapse_whitespace, normalize_title, sanitize_filename};
|
||||
|
||||
#[test]
|
||||
fn normalizes_title() {
|
||||
assert_eq!(normalize_title("The.Matrix!!"), "the matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_filename() {
|
||||
assert_eq!(sanitize_filename("Bad:Name/Here"), "Bad Name Here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapses_whitespace() {
|
||||
assert_eq!(collapse_whitespace("a b c"), "a b c");
|
||||
}
|
||||
}
|
||||
298
tests/integration.rs
Normal file
298
tests/integration.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use httpmock::Method::{GET, POST};
|
||||
use httpmock::MockServer;
|
||||
use predicates::str::contains;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_ffprobe_stub(dir: &Path) -> std::path::PathBuf {
|
||||
let bin_dir = dir.join("bin");
|
||||
fs::create_dir_all(&bin_dir).unwrap();
|
||||
let script_path = bin_dir.join("ffprobe");
|
||||
let script = r#"#!/usr/bin/env sh
|
||||
echo '{"format":{"duration":"7200"},"streams":[{"codec_type":"video","codec_name":"h264","height":1080}]}'
|
||||
"#;
|
||||
fs::write(&script_path, script).unwrap();
|
||||
let mut perms = fs::metadata(&script_path).unwrap().permissions();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, perms).unwrap();
|
||||
}
|
||||
script_path
|
||||
}
|
||||
|
||||
fn prepend_path(path: &Path) -> String {
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
format!("{}:{}", path.display(), current)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmdb_flow_dry_run_with_mock_server() {
|
||||
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", "Some Movie")
|
||||
.query_param("year", "2020");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"results":[{"id":123,"title":"Some Movie","release_date":"2020-01-02"}]}"#);
|
||||
});
|
||||
|
||||
let details_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/movie/123")
|
||||
.query_param("api_key", "test");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"id":123,"title":"Some Movie","release_date":"2020-01-02","runtime":120,"imdb_id":"tt123"}"#);
|
||||
});
|
||||
|
||||
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("Some.Movie.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")
|
||||
.env("MOV_RENAMARR_PROVIDER", "tmdb")
|
||||
.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("renamed"));
|
||||
|
||||
search_mock.assert_hits(1);
|
||||
details_mock.assert_hits(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omdb_flow_dry_run_with_mock_server() {
|
||||
let server = MockServer::start();
|
||||
|
||||
let search_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/")
|
||||
.query_param("apikey", "test")
|
||||
.query_param("s", "Another Movie")
|
||||
.query_param("type", "movie")
|
||||
.query_param("y", "2019");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"Search":[{"Title":"Another Movie","Year":"2019","imdbID":"tt999"}],"Response":"True"}"#);
|
||||
});
|
||||
|
||||
let details_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/")
|
||||
.query_param("apikey", "test")
|
||||
.query_param("i", "tt999")
|
||||
.query_param("plot", "short");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"Title":"Another Movie","Year":"2019","imdbID":"tt999","Runtime":"95 min","Response":"True"}"#);
|
||||
});
|
||||
|
||||
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("Another.Movie.2019.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")
|
||||
.env("MOV_RENAMARR_PROVIDER", "omdb")
|
||||
.env("MOV_RENAMARR_OMDB_API_KEY", "test")
|
||||
.env("MOV_RENAMARR_OMDB_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("renamed"));
|
||||
|
||||
search_mock.assert_hits(1);
|
||||
details_mock.assert_hits(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_default_config_on_no_args() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let config_home = temp.path().join("config");
|
||||
|
||||
let mut cmd = Command::new(assert_cmd::cargo_bin!("mov-renamarr"));
|
||||
cmd.env("XDG_CONFIG_HOME", &config_home);
|
||||
|
||||
cmd.assert().success().stderr(contains("Config file:"));
|
||||
|
||||
let config_path = config_home.join("mov-renamarr").join("config.toml");
|
||||
assert!(config_path.exists());
|
||||
let contents = fs::read_to_string(config_path).unwrap();
|
||||
assert!(contents.contains("provider = \"auto\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_lookup_uses_parsed_title_and_year() {
|
||||
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("Test.Movie.2021.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("--no-lookup")
|
||||
.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("parsed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_lookup_with_llm_parse_renames_missing_year() {
|
||||
let server = MockServer::start();
|
||||
let llm_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api/generate");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"response":"{\"title\":\"Mystery Movie\",\"year\":\"2011\",\"alt_titles\":[]}"}"#);
|
||||
});
|
||||
|
||||
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("Mystery.Movie.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("--no-lookup")
|
||||
.arg("--llm-mode").arg("parse")
|
||||
.arg("--llm-endpoint").arg(server.url(""))
|
||||
.arg("--llm-model").arg("qwen")
|
||||
.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("Mystery Movie (2011)"))
|
||||
.stdout(contains("parsed"));
|
||||
|
||||
llm_mock.assert_hits(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_policy_skips_existing_destination() {
|
||||
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("Some.Movie.2020.mkv"), b"stub").unwrap();
|
||||
|
||||
let ffprobe = make_ffprobe_stub(temp.path());
|
||||
|
||||
// Pre-create destination to trigger collision skip.
|
||||
let dest_dir = output.join("Some Movie (2020)");
|
||||
fs::create_dir_all(&dest_dir).unwrap();
|
||||
let dest_path = dest_dir.join("Some Movie (2020) [1080p].mkv");
|
||||
fs::write(&dest_path, b"existing").unwrap();
|
||||
|
||||
let mut cmd = Command::new(assert_cmd::cargo_bin!("mov-renamarr"));
|
||||
cmd.arg("--input").arg(&input)
|
||||
.arg("--output").arg(&output)
|
||||
.arg("--no-lookup")
|
||||
.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("destination exists"));
|
||||
|
||||
assert!(dest_path.exists());
|
||||
assert!(input.join("Some.Movie.2020.mkv").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecars_are_copied_when_enabled() {
|
||||
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();
|
||||
fs::write(input.join("Film.2020.srt"), b"sub").unwrap();
|
||||
fs::write(input.join("Film.2020.nfo"), b"nfo").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("--no-lookup")
|
||||
.arg("--sidecars")
|
||||
.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();
|
||||
|
||||
let out_dir = output.join("Film (2020)");
|
||||
assert!(out_dir.join("Film (2020) [1080p].mkv").exists());
|
||||
assert!(out_dir.join("Film (2020) [1080p].srt").exists());
|
||||
assert!(out_dir.join("Film (2020) [1080p].nfo").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_in_place_uses_input_as_output() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let input = temp.path().join("input");
|
||||
fs::create_dir_all(&input).unwrap();
|
||||
fs::write(input.join("Alien.1979.1080p.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("--rename-in-place")
|
||||
.arg("--no-lookup")
|
||||
.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("renamed"));
|
||||
|
||||
let renamed = input.join("Alien (1979)").join("Alien (1979) [1080p].mkv");
|
||||
assert!(renamed.exists());
|
||||
assert!(!input.join("Alien.1979.1080p.mkv").exists());
|
||||
}
|
||||
Reference in New Issue
Block a user