Skip to main content

crispy_catchup/
provider.rs

1//! Provider-specific URL regex parsing for Flussonic and Xtream Codes.
2//!
3//! Translated from `Channel::GenerateFlussonicCatchupSource()` and
4//! `Channel::GenerateXtreamCodesCatchupSource()` in `Channel.cpp`.
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9use crate::error::CatchupError;
10
11// ---------------------------------------------------------------------------
12// Flussonic
13// ---------------------------------------------------------------------------
14
15/// Regex for well-defined Flussonic stream URLs.
16///
17/// Examples:
18/// - `http://ch01.spr24.net/151/mpegts?token=my_token`
19/// - `http://list.tv:8888/325/index.m3u8?token=secret`
20/// - `http://list.tv:8888/325/mono.m3u8?token=secret`
21static FLUSSONIC_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r"^(https?://[^/]+)/(.*)/([^/]*)(mpegts|\.m3u8)(\?.+=.+)?$")
23        .expect("flussonic regex")
24});
25
26/// Regex for generic Flussonic URLs (fallback).
27///
28/// Example: `http://list.tv:8888/325/live?token=my_token`
29static FLUSSONIC_GENERIC_REGEX: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(r"^(https?://[^/]+)/(.*)/([^\?]*)(\?.+=.+)?$").expect("flussonic generic regex")
31});
32
33/// Generate a Flussonic catchup source URL from a stream URL.
34///
35/// Returns `(catchup_source, is_ts_stream)`.
36///
37/// Translated from `Channel::GenerateFlussonicCatchupSource()` in `Channel.cpp`.
38///
39/// # Stream URL patterns
40///
41/// ```text
42/// stream:  http://ch01.spr24.net/151/mpegts?token=my_token
43/// catchup: http://ch01.spr24.net/151/timeshift_abs-${start}.ts?token=my_token
44///
45/// stream:  http://list.tv:8888/325/index.m3u8?token=secret
46/// catchup: http://list.tv:8888/325/timeshift_rel-{offset:1}.m3u8?token=secret
47///
48/// stream:  http://list.tv:8888/325/mono.m3u8?token=secret
49/// catchup: http://list.tv:8888/325/mono-timeshift_rel-{offset:1}.m3u8?token=secret
50///
51/// stream:  http://list.tv:8888/325/live?token=my_token
52/// catchup: http://list.tv:8888/325/{utc}.ts?token=my_token
53/// ```
54pub fn generate_flussonic_source(
55    url: &str,
56    is_ts_hint: bool,
57) -> Result<(String, bool), CatchupError> {
58    // Try the well-defined regex first
59    if let Some(caps) = FLUSSONIC_REGEX.captures(url) {
60        let host = caps.get(1).map_or("", |m| m.as_str());
61        let channel_id = caps.get(2).map_or("", |m| m.as_str());
62        let list_type = caps.get(3).map_or("", |m| m.as_str());
63        let stream_type = caps.get(4).map_or("", |m| m.as_str());
64        let url_append = caps.get(5).map_or("", |m| m.as_str());
65
66        let is_ts = stream_type == "mpegts";
67        if is_ts {
68            let source = format!("{host}/{channel_id}/timeshift_abs-${{start}}.ts{url_append}");
69            return Ok((source, true));
70        }
71
72        let source = if list_type == "index" {
73            format!("{host}/{channel_id}/timeshift_rel-{{offset:1}}.m3u8{url_append}")
74        } else {
75            format!("{host}/{channel_id}/{list_type}-timeshift_rel-{{offset:1}}.m3u8{url_append}")
76        };
77        return Ok((source, false));
78    }
79
80    // Fallback to generic regex
81    if let Some(caps) = FLUSSONIC_GENERIC_REGEX.captures(url) {
82        let host = caps.get(1).map_or("", |m| m.as_str());
83        let channel_id = caps.get(2).map_or("", |m| m.as_str());
84        let url_append = caps.get(4).map_or("", |m| m.as_str());
85
86        if is_ts_hint {
87            let source = format!("{host}/{channel_id}/timeshift_abs-${{start}}.ts{url_append}");
88            return Ok((source, true));
89        }
90
91        let source = format!("{host}/{channel_id}/timeshift_rel-{{offset:1}}.m3u8{url_append}");
92        return Ok((source, false));
93    }
94
95    Err(CatchupError::UrlParseFailed {
96        provider: "Flussonic".to_string(),
97        url: url.to_string(),
98    })
99}
100
101// ---------------------------------------------------------------------------
102// Xtream Codes
103// ---------------------------------------------------------------------------
104
105/// Regex for Xtream Codes stream URLs.
106///
107/// Examples:
108/// - `http://list.tv:8080/my@account.xc/my_password/1477`
109/// - `http://list.tv:8080/live/my@account.xc/my_password/1477.m3u8`
110static XTREAM_CODES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
111    Regex::new(r"^(https?://[^/]+)/(?:live/)?([^/]+)/([^/]+)/([^/\.]+)(\.m3u[8]?)?$")
112        .expect("xtream codes regex")
113});
114
115/// Generate an Xtream Codes catchup source URL from a stream URL.
116///
117/// Returns `(catchup_source, is_ts_stream)`.
118///
119/// Translated from `Channel::GenerateXtreamCodesCatchupSource()` in `Channel.cpp`.
120///
121/// # Stream URL patterns
122///
123/// ```text
124/// stream:  http://list.tv:8080/my@account.xc/my_password/1477
125/// catchup: http://list.tv:8080/timeshift/my@account.xc/my_password/{duration:60}/{Y}-{m}-{d}:{H}-{M}/1477.ts
126///
127/// stream:  http://list.tv:8080/live/my@account.xc/my_password/1477.m3u8
128/// catchup: http://list.tv:8080/timeshift/my@account.xc/my_password/{duration:60}/{Y}-{m}-{d}:{H}-{M}/1477.m3u8
129/// ```
130pub fn generate_xtream_codes_source(url: &str) -> Result<(String, bool), CatchupError> {
131    if let Some(caps) = XTREAM_CODES_REGEX.captures(url) {
132        let host = caps.get(1).map_or("", |m| m.as_str());
133        let username = caps.get(2).map_or("", |m| m.as_str());
134        let password = caps.get(3).map_or("", |m| m.as_str());
135        let channel_id = caps.get(4).map_or("", |m| m.as_str());
136        let extension = caps.get(5).map_or("", |m| m.as_str());
137
138        let (ext, is_ts) = if extension.is_empty() {
139            (".ts", true)
140        } else {
141            (extension, false)
142        };
143
144        let source = format!(
145            "{host}/timeshift/{username}/{password}/{{duration:60}}/{{Y}}-{{m}}-{{d}}:{{H}}-{{M}}/{channel_id}{ext}"
146        );
147        return Ok((source, is_ts));
148    }
149
150    Err(CatchupError::UrlParseFailed {
151        provider: "Xtream Codes".to_string(),
152        url: url.to_string(),
153    })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    // -----------------------------------------------------------------------
161    // Flussonic tests
162    // -----------------------------------------------------------------------
163
164    #[test]
165    fn flussonic_mpegts_stream() {
166        let (source, is_ts) =
167            generate_flussonic_source("http://ch01.spr24.net/151/mpegts?token=my_token", false)
168                .unwrap();
169        assert!(is_ts);
170        assert_eq!(
171            source,
172            "http://ch01.spr24.net/151/timeshift_abs-${start}.ts?token=my_token"
173        );
174    }
175
176    #[test]
177    fn flussonic_index_m3u8() {
178        let (source, is_ts) =
179            generate_flussonic_source("http://list.tv:8888/325/index.m3u8?token=secret", false)
180                .unwrap();
181        assert!(!is_ts);
182        assert_eq!(
183            source,
184            "http://list.tv:8888/325/timeshift_rel-{offset:1}.m3u8?token=secret"
185        );
186    }
187
188    #[test]
189    fn flussonic_named_m3u8() {
190        let (source, is_ts) =
191            generate_flussonic_source("http://list.tv:8888/325/mono.m3u8?token=secret", false)
192                .unwrap();
193        assert!(!is_ts);
194        assert_eq!(
195            source,
196            "http://list.tv:8888/325/mono-timeshift_rel-{offset:1}.m3u8?token=secret"
197        );
198    }
199
200    #[test]
201    fn flussonic_generic_hls() {
202        let (source, is_ts) =
203            generate_flussonic_source("http://list.tv:8888/325/live?token=my_token", false)
204                .unwrap();
205        assert!(!is_ts);
206        assert_eq!(
207            source,
208            "http://list.tv:8888/325/timeshift_rel-{offset:1}.m3u8?token=my_token"
209        );
210    }
211
212    #[test]
213    fn flussonic_generic_ts_hint() {
214        let (source, is_ts) =
215            generate_flussonic_source("http://list.tv:8888/325/live?token=my_token", true).unwrap();
216        assert!(is_ts);
217        assert_eq!(
218            source,
219            "http://list.tv:8888/325/timeshift_abs-${start}.ts?token=my_token"
220        );
221    }
222
223    #[test]
224    fn flussonic_invalid_url() {
225        let result = generate_flussonic_source("not-a-url", false);
226        assert!(result.is_err());
227    }
228
229    // -----------------------------------------------------------------------
230    // Xtream Codes tests
231    // -----------------------------------------------------------------------
232
233    #[test]
234    fn xtream_codes_no_extension() {
235        let (source, is_ts) =
236            generate_xtream_codes_source("http://list.tv:8080/my@account.xc/my_password/1477")
237                .unwrap();
238        assert!(is_ts);
239        assert_eq!(
240            source,
241            "http://list.tv:8080/timeshift/my@account.xc/my_password/{duration:60}/{Y}-{m}-{d}:{H}-{M}/1477.ts"
242        );
243    }
244
245    #[test]
246    fn xtream_codes_m3u8_extension() {
247        let (source, is_ts) = generate_xtream_codes_source(
248            "http://list.tv:8080/live/my@account.xc/my_password/1477.m3u8",
249        )
250        .unwrap();
251        assert!(!is_ts);
252        assert_eq!(
253            source,
254            "http://list.tv:8080/timeshift/my@account.xc/my_password/{duration:60}/{Y}-{m}-{d}:{H}-{M}/1477.m3u8"
255        );
256    }
257
258    #[test]
259    fn xtream_codes_with_live_prefix() {
260        let (source, _) =
261            generate_xtream_codes_source("http://list.tv:8080/live/user/pass/1477").unwrap();
262        assert!(source.contains("/timeshift/user/pass/"));
263    }
264
265    #[test]
266    fn xtream_codes_invalid_url() {
267        let result = generate_xtream_codes_source("not-a-url");
268        assert!(result.is_err());
269    }
270}