/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2026 metamuffin */ use std::sync::Arc; use anyhow::{Context, Result, anyhow}; use jellycache::Cache; use jellycommon::{ IDENT_IMDB, MSOURCE_OMDB, NO_DESCRIPTION, NO_IDENTIFIERS, NO_RATINGS, NO_TITLE, RTYP_IMDB, RTYP_METACRITIC, RTYP_ROTTEN_TOMATOES, }; use jellydb::RowNum; use log::info; use reqwest::{ Client, ClientBuilder, header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize}; use tokio::runtime::Handle; use crate::{ USER_AGENT, plugins::{ImportPlugin, PluginContext, PluginInfo}, source_rank::ObjectImportSourceExt, }; pub struct Omdb { client: Client, key: String, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] struct OmdbMovie { title: String, year: String, rated: String, released: String, runtime: String, director: String, writer: String, actors: String, plot: String, language: String, country: String, awards: String, poster: String, ratings: Vec, metascore: String, #[serde(rename = "imdbRating")] imdb_rating: String, #[serde(rename = "imdbVotes")] imdb_votes: String, #[serde(rename = "imdbID")] imdb_id: String, r#type: String, #[serde(rename = "DVD")] dvd: Option, box_office: Option, production: Option, website: Option, response: String, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] struct OmdbRating { source: String, value: String, } impl Omdb { pub fn new(api_key: &str) -> Self { let client = ClientBuilder::new() .default_headers(HeaderMap::from_iter([ ( HeaderName::from_static("accept"), HeaderValue::from_static("application/json"), ), ( HeaderName::from_static("user-agent"), HeaderValue::from_static(USER_AGENT), ), ])) .build() .unwrap(); Self { client, key: api_key.to_owned(), } } fn lookup(&self, cache: &Cache, id: &str, rt: &Handle) -> Result> { cache .cache_memory(&format!("ext/omdb/movie/{id}.json"), move || { rt.block_on(async { info!("lookup {id}"); let res = self .client .get(format!( "http://www.omdbapi.com/?apikey={}&i={id}", self.key )) .send() .await? .error_for_status()?; Ok(res.json().await?) }) }) .context("omdb lookup") } } impl ImportPlugin for Omdb { fn info(&self) -> PluginInfo { PluginInfo { name: "omdb", tag: MSOURCE_OMDB, handle_process: true, ..Default::default() } } fn process(&self, ct: &PluginContext, node: RowNum) -> Result<()> { let data = ct.ic.get_node(node)?.unwrap(); let data = data.as_object(); let Some(id) = data.get(NO_IDENTIFIERS).unwrap_or_default().get(IDENT_IMDB) else { return Ok(()); }; let entry = self.lookup(&ct.ic.cache, id, ct.rt)?; let imdb = match entry.imdb_rating.as_str() { "N/A" => None, v => Some(v.parse::().context("parse imdb rating")?), }; let metascore = match entry.metascore.as_str() { "N/A" => None, v => Some(v.parse::().context("parse metascore rating")?), }; let rotten_tomatoes = entry .ratings .iter() .find(|r| r.source == "Rotten Tomatoes") .map(|r| { r.value .strip_suffix("%") .ok_or(anyhow!("% missing"))? .parse::() .context("parse rotten tomatoes rating") }) .transpose()?; ct.ic.update_node(node, |mut node| { node = node.as_object().insert_s(ct.is, NO_TITLE, &entry.title); node = node .as_object() .insert_s(ct.is, NO_DESCRIPTION, &entry.plot); for (typ, val) in [ (RTYP_METACRITIC, metascore), (RTYP_IMDB, imdb), (RTYP_ROTTEN_TOMATOES, rotten_tomatoes), ] { if let Some(x) = val { node = node .as_object() .update(NO_RATINGS, |rts| rts.insert(typ, x)); } } node })?; Ok(()) } }