Files
kindexr/src/enrich/tmdb.rs
T
enki f98e6f8dfa feat: Phase 4 — publisher, qBittorrent watcher, identity CLI
Adds the full writer/publisher stack: NIP-35 event signing and relay
delivery, qBittorrent polling with publish-delay queue, lava_torrent
.torrent file parsing, TMDB inline lookup before publish, and
kindexr-cli identity/publish subcommands.
2026-05-17 12:43:21 -07:00

106 lines
3.4 KiB
Rust

use anyhow::Result;
use reqwest::Client;
use serde::Deserialize;
use sqlx::SqlitePool;
use tracing::{debug, warn};
use url::form_urlencoded;
#[derive(Clone)]
pub struct Enricher {
api_key: String,
client: Client,
}
#[derive(Deserialize)]
struct SearchResponse {
results: Vec<TmdbHit>,
}
#[derive(Deserialize)]
struct TmdbHit {
id: i64,
}
impl Enricher {
pub fn new(api_key: String) -> Self {
Enricher { api_key, client: Client::new() }
}
pub fn enabled(&self) -> bool {
!self.api_key.is_empty()
}
pub async fn enrich(&self, pool: &SqlitePool, event_id: &str, title: &str, year: Option<i32>, newznab_cat: Option<i32>) {
if !self.enabled() { return; }
let (kind, prefix) = match newznab_cat {
Some(c) if (2000..3000).contains(&c) => ("movie", "movie"),
Some(c) if (5000..6000).contains(&c) => ("tv", "tv"),
_ => return,
};
let result = match kind {
"movie" => self.search("search/movie", title, year, "year").await,
_ => self.search("search/tv", title, year, "first_air_date_year").await,
};
let tmdb_id = match result {
Ok(Some(id)) => id,
Ok(None) => return,
Err(e) => {
warn!(event_id, "tmdb lookup error: {e}");
return;
}
};
let stored = format!("{prefix}:{tmdb_id}");
match sqlx::query(
"UPDATE torrents SET tmdb_id = ? WHERE event_id = ? AND (tmdb_id IS NULL OR tmdb_id = '')",
)
.bind(&stored)
.bind(event_id)
.execute(pool)
.await
{
Ok(_) => debug!(event_id, tmdb_id = %stored, "tmdb enriched"),
Err(e) => warn!(event_id, "tmdb db update failed: {e}"),
}
}
/// Look up a TMDB ID and return it as "movie:ID" or "tv:ID" without writing to DB.
/// Returns None if the title doesn't match or TMDB is disabled.
pub async fn lookup(&self, title: &str, year: Option<i32>, newznab_cat: Option<i32>) -> Option<String> {
if !self.enabled() { return None; }
let (kind, prefix) = match newznab_cat {
Some(c) if (2000..3000).contains(&c) => ("movie", "movie"),
Some(c) if (5000..6000).contains(&c) => ("tv", "tv"),
_ => return None,
};
let result = match kind {
"movie" => self.search("search/movie", title, year, "year").await,
_ => self.search("search/tv", title, year, "first_air_date_year").await,
};
match result {
Ok(Some(id)) => Some(format!("{prefix}:{id}")),
Ok(None) => None,
Err(e) => {
tracing::warn!("tmdb lookup error for '{title}': {e}");
None
}
}
}
async fn search(&self, path: &str, title: &str, year: Option<i32>, year_param: &str) -> Result<Option<i64>> {
let encoded: String = form_urlencoded::byte_serialize(title.as_bytes()).collect();
let mut url = format!(
"https://api.themoviedb.org/3/{path}?api_key={key}&query={encoded}",
key = self.api_key,
);
if let Some(y) = year {
url.push_str(&format!("&{year_param}={y}"));
}
let res: SearchResponse = self.client.get(&url).send().await?.json().await?;
Ok(res.results.first().map(|h| h.id))
}
}