stremio-addon-core 0.1.4

Reusable Rust core for authenticated Stremio addon servers
Documentation
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use sha2::{Digest, Sha256};

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BingeGroupInput {
    pub provider: String,
    pub tier: String,
    pub content_id: String,
    pub season: Option<u32>,
    pub quality: Option<String>,
    pub language: Option<String>,
    pub source: Option<String>,
    pub codec: Option<String>,
    pub release_group: Option<String>,
}

pub fn binge_group_id(input: &BingeGroupInput) -> String {
    let tier = normalize_field(&input.tier);
    let payload = serialize_binge_payload(input);
    let mut hasher = Sha256::new();
    hasher.update(payload.as_bytes());
    let hash = URL_SAFE_NO_PAD.encode(hasher.finalize());

    format!("stremio-core:bg:v1:{tier}:{hash}")
}

fn serialize_binge_payload(input: &BingeGroupInput) -> String {
    let fields = [
        ("provider", normalize_field(&input.provider)),
        ("content_id", normalize_field(&input.content_id)),
        ("season", input.season.unwrap_or_default().to_string()),
        ("quality", normalize_field_opt(&input.quality)),
        ("language", normalize_field_opt(&input.language)),
        ("source", normalize_field_opt(&input.source)),
        ("codec", normalize_field_opt(&input.codec)),
        ("release_group", normalize_field_opt(&input.release_group)),
    ];
    let mut payload = String::new();
    for (key, value) in fields {
        payload.push_str(key);
        payload.push('=');
        payload.push_str(&value.len().to_string());
        payload.push(':');
        payload.push_str(&value);
        payload.push(';');
    }
    payload
}

fn normalize_field(value: &str) -> String {
    value.trim().to_ascii_lowercase()
}

fn normalize_field_opt(value: &Option<String>) -> String {
    value
        .as_deref()
        .map_or_else(|| "na".to_string(), normalize_field)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn binge_group_is_stable_for_same_input() {
        let first = BingeGroupInput {
            provider: "test-provider".to_string(),
            tier: "movie".to_string(),
            content_id: "tt123".to_string(),
            season: Some(1),
            quality: Some("1080p".to_string()),
            language: Some("en".to_string()),
            source: Some("web-dl".to_string()),
            codec: Some("h265".to_string()),
            release_group: Some("example-rg".to_string()),
        };

        let left = binge_group_id(&first);
        let right = binge_group_id(&first);
        assert_eq!(left, right);
        assert_eq!(left.split(':').nth(3), Some("movie"));
        assert!(left.contains("stremio-core:bg:v1:movie:"));
    }

    #[test]
    fn binge_group_does_not_leak_raw_values() {
        let input = BingeGroupInput {
            provider: "provider-X".to_string(),
            tier: "series".to_string(),
            content_id: "some-id-999".to_string(),
            season: Some(2),
            quality: Some("720p".to_string()),
            language: Some("CZ".to_string()),
            source: Some("url/segment".to_string()),
            codec: Some("h264".to_string()),
            release_group: Some("RG+token".to_string()),
        };

        let id = binge_group_id(&input);
        assert!(!id.contains("provider-X"));
        assert!(!id.contains("some-id-999"));
        assert!(!id.contains("RG+token"));
        assert!(!id.contains("url/segment"));
    }

    #[test]
    fn binge_group_changes_with_provider_fields() {
        let base = BingeGroupInput {
            provider: "provider-a".to_string(),
            tier: "series".to_string(),
            content_id: "tt123".to_string(),
            season: Some(1),
            quality: Some("1080p".to_string()),
            ..BingeGroupInput::default()
        };
        let mut changed = base.clone();
        changed.quality = Some("720p".to_string());
        assert_ne!(binge_group_id(&base), binge_group_id(&changed));

        changed.quality = None;
        assert_ne!(binge_group_id(&base), binge_group_id(&changed));
    }

    #[test]
    fn binge_group_payload_is_not_delimiter_ambiguous() {
        let left = BingeGroupInput {
            provider: "provider|content_id=2:x".to_string(),
            tier: "series".to_string(),
            content_id: "tt123".to_string(),
            season: Some(1),
            quality: Some("1080p".to_string()),
            ..BingeGroupInput::default()
        };
        let mut right = left.clone();
        right.provider = "provider".to_string();
        right.content_id = "content_id=2:x|tt123".to_string();

        assert_ne!(binge_group_id(&left), binge_group_id(&right));
    }
}