nako-metadata-scraper 0.1.0-alpha.2

Official Nako metadata scraper Addon Sidecar.
Documentation
use async_trait::async_trait;
use serde::Deserialize;

use crate::nako_runtime::{
    NakoPermission, NakoRuntimeClient, NakoRuntimeResult, NakoRuntimeTransport,
    NakoSideEffectResponse, NakoSideEffectSummary, NakoSideEffectTarget, NakoSideEffectTargetKind,
    SubmitNakoArtworkWriteRequest, SubmitNakoMetadataWriteRequest,
};

use super::{
    MetadataCandidate, MetadataQuery, artwork,
    side_effect::{
        SideEffectWritebackAdapter, SideEffectWritebackInput, SideEffectWritebackRequest,
        run_side_effect_writeback,
    },
};

#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetadataWritebackRequest {
    pub library_id: String,
    pub target: NakoSideEffectTarget,
    pub idempotency_key: String,
}

pub(crate) type MetadataWritebackInput = SideEffectWritebackInput<MetadataWritebackRequest>;

#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MetadataWritebackResult {
    pub status: MetadataWritebackStatus,
    #[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, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MetadataWritebackStatus {
    Submitted,
    Skipped,
    Failed,
}

impl SideEffectWritebackRequest for MetadataWritebackRequest {
    fn library_id(&self) -> &str {
        &self.library_id
    }

    fn target(&self) -> &NakoSideEffectTarget {
        &self.target
    }

    fn idempotency_key(&self) -> &str {
        &self.idempotency_key
    }
}

#[must_use]
pub(crate) fn metadata_writeback_input_from_payload(
    payload: &serde_json::Value,
) -> MetadataWritebackInput {
    MetadataWritebackInput::from_payload(payload, "writeback", "invalid_writeback_request")
}

pub(crate) async fn maybe_submit_metadata_writeback<T>(
    runtime: Option<&NakoRuntimeClient<T>>,
    request_id: &str,
    query: &MetadataQuery,
    selected_candidate: Option<&MetadataCandidate>,
    writeback_request: MetadataWritebackInput,
) -> Option<MetadataWritebackResult>
where
    T: NakoRuntimeTransport,
{
    run_side_effect_writeback(
        runtime,
        request_id,
        writeback_request,
        MetadataWritebackAdapter {
            request_id,
            query,
            selected_candidate,
        },
    )
    .await
}

pub(crate) async fn maybe_submit_artwork_writeback<T>(
    runtime: Option<&NakoRuntimeClient<T>>,
    request_id: &str,
    query: &MetadataQuery,
    candidates: &[MetadataCandidate],
    writeback_request: artwork::ArtworkWritebackInput,
) -> Option<artwork::ArtworkWritebackResult>
where
    T: NakoRuntimeTransport,
{
    run_side_effect_writeback(
        runtime,
        request_id,
        writeback_request,
        ArtworkWritebackAdapter {
            request_id,
            query,
            candidates,
        },
    )
    .await
}

#[must_use]
fn valid_metadata_target(target: &NakoSideEffectTarget) -> bool {
    target.kind == NakoSideEffectTargetKind::MediaSource
}

#[must_use]
fn metadata_write_summary(
    status: MetadataWritebackStatus,
    safe_error_code: Option<String>,
    side_effect: Option<NakoSideEffectSummary>,
) -> MetadataWritebackResult {
    MetadataWritebackResult {
        status,
        safe_error_code,
        side_effect,
    }
}

struct MetadataWritebackAdapter<'a> {
    request_id: &'a str,
    query: &'a MetadataQuery,
    selected_candidate: Option<&'a MetadataCandidate>,
}

struct MetadataWritebackSubmission {
    provenance: serde_json::Value,
    patch: nako_addon_protocol::AddonMetadataPatch,
}

#[async_trait]
impl<T> SideEffectWritebackAdapter<T> for MetadataWritebackAdapter<'_>
where
    T: NakoRuntimeTransport,
{
    type Request = MetadataWritebackRequest;
    type Prepared = MetadataWritebackSubmission;
    type Result = MetadataWritebackResult;

    fn operation_name(&self) -> &'static str {
        "metadata writeback"
    }

    fn permission(&self) -> NakoPermission {
        NakoPermission::MetadataWrite
    }

    fn validate_target(&self, target: &NakoSideEffectTarget) -> Result<(), &'static str> {
        valid_metadata_target(target)
            .then_some(())
            .ok_or("invalid_metadata_target_kind")
    }

    fn prepare(&self, _request: &Self::Request) -> Result<Self::Prepared, &'static str> {
        let Some(selected_candidate) = self.selected_candidate else {
            return Err("no_candidates");
        };

        Ok(MetadataWritebackSubmission {
            provenance: serde_json::json!({
                "origin": "nako-metadata-scraper",
                "request_id": self.request_id,
                "query": {
                    "title": self.query.title,
                    "year": self.query.year,
                    "language": self.query.language
                },
                "selected_candidate": {
                    "provider": selected_candidate.provider,
                    "provider_id": selected_candidate.provider_id,
                    "confidence_milli": selected_candidate.confidence_milli
                }
            }),
            patch: selected_candidate.patch.clone(),
        })
    }

    fn skipped(&self, safe_error_code: String) -> Self::Result {
        metadata_write_summary(
            MetadataWritebackStatus::Skipped,
            Some(safe_error_code),
            None,
        )
    }

    fn failed(&self, safe_error_code: String) -> Self::Result {
        metadata_write_summary(MetadataWritebackStatus::Failed, Some(safe_error_code), None)
    }

    fn submitted(&self, side_effect: NakoSideEffectSummary) -> Self::Result {
        metadata_write_summary(MetadataWritebackStatus::Submitted, None, Some(side_effect))
    }

    async fn submit(
        &self,
        runtime: &NakoRuntimeClient<T>,
        request: &Self::Request,
        prepared: Self::Prepared,
    ) -> NakoRuntimeResult<NakoSideEffectResponse> {
        runtime
            .submit_metadata_write(SubmitNakoMetadataWriteRequest {
                library_id: request.library_id().to_owned(),
                target: request.target().clone(),
                idempotency_key: request.idempotency_key().to_owned(),
                provenance: prepared.provenance,
                patch: prepared.patch,
            })
            .await
    }
}

impl SideEffectWritebackRequest for artwork::ArtworkWritebackRequest {
    fn library_id(&self) -> &str {
        &self.library_id
    }

    fn target(&self) -> &NakoSideEffectTarget {
        &self.target
    }

    fn idempotency_key(&self) -> &str {
        &self.idempotency_key
    }
}

#[cfg(test)]
mod tests {
    use crate::nako_runtime::{NakoSideEffectTarget, NakoSideEffectTargetKind};

    use super::*;

    #[test]
    fn metadata_writeback_input_parses_explicit_payload() {
        let input = metadata_writeback_input_from_payload(&serde_json::json!({
            "writeback": {
                "library_id": "library-1",
                "target": {
                    "kind": "media_source",
                    "id": "source-1"
                },
                "idempotency_key": "metadata-demo-1"
            }
        }));

        match input {
            MetadataWritebackInput::Requested(request) => {
                assert_eq!(request.library_id, "library-1");
                assert_eq!(request.target.kind, NakoSideEffectTargetKind::MediaSource);
                assert_eq!(request.idempotency_key, "metadata-demo-1");
            }
            other => panic!("unexpected metadata writeback input: {other:?}"),
        }
    }

    #[test]
    fn metadata_writeback_input_rejects_host_policy_fields() {
        let input = metadata_writeback_input_from_payload(&serde_json::json!({
            "writeback": {
                "library_id": "library-1",
                "target": {
                    "kind": "media_source",
                    "id": "source-1"
                },
                "idempotency_key": "metadata-demo-1",
                "refresh_mode": "full_refresh"
            }
        }));

        assert_eq!(
            input,
            MetadataWritebackInput::Invalid {
                safe_error_code: "invalid_writeback_request"
            }
        );
    }

    #[test]
    fn metadata_target_validation_is_media_source_only() {
        assert!(valid_metadata_target(&NakoSideEffectTarget {
            kind: NakoSideEffectTargetKind::MediaSource,
            id: "source-1".to_owned(),
        }));
        assert!(!valid_metadata_target(&NakoSideEffectTarget {
            kind: NakoSideEffectTargetKind::MediaItem,
            id: "item-1".to_owned(),
        }));
    }
}

struct ArtworkWritebackAdapter<'a> {
    request_id: &'a str,
    query: &'a MetadataQuery,
    candidates: &'a [MetadataCandidate],
}

struct ArtworkWritebackSubmission {
    provenance: serde_json::Value,
    artwork: nako_addon_protocol::AddonArtworkWritePayload,
}

#[async_trait]
impl<T> SideEffectWritebackAdapter<T> for ArtworkWritebackAdapter<'_>
where
    T: NakoRuntimeTransport,
{
    type Request = artwork::ArtworkWritebackRequest;
    type Prepared = ArtworkWritebackSubmission;
    type Result = artwork::ArtworkWritebackResult;

    fn operation_name(&self) -> &'static str {
        "artwork writeback"
    }

    fn permission(&self) -> NakoPermission {
        NakoPermission::ArtworkWrite
    }

    fn validate_target(&self, target: &NakoSideEffectTarget) -> Result<(), &'static str> {
        artwork::valid_artwork_target(target)
            .then_some(())
            .ok_or("invalid_artwork_target_kind")
    }

    fn prepare(&self, request: &Self::Request) -> Result<Self::Prepared, &'static str> {
        let Some(selected_candidate) =
            artwork::select_artwork_candidate(self.candidates, request.kind)
        else {
            return Err("no_artwork_candidates");
        };

        Ok(ArtworkWritebackSubmission {
            provenance: artwork::artwork_write_provenance(
                "nako-metadata-scraper",
                self.request_id,
                &self.query.title,
                self.query.year,
                &self.query.language,
                selected_candidate,
            ),
            artwork: selected_candidate.artwork.clone(),
        })
    }

    fn skipped(&self, safe_error_code: String) -> Self::Result {
        artwork::artwork_write_summary(
            artwork::ArtworkWritebackStatus::Skipped,
            Some(safe_error_code),
            None,
        )
    }

    fn failed(&self, safe_error_code: String) -> Self::Result {
        artwork::artwork_write_summary(
            artwork::ArtworkWritebackStatus::Failed,
            Some(safe_error_code),
            None,
        )
    }

    fn submitted(&self, side_effect: NakoSideEffectSummary) -> Self::Result {
        artwork::artwork_write_summary(
            artwork::ArtworkWritebackStatus::Submitted,
            None,
            Some(side_effect),
        )
    }

    async fn submit(
        &self,
        runtime: &NakoRuntimeClient<T>,
        request: &Self::Request,
        prepared: Self::Prepared,
    ) -> NakoRuntimeResult<NakoSideEffectResponse> {
        runtime
            .submit_artwork_write(SubmitNakoArtworkWriteRequest {
                library_id: request.library_id().to_owned(),
                target: request.target().clone(),
                idempotency_key: request.idempotency_key().to_owned(),
                provenance: prepared.provenance,
                artwork: prepared.artwork,
            })
            .await
    }
}