Skip to main content

stremio_addon_core/
binge.rs

1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
3use sha2::{Digest, Sha256};
4
5#[derive(Clone, Debug, Default, PartialEq, Eq)]
6pub struct BingeGroupInput {
7    pub provider: String,
8    pub tier: String,
9    pub content_id: String,
10    pub season: Option<u32>,
11    pub quality: Option<String>,
12    pub language: Option<String>,
13    pub source: Option<String>,
14    pub codec: Option<String>,
15    pub release_group: Option<String>,
16}
17
18pub fn binge_group_id(input: &BingeGroupInput) -> String {
19    let tier = normalize_field(&input.tier);
20    let payload = serialize_binge_payload(input);
21    let mut hasher = Sha256::new();
22    hasher.update(payload.as_bytes());
23    let hash = URL_SAFE_NO_PAD.encode(hasher.finalize());
24
25    format!("stremio-core:bg:v1:{tier}:{hash}")
26}
27
28fn serialize_binge_payload(input: &BingeGroupInput) -> String {
29    let fields = [
30        ("provider", normalize_field(&input.provider)),
31        ("content_id", normalize_field(&input.content_id)),
32        ("season", input.season.unwrap_or_default().to_string()),
33        ("quality", normalize_field_opt(&input.quality)),
34        ("language", normalize_field_opt(&input.language)),
35        ("source", normalize_field_opt(&input.source)),
36        ("codec", normalize_field_opt(&input.codec)),
37        ("release_group", normalize_field_opt(&input.release_group)),
38    ];
39    let mut payload = String::new();
40    for (key, value) in fields {
41        payload.push_str(key);
42        payload.push('=');
43        payload.push_str(&value.len().to_string());
44        payload.push(':');
45        payload.push_str(&value);
46        payload.push(';');
47    }
48    payload
49}
50
51fn normalize_field(value: &str) -> String {
52    value.trim().to_ascii_lowercase()
53}
54
55fn normalize_field_opt(value: &Option<String>) -> String {
56    value
57        .as_deref()
58        .map_or_else(|| "na".to_string(), normalize_field)
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn binge_group_is_stable_for_same_input() {
67        let first = BingeGroupInput {
68            provider: "test-provider".to_string(),
69            tier: "movie".to_string(),
70            content_id: "tt123".to_string(),
71            season: Some(1),
72            quality: Some("1080p".to_string()),
73            language: Some("en".to_string()),
74            source: Some("web-dl".to_string()),
75            codec: Some("h265".to_string()),
76            release_group: Some("example-rg".to_string()),
77        };
78
79        let left = binge_group_id(&first);
80        let right = binge_group_id(&first);
81        assert_eq!(left, right);
82        assert_eq!(left.split(':').nth(3), Some("movie"));
83        assert!(left.contains("stremio-core:bg:v1:movie:"));
84    }
85
86    #[test]
87    fn binge_group_does_not_leak_raw_values() {
88        let input = BingeGroupInput {
89            provider: "provider-X".to_string(),
90            tier: "series".to_string(),
91            content_id: "some-id-999".to_string(),
92            season: Some(2),
93            quality: Some("720p".to_string()),
94            language: Some("CZ".to_string()),
95            source: Some("url/segment".to_string()),
96            codec: Some("h264".to_string()),
97            release_group: Some("RG+token".to_string()),
98        };
99
100        let id = binge_group_id(&input);
101        assert!(!id.contains("provider-X"));
102        assert!(!id.contains("some-id-999"));
103        assert!(!id.contains("RG+token"));
104        assert!(!id.contains("url/segment"));
105    }
106
107    #[test]
108    fn binge_group_changes_with_provider_fields() {
109        let base = BingeGroupInput {
110            provider: "provider-a".to_string(),
111            tier: "series".to_string(),
112            content_id: "tt123".to_string(),
113            season: Some(1),
114            quality: Some("1080p".to_string()),
115            ..BingeGroupInput::default()
116        };
117        let mut changed = base.clone();
118        changed.quality = Some("720p".to_string());
119        assert_ne!(binge_group_id(&base), binge_group_id(&changed));
120
121        changed.quality = None;
122        assert_ne!(binge_group_id(&base), binge_group_id(&changed));
123    }
124
125    #[test]
126    fn binge_group_payload_is_not_delimiter_ambiguous() {
127        let left = BingeGroupInput {
128            provider: "provider|content_id=2:x".to_string(),
129            tier: "series".to_string(),
130            content_id: "tt123".to_string(),
131            season: Some(1),
132            quality: Some("1080p".to_string()),
133            ..BingeGroupInput::default()
134        };
135        let mut right = left.clone();
136        right.provider = "provider".to_string();
137        right.content_id = "content_id=2:x|tt123".to_string();
138
139        assert_ne!(binge_group_id(&left), binge_group_id(&right));
140    }
141}