Skip to main content

crispy_catchup/
mode.rs

1//! Catchup mode enum and configuration.
2//!
3//! Translated from Kodi pvr.iptvsimple `CatchupMode` enum in `Channel.h`
4//! and the `ConfigureCatchupMode()` / validation helpers in `Channel.cpp`.
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::CatchupError;
9use crate::provider;
10
11/// Catchup playback modes.
12///
13/// Mirrors the 8 modes from Kodi pvr.iptvsimple `CatchupMode` enum.
14/// `Timeshift` is obsolete but still used by some providers; it behaves
15/// identically to `Shift`.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum CatchupMode {
19    #[default]
20    Disabled = 0,
21    Default = 1,
22    Append = 2,
23    Shift = 3,
24    Flussonic = 4,
25    XtreamCodes = 5,
26    Timeshift = 6,
27    Vod = 7,
28}
29
30impl CatchupMode {
31    /// Human-readable label for the mode.
32    pub fn label(self) -> &'static str {
33        match self {
34            Self::Disabled => "Disabled",
35            Self::Default => "Default",
36            Self::Append => "Append",
37            Self::Shift | Self::Timeshift => "Shift (SIPTV)",
38            Self::Flussonic => "Flussonic",
39            Self::XtreamCodes => "Xtream codes",
40            Self::Vod => "VOD",
41        }
42    }
43}
44
45/// Convert from `crispy_iptv_types::CatchupType` to our `CatchupMode`.
46impl From<crispy_iptv_types::CatchupType> for CatchupMode {
47    fn from(ct: crispy_iptv_types::CatchupType) -> Self {
48        match ct {
49            crispy_iptv_types::CatchupType::Default => Self::Default,
50            crispy_iptv_types::CatchupType::Append => Self::Append,
51            crispy_iptv_types::CatchupType::Shift => Self::Shift,
52            crispy_iptv_types::CatchupType::Flussonic => Self::Flussonic,
53            crispy_iptv_types::CatchupType::Fs => Self::Flussonic,
54            crispy_iptv_types::CatchupType::Xc => Self::XtreamCodes,
55        }
56    }
57}
58
59/// Fully resolved catchup configuration for a channel after
60/// `ConfigureCatchupMode()` processing.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct CatchupConfig {
63    /// The active catchup mode.
64    pub mode: CatchupMode,
65
66    /// The fully-resolved URL template for catchup playback.
67    /// Contains placeholders like `{utc}`, `{duration}`, `${start}`, etc.
68    pub source: String,
69
70    /// Number of catchup days available.
71    pub catchup_days: i32,
72
73    /// Whether the catchup source supports live-stream timeshifting.
74    pub supports_timeshifting: bool,
75
76    /// Whether the catchup stream terminates (has end-time specifier).
77    pub terminates: bool,
78
79    /// Granularity in seconds (1 = second-level, 60 = minute-level).
80    pub granularity_seconds: i32,
81
82    /// Whether the catchup stream is a TS (MPEG-TS) stream.
83    pub is_ts_stream: bool,
84}
85
86impl Default for CatchupConfig {
87    fn default() -> Self {
88        Self {
89            mode: CatchupMode::Disabled,
90            source: String::new(),
91            catchup_days: 0,
92            supports_timeshifting: false,
93            terminates: false,
94            granularity_seconds: 1,
95            is_ts_stream: false,
96        }
97    }
98}
99
100/// Sentinel value meaning "ignore catchup days limit".
101pub const IGNORE_CATCHUP_DAYS: i32 = -1;
102
103/// Configure catchup mode for a channel, generating the catchup source URL
104/// template and determining stream properties.
105///
106/// Translated from `Channel::ConfigureCatchupMode()` in `Channel.cpp`.
107///
108/// # Arguments
109/// * `mode` - The catchup mode from the M3U entry.
110/// * `stream_url` - The channel's primary stream URL.
111/// * `catchup_source` - The raw `catchup-source` attribute from M3U (may be empty).
112/// * `catchup_days` - Number of catchup days (0 uses default, -1 ignores limit).
113/// * `default_days` - Default catchup days from settings.
114/// * `default_query_format` - Default catchup query format from settings.
115/// * `is_ts_hint` - Whether the M3U entry hinted at a TS stream (e.g., "flussonic-ts" or "fs").
116pub fn configure_catchup(
117    mode: CatchupMode,
118    stream_url: &str,
119    catchup_source: &str,
120    catchup_days: i32,
121    default_days: i32,
122    default_query_format: &str,
123    is_ts_hint: bool,
124) -> Result<CatchupConfig, CatchupError> {
125    // Separate protocol options after "|" (Kodi convention)
126    let (url, protocol_options) = split_protocol_options(stream_url);
127
128    let mut append_protocol_options = true;
129    let mut is_ts_stream = is_ts_hint;
130
131    let resolved_source = match mode {
132        CatchupMode::Disabled => {
133            return Err(CatchupError::Disabled);
134        }
135        CatchupMode::Default => {
136            if !catchup_source.is_empty() {
137                if catchup_source.contains('|') {
138                    append_protocol_options = false;
139                }
140                catchup_source.to_string()
141            } else {
142                generate_append_source(url, catchup_source, default_query_format)?
143            }
144        }
145        CatchupMode::Append => generate_append_source(url, catchup_source, default_query_format)?,
146        CatchupMode::Shift | CatchupMode::Timeshift => generate_shift_source(url),
147        CatchupMode::Flussonic => {
148            let (source, ts) = provider::generate_flussonic_source(url, is_ts_hint)?;
149            is_ts_stream = ts;
150            source
151        }
152        CatchupMode::XtreamCodes => {
153            let (source, ts) = provider::generate_xtream_codes_source(url)?;
154            is_ts_stream = ts;
155            source
156        }
157        CatchupMode::Vod => {
158            if !catchup_source.is_empty() {
159                if catchup_source.contains('|') {
160                    append_protocol_options = false;
161                }
162                catchup_source.to_string()
163            } else {
164                "{catchup-id}".to_string()
165            }
166        }
167    };
168
169    let mut source = resolved_source;
170    if !protocol_options.is_empty() && append_protocol_options {
171        source.push_str(protocol_options);
172    }
173
174    let days = if catchup_days > 0 || catchup_days == IGNORE_CATCHUP_DAYS {
175        catchup_days
176    } else {
177        default_days
178    };
179
180    Ok(CatchupConfig {
181        mode,
182        supports_timeshifting: is_valid_timeshifting_source(&source, mode),
183        terminates: is_terminating_source(&source),
184        granularity_seconds: find_granularity_seconds(&source),
185        source,
186        catchup_days: days,
187        is_ts_stream,
188    })
189}
190
191/// Split a URL at the first `|` to separate Kodi protocol options.
192fn split_protocol_options(url: &str) -> (&str, &str) {
193    match url.find('|') {
194        Some(pos) => (&url[..pos], &url[pos..]),
195        None => (url, ""),
196    }
197}
198
199/// Generate an "append" catchup source: base URL + query string.
200///
201/// From `Channel::GenerateAppendCatchupSource()`.
202fn generate_append_source(
203    url: &str,
204    catchup_source: &str,
205    default_query_format: &str,
206) -> Result<String, CatchupError> {
207    if !catchup_source.is_empty() {
208        Ok(format!("{url}{catchup_source}"))
209    } else if !default_query_format.is_empty() {
210        Ok(format!("{url}{default_query_format}"))
211    } else {
212        Err(CatchupError::InvalidSource(
213            "append mode requires a catchup source or default query format".to_string(),
214        ))
215    }
216}
217
218/// Generate a "shift" (SIPTV) catchup source.
219///
220/// From `Channel::GenerateShiftCatchupSource()`.
221fn generate_shift_source(url: &str) -> String {
222    if url.contains('?') {
223        format!("{url}&utc={{utc}}&lutc={{lutc}}")
224    } else {
225        format!("{url}?utc={{utc}}&lutc={{lutc}}")
226    }
227}
228
229/// Check if a catchup source supports live-stream timeshifting.
230///
231/// From `IsValidTimeshiftingCatchupSource()` in `Channel.cpp`.
232fn is_valid_timeshifting_source(source: &str, mode: CatchupMode) -> bool {
233    let specifier_re = regex::Regex::new(r"\{[^{]+\}").expect("static regex");
234    let count = specifier_re.find_iter(source).count();
235
236    if count > 0 {
237        // If we only have {catchup-id} and nothing else, can't timeshift
238        if (source.contains("{catchup-id}") && count == 1) || mode == CatchupMode::Vod {
239            return false;
240        }
241        return true;
242    }
243
244    false
245}
246
247/// Check if a catchup source terminates (has end-time specifiers).
248///
249/// From `IsTerminatingCatchupSource()` in `Channel.cpp`.
250fn is_terminating_source(source: &str) -> bool {
251    source.contains("{duration}")
252        || source.contains("{duration:")
253        || source.contains("{lutc}")
254        || source.contains("{lutc:")
255        || source.contains("${timestamp}")
256        || source.contains("${timestamp:")
257        || source.contains("{utcend}")
258        || source.contains("{utcend:")
259        || source.contains("${end}")
260        || source.contains("${end:")
261}
262
263/// Determine the granularity (in seconds) of a catchup source.
264///
265/// From `FindCatchupSourceGranularitySeconds()` in `Channel.cpp`.
266fn find_granularity_seconds(source: &str) -> i32 {
267    if source.contains("{utc}")
268        || source.contains("{utc:")
269        || source.contains("${start}")
270        || source.contains("${start:")
271        || source.contains("{S}")
272        || source.contains("{offset:1}")
273    {
274        1
275    } else {
276        60
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn disabled_mode_returns_error() {
286        let result = configure_catchup(
287            CatchupMode::Disabled,
288            "http://example.com/stream",
289            "",
290            7,
291            7,
292            "",
293            false,
294        );
295        assert!(result.is_err());
296    }
297
298    #[test]
299    fn default_mode_with_source() {
300        let cfg = configure_catchup(
301            CatchupMode::Default,
302            "http://example.com/stream",
303            "http://example.com/catchup?start={utc}&end={utcend}",
304            5,
305            7,
306            "",
307            false,
308        )
309        .unwrap();
310        assert_eq!(cfg.mode, CatchupMode::Default);
311        assert!(cfg.source.contains("{utc}"));
312        assert_eq!(cfg.catchup_days, 5);
313        assert!(cfg.supports_timeshifting);
314        assert!(cfg.terminates); // has {utcend}
315        assert_eq!(cfg.granularity_seconds, 1); // has {utc}
316    }
317
318    #[test]
319    fn default_mode_falls_back_to_append() {
320        let cfg = configure_catchup(
321            CatchupMode::Default,
322            "http://example.com/stream",
323            "",
324            0,
325            7,
326            "?utc={utc}&lutc={lutc}",
327            false,
328        )
329        .unwrap();
330        assert!(cfg.source.starts_with("http://example.com/stream?utc="));
331    }
332
333    #[test]
334    fn append_mode_with_query() {
335        let cfg = configure_catchup(
336            CatchupMode::Append,
337            "http://example.com/stream",
338            "?start={utc}&dur={duration}",
339            3,
340            7,
341            "",
342            false,
343        )
344        .unwrap();
345        assert_eq!(
346            cfg.source,
347            "http://example.com/stream?start={utc}&dur={duration}"
348        );
349        assert!(cfg.terminates);
350        assert_eq!(cfg.granularity_seconds, 1);
351    }
352
353    #[test]
354    fn shift_mode_without_query() {
355        let cfg = configure_catchup(
356            CatchupMode::Shift,
357            "http://example.com/stream",
358            "",
359            7,
360            7,
361            "",
362            false,
363        )
364        .unwrap();
365        assert_eq!(
366            cfg.source,
367            "http://example.com/stream?utc={utc}&lutc={lutc}"
368        );
369        assert!(cfg.supports_timeshifting);
370        assert!(cfg.terminates); // has {lutc}
371        assert_eq!(cfg.granularity_seconds, 1); // has {utc}
372    }
373
374    #[test]
375    fn shift_mode_with_existing_query() {
376        let cfg = configure_catchup(
377            CatchupMode::Shift,
378            "http://example.com/stream?token=abc",
379            "",
380            7,
381            7,
382            "",
383            false,
384        )
385        .unwrap();
386        assert_eq!(
387            cfg.source,
388            "http://example.com/stream?token=abc&utc={utc}&lutc={lutc}"
389        );
390    }
391
392    #[test]
393    fn timeshift_mode_behaves_like_shift() {
394        let cfg = configure_catchup(
395            CatchupMode::Timeshift,
396            "http://example.com/stream",
397            "",
398            7,
399            7,
400            "",
401            false,
402        )
403        .unwrap();
404        assert!(cfg.source.contains("utc={utc}"));
405    }
406
407    #[test]
408    fn vod_mode_uses_catchup_id() {
409        let cfg = configure_catchup(
410            CatchupMode::Vod,
411            "http://example.com/stream",
412            "",
413            -1,
414            7,
415            "",
416            false,
417        )
418        .unwrap();
419        assert_eq!(cfg.source, "{catchup-id}");
420        assert!(!cfg.supports_timeshifting); // VOD doesn't support timeshifting
421        assert_eq!(cfg.catchup_days, IGNORE_CATCHUP_DAYS);
422    }
423
424    #[test]
425    fn vod_mode_with_custom_source() {
426        let cfg = configure_catchup(
427            CatchupMode::Vod,
428            "http://example.com/stream",
429            "http://example.com/vod/{catchup-id}",
430            7,
431            7,
432            "",
433            false,
434        )
435        .unwrap();
436        assert_eq!(cfg.source, "http://example.com/vod/{catchup-id}");
437    }
438
439    #[test]
440    fn protocol_options_appended() {
441        let cfg = configure_catchup(
442            CatchupMode::Shift,
443            "http://example.com/stream|User-Agent=test",
444            "",
445            7,
446            7,
447            "",
448            false,
449        )
450        .unwrap();
451        assert!(cfg.source.ends_with("|User-Agent=test"));
452    }
453
454    #[test]
455    fn catchup_days_uses_default_when_zero() {
456        let cfg = configure_catchup(
457            CatchupMode::Shift,
458            "http://example.com/stream",
459            "",
460            0,
461            14,
462            "",
463            false,
464        )
465        .unwrap();
466        assert_eq!(cfg.catchup_days, 14);
467    }
468
469    #[test]
470    fn catchup_id_only_source_cannot_timeshift() {
471        assert!(!is_valid_timeshifting_source(
472            "http://example.com/{catchup-id}",
473            CatchupMode::Default
474        ));
475    }
476
477    #[test]
478    fn terminating_source_detection() {
479        assert!(is_terminating_source("url?d={duration}"));
480        assert!(is_terminating_source("url?d={duration:60}"));
481        assert!(is_terminating_source("url?e={utcend}"));
482        assert!(is_terminating_source("url?e=${end}"));
483        assert!(is_terminating_source("url?l={lutc}"));
484        assert!(is_terminating_source("url?t=${timestamp}"));
485        assert!(!is_terminating_source("url?s={utc}"));
486    }
487
488    #[test]
489    fn granularity_detection() {
490        assert_eq!(find_granularity_seconds("url?s={utc}"), 1);
491        assert_eq!(find_granularity_seconds("url?s=${start}"), 1);
492        assert_eq!(find_granularity_seconds("url?s={S}"), 1);
493        assert_eq!(find_granularity_seconds("url?o={offset:1}"), 1);
494        assert_eq!(
495            find_granularity_seconds("url?d={duration:60}&t={Y}-{m}-{d}:{H}-{M}"),
496            60
497        );
498    }
499}