143 lines
4.0 KiB
Rust
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())
|
|
}
|