use std::cmp::Ordering;
use nako_addon_protocol::{
AddonArtworkIntent, AddonArtworkKind, AddonArtworkSourceKind, AddonArtworkSourcePayload,
AddonArtworkWritePayload,
};
use serde::{Deserialize, Serialize};
use crate::nako_runtime::{NakoSideEffectSummary, NakoSideEffectTarget, NakoSideEffectTargetKind};
use super::{ranking::MetadataCandidate, side_effect::SideEffectWritebackInput};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProviderArtworkCandidate {
pub provider: String,
pub provider_id: String,
pub facts: ProviderArtworkCandidateFacts,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProviderArtworkCandidateFacts {
pub kind: AddonArtworkKind,
pub source_url: String,
pub language: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
}
impl ProviderArtworkCandidateFacts {
#[must_use]
pub fn into_artwork(self) -> AddonArtworkWritePayload {
AddonArtworkWritePayload {
intent: AddonArtworkIntent::ProposeArtwork,
kind: self.kind,
source: AddonArtworkSourcePayload {
kind: AddonArtworkSourceKind::RemoteUrl,
url: self.source_url,
},
language: self.language,
width: self.width,
height: self.height,
}
}
}
#[derive(Clone, Debug, Serialize, Eq, PartialEq)]
pub struct ArtworkCandidate {
pub provider: String,
pub provider_id: String,
pub confidence_milli: u16,
pub artwork: AddonArtworkWritePayload,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub(crate) struct ArtworkWritebackRequest {
pub library_id: String,
pub target: NakoSideEffectTarget,
pub idempotency_key: String,
pub kind: AddonArtworkKind,
}
pub(crate) type ArtworkWritebackInput = SideEffectWritebackInput<ArtworkWritebackRequest>;
#[derive(Clone, Debug, Serialize, Eq, PartialEq)]
pub struct ArtworkWritebackResult {
pub status: ArtworkWritebackStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub safe_error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub side_effect: Option<NakoSideEffectSummary>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtworkWritebackStatus {
Submitted,
Skipped,
Failed,
}
#[must_use]
pub(crate) fn artwork_writeback_input_from_payload(
payload: &serde_json::Value,
) -> ArtworkWritebackInput {
ArtworkWritebackInput::from_payload(
payload,
"artwork_writeback",
"invalid_artwork_writeback_request",
)
}
#[must_use]
pub fn select_artwork_candidate(
candidates: &[MetadataCandidate],
kind: AddonArtworkKind,
) -> Option<&ArtworkCandidate> {
candidates
.iter()
.flat_map(|candidate| candidate.artwork_candidates.iter())
.filter(|artwork_candidate| artwork_candidate.artwork.kind == kind)
.max_by(|left, right| compare_artwork_candidates(left, right))
}
fn compare_artwork_candidates(left: &ArtworkCandidate, right: &ArtworkCandidate) -> Ordering {
left.confidence_milli
.cmp(&right.confidence_milli)
.then_with(|| artwork_area(&left.artwork).cmp(&artwork_area(&right.artwork)))
.then_with(|| left.provider.cmp(&right.provider))
.then_with(|| left.provider_id.cmp(&right.provider_id))
.then_with(|| left.artwork.source.url.cmp(&right.artwork.source.url))
}
fn artwork_area(artwork: &AddonArtworkWritePayload) -> u64 {
match (artwork.width, artwork.height) {
(Some(width), Some(height)) => u64::from(width) * u64::from(height),
_ => 0,
}
}
#[must_use]
pub fn valid_artwork_target(target: &NakoSideEffectTarget) -> bool {
target.kind == NakoSideEffectTargetKind::MediaItem
}
#[must_use]
pub fn artwork_write_provenance(
origin: &str,
request_id: &str,
query_title: &str,
query_year: Option<i32>,
query_language: &str,
selected_candidate: &ArtworkCandidate,
) -> serde_json::Value {
serde_json::json!({
"origin": origin,
"request_id": request_id,
"query": {
"title": query_title,
"year": query_year,
"language": query_language
},
"selected_candidate": {
"provider": selected_candidate.provider,
"provider_id": selected_candidate.provider_id,
"confidence_milli": selected_candidate.confidence_milli,
"kind": selected_candidate.artwork.kind,
}
})
}
#[must_use]
pub(crate) fn artwork_write_summary(
status: ArtworkWritebackStatus,
safe_error_code: Option<String>,
side_effect: Option<NakoSideEffectSummary>,
) -> ArtworkWritebackResult {
ArtworkWritebackResult {
status,
safe_error_code,
side_effect,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::ranking::{
ExternalIdMatchEvidence, LanguageMatchEvidence, TitleMatchEvidence, YearMatchEvidence,
};
use crate::engine::{CandidateEvidence, MetadataCandidate};
fn candidate(
provider: &str,
provider_id: &str,
confidence_milli: u16,
source_url: &str,
width: Option<u32>,
height: Option<u32>,
) -> MetadataCandidate {
MetadataCandidate {
provider: provider.to_owned(),
provider_id: provider_id.to_owned(),
confidence_milli,
patch: nako_addon_protocol::AddonMetadataPatch::default(),
av: None,
artwork_candidates: vec![ArtworkCandidate {
provider: provider.to_owned(),
provider_id: provider_id.to_owned(),
confidence_milli,
artwork: AddonArtworkWritePayload {
intent: AddonArtworkIntent::ProposeArtwork,
kind: AddonArtworkKind::Poster,
source: AddonArtworkSourcePayload {
kind: AddonArtworkSourceKind::RemoteUrl,
url: source_url.to_owned(),
},
language: Some("en".to_owned()),
width,
height,
},
}],
evidence: CandidateEvidence {
title_match: TitleMatchEvidence::MissingCandidate,
year_match: YearMatchEvidence::QueryMissing,
language_match: LanguageMatchEvidence::Exact,
external_id_match: ExternalIdMatchEvidence::QueryMissing,
score_reasons: Vec::new(),
field_sources: Vec::new(),
provider_sources: Vec::new(),
merge_reasons: Vec::new(),
provider_note: None,
},
}
}
#[test]
fn provider_artwork_candidate_facts_map_to_artwork_payload() {
let artwork = ProviderArtworkCandidateFacts {
kind: AddonArtworkKind::Poster,
source_url: "https://example.test/poster.jpg".to_owned(),
language: Some("en".to_owned()),
width: Some(1000),
height: Some(1500),
}
.into_artwork();
assert_eq!(artwork.intent, AddonArtworkIntent::ProposeArtwork);
assert_eq!(artwork.kind, AddonArtworkKind::Poster);
assert_eq!(artwork.source.kind, AddonArtworkSourceKind::RemoteUrl);
assert_eq!(artwork.source.url, "https://example.test/poster.jpg");
}
#[test]
fn artwork_writeback_input_parses_explicit_payload() {
let input = artwork_writeback_input_from_payload(&serde_json::json!({
"artwork_writeback": {
"library_id": "library-1",
"target": {
"kind": "media_item",
"id": "item-1"
},
"idempotency_key": "artwork-demo-1",
"kind": "poster"
}
}));
match input {
ArtworkWritebackInput::Requested(request) => {
assert_eq!(request.library_id, "library-1");
assert_eq!(request.target.kind, NakoSideEffectTargetKind::MediaItem);
assert_eq!(request.kind, AddonArtworkKind::Poster);
}
other => panic!("unexpected artwork writeback input: {other:?}"),
}
}
#[test]
fn artwork_target_validation_is_media_item_only() {
assert!(valid_artwork_target(&NakoSideEffectTarget {
kind: NakoSideEffectTargetKind::MediaItem,
id: "item-1".to_owned(),
}));
assert!(!valid_artwork_target(&NakoSideEffectTarget {
kind: NakoSideEffectTargetKind::MediaSource,
id: "source-1".to_owned(),
}));
}
#[test]
fn select_artwork_candidate_prefers_higher_confidence_candidate() {
let candidates = vec![
candidate(
"tmdb",
"tmdb:poster:1",
600,
"https://example.test/low.jpg",
Some(1000),
Some(1500),
),
candidate(
"bangumi",
"bangumi:poster:2",
850,
"https://example.test/high.jpg",
Some(900),
Some(1350),
),
];
let selected = select_artwork_candidate(&candidates, AddonArtworkKind::Poster)
.expect("expected a poster candidate");
assert_eq!(selected.provider, "bangumi");
assert_eq!(selected.provider_id, "bangumi:poster:2");
assert_eq!(selected.artwork.source.url, "https://example.test/high.jpg");
}
#[test]
fn select_artwork_candidate_uses_resolution_as_tiebreaker() {
let candidates = vec![
candidate(
"tmdb",
"tmdb:poster:1",
750,
"https://example.test/small.jpg",
Some(1000),
Some(1500),
),
candidate(
"bangumi",
"bangumi:poster:2",
750,
"https://example.test/large.jpg",
Some(1200),
Some(1800),
),
];
let selected = select_artwork_candidate(&candidates, AddonArtworkKind::Poster)
.expect("expected a poster candidate");
assert_eq!(selected.provider, "bangumi");
assert_eq!(
selected.artwork.source.url,
"https://example.test/large.jpg"
);
}
}