Initial commit
This commit is contained in:
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())
|
||||
}
|
||||
Reference in New Issue
Block a user