Skip to main content

crispy_xtream/
parse.rs

1//! Response parsing helpers.
2//!
3//! Xtream servers frequently base64-encode EPG titles and descriptions.
4//! This module provides transparent decoding utilities.
5
6use base64::Engine;
7use base64::engine::general_purpose::STANDARD;
8
9/// Attempt to decode a base64-encoded string.
10///
11/// Returns the decoded UTF-8 string if the input is valid base64 that
12/// decodes to valid UTF-8. Returns the original string unchanged otherwise.
13pub fn maybe_decode_base64(input: &str) -> String {
14    if input.is_empty() {
15        return String::new();
16    }
17
18    // Quick heuristic: base64 strings from Xtream servers are typically
19    // at least 4 chars, contain only base64 alphabet, and are padded.
20    // We try to decode and fall back to the original on failure.
21    match STANDARD.decode(input.trim()) {
22        Ok(bytes) => match String::from_utf8(bytes) {
23            Ok(decoded) => {
24                // Sanity check: if the "decoded" string looks like garbage
25                // (contains many control chars), the input was probably not
26                // base64 after all.
27                let control_count = decoded
28                    .chars()
29                    .filter(|c| c.is_control() && *c != '\n')
30                    .count();
31                if decoded.len() > 4 && control_count > decoded.len() / 4 {
32                    input.to_string()
33                } else {
34                    decoded
35                }
36            }
37            Err(_) => input.to_string(),
38        },
39        Err(_) => input.to_string(),
40    }
41}
42
43/// Decode EPG title/description fields in-place.
44///
45/// Xtream servers may return titles and descriptions as base64 or plain text.
46/// This function attempts to decode and replaces the value only when valid.
47pub fn decode_epg_field(field: &Option<String>) -> Option<String> {
48    field.as_ref().map(|s| maybe_decode_base64(s))
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn decodes_valid_base64() {
57        // "News" in base64
58        assert_eq!(maybe_decode_base64("TmV3cw=="), "News");
59    }
60
61    #[test]
62    fn decodes_base64_description() {
63        // "Daily news" in base64
64        assert_eq!(maybe_decode_base64("RGFpbHkgbmV3cw=="), "Daily news");
65    }
66
67    #[test]
68    fn returns_plain_text_unchanged() {
69        assert_eq!(maybe_decode_base64("Just plain text"), "Just plain text");
70    }
71
72    #[test]
73    fn handles_empty_string() {
74        assert_eq!(maybe_decode_base64(""), "");
75    }
76
77    #[test]
78    fn handles_unicode_plain_text() {
79        let text = "Programme en fran\u{00e7}ais";
80        assert_eq!(maybe_decode_base64(text), text);
81    }
82
83    #[test]
84    fn decode_epg_field_some() {
85        let field = Some("TmV3cw==".to_string());
86        assert_eq!(decode_epg_field(&field), Some("News".to_string()));
87    }
88
89    #[test]
90    fn decode_epg_field_none() {
91        assert_eq!(decode_epg_field(&None), None);
92    }
93
94    #[test]
95    fn decodes_base64_with_whitespace() {
96        assert_eq!(maybe_decode_base64("  TmV3cw==  "), "News");
97    }
98}