stremio_addon_core/
binge.rs1use 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}