nako-metadata-scraper 0.1.0-alpha.2

Official Nako metadata scraper Addon Sidecar.
Documentation
use nako_addon_protocol::{
    AddonMetadataCollection, AddonMetadataCredit, AddonMetadataExternalId, AddonMetadataImage,
    AddonMetadataPatch, AddonMetadataStudio,
};

use super::{
    AvMetadataFacts, ProviderArtworkCandidate, ProviderExternalId, ProviderMetadataCandidate,
};

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct NativeMetadataProjection {
    pub(crate) patch: AddonMetadataPatch,
    pub(crate) av: Option<AvMetadataFacts>,
    pub(crate) artwork_candidates: Vec<ProviderArtworkCandidate>,
}

pub(crate) fn project_provider_candidate(
    candidate: &ProviderMetadataCandidate,
) -> NativeMetadataProjection {
    let av = candidate.facts.av.clone();
    let mut patch = candidate.patch.clone();
    materialize_native_metadata_patch(
        &mut patch,
        &candidate.provider,
        av.as_ref(),
        &candidate.facts.external_ids,
        &candidate.artwork_candidates,
    );

    NativeMetadataProjection {
        patch,
        av,
        artwork_candidates: candidate.artwork_candidates.clone(),
    }
}

pub(crate) fn materialize_native_metadata_patch(
    patch: &mut AddonMetadataPatch,
    provider: &str,
    av: Option<&AvMetadataFacts>,
    external_ids: &[ProviderExternalId],
    artwork_candidates: &[ProviderArtworkCandidate],
) {
    merge_external_ids(patch, external_ids);
    merge_images(patch, provider, av, artwork_candidates);

    let Some(av) = av else {
        return;
    };

    merge_credits(patch, av);
    merge_studios(patch, av);
    merge_collections(patch, av);
}

fn merge_external_ids(patch: &mut AddonMetadataPatch, external_ids: &[ProviderExternalId]) {
    let mut merged = patch.external_ids.take().unwrap_or_default();
    for external_id in external_ids {
        push_external_id(&mut merged, &external_id.provider, &external_id.value);
    }
    patch.external_ids = (!merged.is_empty()).then_some(merged);
}

fn merge_images(
    patch: &mut AddonMetadataPatch,
    provider: &str,
    av: Option<&AvMetadataFacts>,
    artwork_candidates: &[ProviderArtworkCandidate],
) {
    let mut merged = patch.images.take().unwrap_or_default();
    for candidate in artwork_candidates {
        push_image(
            &mut merged,
            candidate.facts.kind.as_str(),
            &candidate.facts.source_url,
            &candidate.provider,
            candidate.facts.width,
            candidate.facts.height,
            candidate.facts.language.clone(),
        );
    }

    if let Some(av) = av {
        if let Some(url) = av.thumb_url.as_deref() {
            push_image(&mut merged, "thumbnail", url, provider, None, None, None);
        }
        for url in &av.extrafanart_urls {
            push_image(&mut merged, "backdrop", url, provider, None, None, None);
        }
        if let Some(url) = av.trailer_url.as_deref() {
            push_external_id(
                patch.external_ids.get_or_insert_with(Vec::new),
                "trailer_url",
                url,
            );
        }
    }

    patch.images = (!merged.is_empty()).then_some(merged);
}

fn merge_credits(patch: &mut AddonMetadataPatch, av: &AvMetadataFacts) {
    let mut merged = patch.credits.take().unwrap_or_default();
    let actor_names = if av.all_actors.is_empty() {
        &av.actors
    } else {
        &av.all_actors
    };

    for (index, actor) in actor_names.iter().enumerate() {
        push_credit(&mut merged, actor, "actor", Some((index + 1) as u32));
    }
    for director in &av.directors {
        push_credit(&mut merged, director, "director", None);
    }

    patch.credits = (!merged.is_empty()).then_some(merged);
}

fn merge_studios(patch: &mut AddonMetadataPatch, av: &AvMetadataFacts) {
    let mut merged = patch.studios.take().unwrap_or_default();
    for name in [
        av.studio.as_deref(),
        av.maker.as_deref(),
        av.publisher.as_deref(),
        av.label.as_deref(),
    ]
    .into_iter()
    .flatten()
    {
        push_studio(&mut merged, name);
    }
    patch.studios = (!merged.is_empty()).then_some(merged);
}

fn merge_collections(patch: &mut AddonMetadataPatch, av: &AvMetadataFacts) {
    let Some(series) = av.series.as_deref() else {
        return;
    };

    let mut merged = patch.collections.take().unwrap_or_default();
    if normalized(series).is_some()
        && !merged
            .iter()
            .any(|collection| same_label(&collection.name, series))
    {
        merged.push(AddonMetadataCollection {
            name: series.trim().to_owned(),
            overview: None,
            sort_order: None,
            external_ids: Vec::new(),
        });
    }
    patch.collections = (!merged.is_empty()).then_some(merged);
}

fn push_external_id(values: &mut Vec<AddonMetadataExternalId>, provider: &str, value: &str) {
    let Some(provider) = normalized(provider) else {
        return;
    };
    let Some(value) = normalized(value) else {
        return;
    };
    if values.iter().any(|existing| {
        same_label(&existing.provider, &provider) && same_label(&existing.value, &value)
    }) {
        return;
    }
    values.push(AddonMetadataExternalId { provider, value });
}

fn push_image(
    values: &mut Vec<AddonMetadataImage>,
    kind: &str,
    uri: &str,
    provider: &str,
    width: Option<u32>,
    height: Option<u32>,
    language: Option<String>,
) {
    let Some(kind) = normalized(kind) else {
        return;
    };
    let Some(uri) = normalized(uri) else {
        return;
    };
    let Some(provider) = normalized(provider) else {
        return;
    };
    if values
        .iter()
        .any(|existing| same_label(&existing.kind, &kind) && same_label(&existing.uri, &uri))
    {
        return;
    }
    values.push(AddonMetadataImage {
        kind,
        uri,
        provider,
        width,
        height,
        language: language.and_then(|value| normalized(&value)),
    });
}

fn push_credit(values: &mut Vec<AddonMetadataCredit>, name: &str, role: &str, order: Option<u32>) {
    let Some(name) = normalized(name) else {
        return;
    };
    if values
        .iter()
        .any(|existing| same_label(&existing.name, &name) && same_label(&existing.role, role))
    {
        return;
    }
    values.push(AddonMetadataCredit {
        name,
        role: role.to_owned(),
        character: None,
        order,
        external_ids: Vec::new(),
    });
}

fn push_studio(values: &mut Vec<AddonMetadataStudio>, name: &str) {
    let Some(name) = normalized(name) else {
        return;
    };
    if values
        .iter()
        .any(|existing| same_label(&existing.name, &name))
    {
        return;
    }
    values.push(AddonMetadataStudio {
        name,
        external_ids: Vec::new(),
    });
}

fn normalized(value: &str) -> Option<String> {
    let value = value.trim();
    (!value.is_empty()).then(|| value.to_owned())
}

fn same_label(left: &str, right: &str) -> bool {
    left.trim().eq_ignore_ascii_case(right.trim())
}

#[cfg(test)]
mod tests {
    use nako_addon_protocol::{AddonArtworkKind, AddonMetadataPatch};

    use crate::engine::{
        AvMetadataFacts, ProviderArtworkCandidate, ProviderArtworkCandidateFacts,
        ProviderExternalId,
    };

    use super::materialize_native_metadata_patch;

    #[test]
    fn av_facts_materialize_into_native_metadata_patch() {
        let av = AvMetadataFacts {
            actors: vec!["Displayed Actor".to_owned()],
            all_actors: vec!["Alice Actor".to_owned(), "Bob Actor".to_owned()],
            directors: vec!["Diana Director".to_owned()],
            series: Some("Series One".to_owned()),
            studio: Some("Studio One".to_owned()),
            maker: Some("Studio One".to_owned()),
            publisher: Some("Publisher One".to_owned()),
            label: Some("Label One".to_owned()),
            thumb_url: Some("https://img.example/thumb.jpg".to_owned()),
            trailer_url: Some("https://video.example/trailer.mp4".to_owned()),
            extrafanart_urls: vec!["https://img.example/backdrop.jpg".to_owned()],
            ..AvMetadataFacts::default()
        };
        let artwork = vec![ProviderArtworkCandidate {
            provider: "javdb".to_owned(),
            provider_id: "javdb:movie:abc-001:poster".to_owned(),
            facts: ProviderArtworkCandidateFacts {
                kind: AddonArtworkKind::Poster,
                source_url: "https://img.example/poster.jpg".to_owned(),
                language: Some("ja".to_owned()),
                width: Some(800),
                height: Some(1200),
            },
        }];
        let external_ids = vec![ProviderExternalId {
            provider: "javdb".to_owned(),
            value: "abc-001".to_owned(),
        }];
        let mut patch = AddonMetadataPatch::default();

        materialize_native_metadata_patch(&mut patch, "javdb", Some(&av), &external_ids, &artwork);

        let credits = patch.credits.as_ref().unwrap();
        assert_eq!(credits.len(), 3);
        assert_eq!(credits[0].name, "Alice Actor");
        assert_eq!(credits[0].role, "actor");
        assert_eq!(credits[2].name, "Diana Director");
        assert_eq!(credits[2].role, "director");

        let studios = patch.studios.as_ref().unwrap();
        assert_eq!(studios.len(), 3);
        assert!(studios.iter().any(|studio| studio.name == "Studio One"));
        assert!(studios.iter().any(|studio| studio.name == "Publisher One"));
        assert!(studios.iter().any(|studio| studio.name == "Label One"));
        assert_eq!(patch.collections.as_ref().unwrap()[0].name, "Series One");

        let images = patch.images.as_ref().unwrap();
        assert_eq!(images.len(), 3);
        assert!(images.iter().any(|image| image.kind == "poster"));
        assert!(images.iter().any(|image| image.kind == "thumbnail"));
        assert!(images.iter().any(|image| image.kind == "backdrop"));

        let ids = patch.external_ids.as_ref().unwrap();
        assert!(
            ids.iter()
                .any(|id| id.provider == "javdb" && id.value == "abc-001")
        );
        assert!(ids.iter().any(|id| id.provider == "trailer_url"));
    }
}