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

143 lines
4.0 KiB
Rust

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())
}