Skip to main content

crispy_catchup/
lib.rs

1//! Catchup/timeshift URL template system for IPTV channels.
2//!
3//! Translated from Kodi pvr.iptvsimple's `CatchupController` and `Channel`
4//! catchup logic. Provides:
5//!
6//! - **Mode configuration** — 8 catchup modes with automatic source generation
7//! - **Template engine** — variable substitution for time-based URL placeholders
8//! - **Provider parsing** — Flussonic and Xtream Codes URL regex extraction
9//! - **Window validation** — catchup availability checking
10//! - **EPG-tag processors** — convenience wrappers for programme/channel playback
11
12pub mod error;
13pub mod mode;
14pub mod provider;
15pub mod template;
16
17pub use error::CatchupError;
18pub use mode::{CatchupConfig, CatchupMode, IGNORE_CATCHUP_DAYS, configure_catchup};
19pub use template::{
20    format_catchup_url, format_catchup_url_with_granularity, format_now_only,
21    is_within_catchup_window,
22};
23
24use chrono::Utc;
25
26/// Build a catchup URL for time-shifted playback of a specific EPG programme.
27///
28/// Translated from the logic in `CatchupController::ProcessEPGTagForTimeshiftedPlayback()`
29/// and `CatchupController::GetCatchupUrl()`. Takes programme start/end times and
30/// produces the fully-substituted URL using the channel's catchup source template.
31///
32/// # Arguments
33/// * `config` - The channel's resolved catchup configuration.
34/// * `programme_start` - Programme start time (UTC epoch seconds).
35/// * `programme_end` - Programme end time (UTC epoch seconds).
36/// * `programme_catchup_id` - Optional catchup-id from the EPG entry.
37/// * `timezone_shift_secs` - Combined tvg-shift + catchup correction in seconds.
38pub fn process_programme_for_timeshift(
39    config: &CatchupConfig,
40    programme_start: i64,
41    programme_end: i64,
42    programme_catchup_id: Option<&str>,
43    timezone_shift_secs: i32,
44) -> String {
45    let mut duration = programme_end - programme_start;
46
47    // Cap duration to now (can't timeshift into the future)
48    let now = Utc::now().timestamp();
49    if programme_start + duration > now {
50        duration = now - programme_start;
51    }
52    if duration < 0 {
53        duration = 0;
54    }
55
56    format_catchup_url_with_granularity(
57        &config.source,
58        programme_start,
59        duration,
60        programme_catchup_id,
61        timezone_shift_secs,
62        config.granularity_seconds,
63    )
64}
65
66/// Build a catchup URL for VOD playback of an EPG programme.
67///
68/// Translated from `CatchupController::ProcessEPGTagForVideoPlayback()`.
69/// For VOD mode, the catchup source is typically just `{catchup-id}` or a
70/// URL template containing `{catchup-id}`. This function substitutes the
71/// catchup-id and processes any remaining time placeholders.
72///
73/// # Arguments
74/// * `config` - The channel's resolved catchup configuration.
75/// * `programme_catchup_id` - The catchup-id from the EPG entry.
76pub fn process_programme_for_vod(config: &CatchupConfig, programme_catchup_id: &str) -> String {
77    // VOD sources are typically just {catchup-id} or a URL with {catchup-id}.
78    // We substitute it directly, using a minimal time context (now-based).
79    let now = Utc::now().timestamp();
80    format_catchup_url_with_granularity(
81        &config.source,
82        now,
83        0,
84        Some(programme_catchup_id),
85        0,
86        config.granularity_seconds,
87    )
88}
89
90/// Build a live-stream URL with catchup "now" placeholders substituted.
91///
92/// Translated from `CatchupController::ProcessStreamUrl()` and
93/// `CatchupController::ProcessChannelForPlayback()`. Used when a channel
94/// supports catchup but is currently playing live — processes `{lutc}`,
95/// `${now}`, `${timestamp}` and similar now-only placeholders.
96///
97/// # Arguments
98/// * `config` - The channel's resolved catchup configuration.
99pub fn process_channel_for_live(config: &CatchupConfig) -> String {
100    format_now_only(&config.source, 0, 0, 0)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use chrono::TimeZone;
107
108    fn fixed_start() -> i64 {
109        Utc.with_ymd_and_hms(2024, 3, 15, 14, 30, 45)
110            .unwrap()
111            .timestamp()
112    }
113
114    fn make_config(source: &str, granularity: i32) -> CatchupConfig {
115        CatchupConfig {
116            mode: CatchupMode::Default,
117            source: source.to_string(),
118            catchup_days: 7,
119            supports_timeshifting: true,
120            terminates: true,
121            granularity_seconds: granularity,
122            is_ts_stream: false,
123        }
124    }
125
126    #[test]
127    fn process_programme_for_timeshift_produces_correct_url() {
128        let start = fixed_start();
129        let end = start + 3600;
130        let config = make_config(
131            "http://example.com/catchup?start={utc}&end={utcend}&id={catchup-id}",
132            1,
133        );
134
135        let result = process_programme_for_timeshift(&config, start, end, Some("prog_123"), 0);
136
137        assert!(result.contains(&format!("start={start}")));
138        assert!(result.contains("id=prog_123"));
139        // end depends on now-capping, but should not contain raw placeholder
140        assert!(!result.contains("{utc}"));
141        assert!(!result.contains("{utcend}"));
142        assert!(!result.contains("{catchup-id}"));
143    }
144
145    #[test]
146    fn process_programme_for_vod_substitutes_catchup_id() {
147        let config = make_config("http://example.com/vod/{catchup-id}", 1);
148
149        let result = process_programme_for_vod(&config, "movie_456");
150
151        assert_eq!(result, "http://example.com/vod/movie_456");
152    }
153
154    #[test]
155    fn process_channel_for_live_uses_current_time() {
156        let config = make_config("http://example.com/live?now=${now}", 1);
157
158        let before = Utc::now().timestamp();
159        let result = process_channel_for_live(&config);
160        let after = Utc::now().timestamp();
161
162        // Extract the now value and verify it's approximately current time
163        let now_str = result.split("now=").nth(1).unwrap();
164        let now_val: i64 = now_str.parse().expect("should be a timestamp");
165        assert!(now_val >= before && now_val <= after);
166    }
167}