Skip to main content

crispy_catchup/
template.rs

1//! Template variable substitution engine for catchup URLs.
2//!
3//! Translated from the anonymous namespace functions in `CatchupController.cpp`:
4//! - `FormatDateTime()` — full template processing
5//! - `FormatTime()` — single-char time format specifiers (`{Y}`, `{m}`, etc.)
6//! - `FormatUnits()` — divisible time units (`{duration:N}`, `{offset:N}`)
7//! - `FormatUtc()` — absolute timestamp substitution
8
9use chrono::{DateTime, Datelike, Local, TimeZone, Timelike, Utc};
10use regex::Regex;
11
12/// Format a catchup URL template by substituting all time-related placeholders.
13///
14/// This is the main entry point, translated from `FormatDateTime()` in
15/// `CatchupController.cpp`.
16///
17/// # Arguments
18/// * `template` - URL template with placeholders
19/// * `start` - Programme start time (UTC epoch seconds)
20/// * `duration_secs` - Programme duration in seconds
21/// * `catchup_id` - Optional programme catchup ID for `{catchup-id}` substitution
22/// * `timezone_shift_secs` - Timezone offset to apply (channel tvg-shift + correction)
23pub fn format_catchup_url(
24    template: &str,
25    start: i64,
26    duration_secs: i64,
27    catchup_id: Option<&str>,
28    timezone_shift_secs: i32,
29) -> String {
30    format_catchup_url_with_granularity(
31        template,
32        start,
33        duration_secs,
34        catchup_id,
35        timezone_shift_secs,
36        1,
37    )
38}
39
40/// Format a catchup URL template with granularity clamping.
41///
42/// When `granularity_secs > 1`, the effective duration is clamped (rounded down)
43/// to the nearest multiple of `granularity_secs`. This matches Kodi's behaviour
44/// where `FindCatchupSourceGranularitySeconds()` controls time precision.
45///
46/// # Arguments
47/// * `template` - URL template with placeholders
48/// * `start` - Programme start time (UTC epoch seconds)
49/// * `duration_secs` - Programme duration in seconds
50/// * `catchup_id` - Optional programme catchup ID for `{catchup-id}` substitution
51/// * `timezone_shift_secs` - Timezone offset to apply (channel tvg-shift + correction)
52/// * `granularity_secs` - Time granularity in seconds (1 = no clamping, 60 = minute boundaries)
53pub fn format_catchup_url_with_granularity(
54    template: &str,
55    start: i64,
56    duration_secs: i64,
57    catchup_id: Option<&str>,
58    timezone_shift_secs: i32,
59    granularity_secs: i32,
60) -> String {
61    let clamped_duration = if granularity_secs > 1 {
62        let g = granularity_secs as i64;
63        (duration_secs / g) * g
64    } else {
65        duration_secs
66    };
67
68    let adjusted_start = start - timezone_shift_secs as i64;
69    let now = Utc::now().timestamp() - timezone_shift_secs as i64;
70    let end = adjusted_start + clamped_duration;
71
72    let dt_start = timestamp_to_local(adjusted_start);
73    let dt_end = timestamp_to_local(end);
74    let dt_now = timestamp_to_local(now);
75
76    let mut result = template.to_string();
77
78    // Single-char time specifiers based on start time: {Y}, {m}, {d}, {H}, {M}, {S}
79    format_time_char('Y', &dt_start, &mut result);
80    format_time_char('m', &dt_start, &mut result);
81    format_time_char('d', &dt_start, &mut result);
82    format_time_char('H', &dt_start, &mut result);
83    format_time_char('M', &dt_start, &mut result);
84    format_time_char('S', &dt_start, &mut result);
85
86    // Absolute UTC timestamps
87    format_utc("{utc}", adjusted_start, &mut result);
88    format_utc("${start}", adjusted_start, &mut result);
89    format_utc("{utcend}", end, &mut result);
90    format_utc("${end}", end, &mut result);
91    format_utc("{lutc}", now, &mut result);
92    format_utc("${now}", now, &mut result);
93    format_utc("${timestamp}", now, &mut result);
94    format_utc("${duration}", clamped_duration, &mut result);
95    format_utc("{duration}", clamped_duration, &mut result);
96    format_units("duration", clamped_duration, &mut result);
97    format_utc("${offset}", now - adjusted_start, &mut result);
98    format_units("offset", now - adjusted_start, &mut result);
99
100    // Named time format strings: {utc:YmdHMS}, ${start:Y-m-d}, etc.
101    format_time_named("utc", &dt_start, &mut result, false);
102    format_time_named("start", &dt_start, &mut result, true);
103    format_time_named("utcend", &dt_end, &mut result, false);
104    format_time_named("end", &dt_end, &mut result, true);
105    format_time_named("lutc", &dt_now, &mut result, false);
106    format_time_named("now", &dt_now, &mut result, true);
107    format_time_named("timestamp", &dt_now, &mut result, true);
108
109    // {catchup-id} substitution
110    if let Some(id) = catchup_id {
111        result = result.replace("{catchup-id}", id);
112    }
113
114    result
115}
116
117/// Format only "now"-related timestamps in a URL template.
118///
119/// Used for live stream URL processing where only current-time placeholders
120/// need substitution. Translated from `FormatDateTimeNowOnly()`.
121///
122/// If `programme_start > 0`, also processes start/end/duration specifiers.
123pub fn format_now_only(
124    template: &str,
125    timezone_shift_secs: i32,
126    programme_start: i64,
127    programme_duration: i64,
128) -> String {
129    let now = Utc::now().timestamp() - timezone_shift_secs as i64;
130    let dt_now = timestamp_to_local(now);
131
132    let mut result = template.to_string();
133
134    format_utc("{lutc}", now, &mut result);
135    format_utc("${now}", now, &mut result);
136    format_utc("${timestamp}", now, &mut result);
137    format_time_named("lutc", &dt_now, &mut result, false);
138    format_time_named("now", &dt_now, &mut result, true);
139    format_time_named("timestamp", &dt_now, &mut result, true);
140
141    if programme_start > 0 {
142        let adjusted_start = programme_start - timezone_shift_secs as i64;
143        let end = adjusted_start + programme_duration;
144        let dt_start = timestamp_to_local(adjusted_start);
145        let dt_end = timestamp_to_local(end);
146
147        format_time_char('Y', &dt_start, &mut result);
148        format_time_char('m', &dt_start, &mut result);
149        format_time_char('d', &dt_start, &mut result);
150        format_time_char('H', &dt_start, &mut result);
151        format_time_char('M', &dt_start, &mut result);
152        format_time_char('S', &dt_start, &mut result);
153
154        format_utc("{utc}", adjusted_start, &mut result);
155        format_utc("${start}", adjusted_start, &mut result);
156        format_utc("{utcend}", end, &mut result);
157        format_utc("${end}", end, &mut result);
158        format_utc("{lutc}", now, &mut result);
159        format_utc("${now}", now, &mut result);
160        format_utc("${timestamp}", now, &mut result);
161        format_utc("${duration}", programme_duration, &mut result);
162        format_utc("{duration}", programme_duration, &mut result);
163        format_units("duration", programme_duration, &mut result);
164        format_utc("${offset}", now - adjusted_start, &mut result);
165        format_units("offset", now - adjusted_start, &mut result);
166
167        format_time_named("utc", &dt_start, &mut result, false);
168        format_time_named("start", &dt_start, &mut result, true);
169        format_time_named("utcend", &dt_end, &mut result, false);
170        format_time_named("end", &dt_end, &mut result, true);
171        format_time_named("lutc", &dt_now, &mut result, false);
172        format_time_named("now", &dt_now, &mut result, true);
173        format_time_named("timestamp", &dt_now, &mut result, true);
174    }
175
176    result
177}
178
179/// Validate whether a requested catchup time is within the allowed window.
180///
181/// # Arguments
182/// * `requested_time` - The requested start time (UTC epoch seconds)
183/// * `catchup_days` - Number of days in the catchup window (-1 to ignore)
184///
185/// Returns `true` if the time is within the window or the window is ignored.
186pub fn is_within_catchup_window(requested_time: i64, catchup_days: i32) -> bool {
187    if catchup_days < 0 {
188        return true; // IGNORE_CATCHUP_DAYS
189    }
190    if catchup_days == 0 {
191        return false;
192    }
193    let window_start = Utc::now().timestamp() - (catchup_days as i64 * 24 * 60 * 60);
194    requested_time >= window_start
195}
196
197// ---------------------------------------------------------------------------
198// Internal helpers
199// ---------------------------------------------------------------------------
200
201/// Convert a UTC epoch timestamp to a local `DateTime`.
202fn timestamp_to_local(epoch: i64) -> DateTime<Local> {
203    Local
204        .timestamp_opt(epoch, 0)
205        .single()
206        .unwrap_or_else(Local::now)
207}
208
209/// Replace a single-char time specifier `{ch}` with the formatted value.
210///
211/// Translated from `FormatTime(const char ch, ...)` in `CatchupController.cpp`.
212/// Supports: Y (4-digit year), m (2-digit month), d (2-digit day),
213/// H (2-digit hour), M (2-digit minute), S (2-digit second).
214fn format_time_char(ch: char, dt: &DateTime<Local>, url: &mut String) {
215    let placeholder = format!("{{{ch}}}");
216    if !url.contains(&placeholder) {
217        return;
218    }
219
220    let replacement = match ch {
221        'Y' => format!("{:04}", dt.year()),
222        'm' => format!("{:02}", dt.month()),
223        'd' => format!("{:02}", dt.day()),
224        'H' => format!("{:02}", dt.hour()),
225        'M' => format!("{:02}", dt.minute()),
226        'S' => format!("{:02}", dt.second()),
227        _ => return,
228    };
229
230    while url.contains(&placeholder) {
231        *url = url.replacen(&placeholder, &replacement, 1);
232    }
233}
234
235/// Replace an absolute UTC timestamp placeholder with the epoch value.
236///
237/// Translated from `FormatUtc()` in `CatchupController.cpp`.
238fn format_utc(placeholder: &str, epoch: i64, url: &mut String) {
239    if let Some(pos) = url.find(placeholder) {
240        let value = epoch.to_string();
241        url.replace_range(pos..pos + placeholder.len(), &value);
242    }
243}
244
245/// Replace `{name:N}` divisible-unit specifiers.
246///
247/// Translated from `FormatUnits()` in `CatchupController.cpp`.
248/// E.g., `{duration:60}` divides the duration by 60 to get minutes.
249fn format_units(name: &str, time: i64, url: &mut String) {
250    let pattern = format!(r"\{{{}:(\d+)\}}", regex::escape(name));
251    let re = Regex::new(&pattern).expect("dynamic units regex");
252
253    if let Some(caps) = re.captures(url) {
254        let full_match = caps.get(0).unwrap();
255        let divider: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1);
256
257        if divider != 0 {
258            let units = std::cmp::max(0, time / divider);
259            let match_str = full_match.as_str().to_string();
260            *url = url.replacen(&match_str, &units.to_string(), 1);
261        }
262    }
263}
264
265/// Replace named time format strings like `{utc:Y-m-d H:M:S}` or `${start:YmdHMS}`.
266///
267/// Translated from `FormatTime(const std::string name, ...)` in
268/// `CatchupController.cpp`.
269///
270/// The format string inside the braces uses single-char specifiers:
271/// Y, m, d, H, M, S — each replaced with the strftime equivalent `%Y`, etc.
272fn format_time_named(name: &str, dt: &DateTime<Local>, url: &mut String, has_var_prefix: bool) {
273    let qualifier = if has_var_prefix {
274        format!("${{{name}:")
275    } else {
276        format!("{{{name}:")
277    };
278
279    let Some(found) = url.find(&qualifier) else {
280        return;
281    };
282
283    let start = found + qualifier.len();
284    let end = match url[start..].find('}') {
285        Some(pos) => start + pos,
286        None => return,
287    };
288
289    let format_str = &url[start..end];
290
291    // Replace each single-char specifier with its value
292    let mut formatted = format_str.to_string();
293    formatted = formatted.replace('Y', &format!("{:04}", dt.year()));
294    formatted = formatted.replace('m', &format!("{:02}", dt.month()));
295    formatted = formatted.replace('d', &format!("{:02}", dt.day()));
296    formatted = formatted.replace('H', &format!("{:02}", dt.hour()));
297    formatted = formatted.replace('M', &format!("{:02}", dt.minute()));
298    formatted = formatted.replace('S', &format!("{:02}", dt.second()));
299
300    // Replace the entire qualifier...format} with the result
301    let total_end = end + 1; // include closing '}'
302    url.replace_range(found..total_end, &formatted);
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use chrono::TimeZone;
309
310    /// Helper: create a fixed UTC timestamp for reproducible tests.
311    /// 2024-03-15 14:30:45 UTC
312    fn fixed_start() -> i64 {
313        Utc.with_ymd_and_hms(2024, 3, 15, 14, 30, 45)
314            .unwrap()
315            .timestamp()
316    }
317
318    // Use a deterministic template test that doesn't depend on "now"
319    // by using a template that only references start/end/duration.
320
321    #[test]
322    fn single_char_time_specifiers() {
323        let start = fixed_start();
324        let template = "http://example.com/{Y}/{m}/{d}/{H}/{M}/{S}";
325        let result = format_catchup_url(template, start, 3600, None, 0);
326
327        // The exact values depend on local timezone, but we can verify
328        // the placeholders were replaced (no braces remain for those chars)
329        assert!(!result.contains("{Y}"));
330        assert!(!result.contains("{m}"));
331        assert!(!result.contains("{d}"));
332        assert!(!result.contains("{H}"));
333        assert!(!result.contains("{M}"));
334        assert!(!result.contains("{S}"));
335    }
336
337    #[test]
338    fn absolute_utc_timestamps() {
339        let start = fixed_start();
340        let duration = 3600i64;
341        let template = "http://example.com?start={utc}&end={utcend}&dur={duration}";
342        let result = format_catchup_url(template, start, duration, None, 0);
343
344        assert!(result.contains(&format!("start={start}")));
345        assert!(result.contains(&format!("end={}", start + duration)));
346        assert!(result.contains(&format!("dur={duration}")));
347    }
348
349    #[test]
350    fn dollar_prefixed_timestamps() {
351        let start = fixed_start();
352        let duration = 7200i64;
353        let template = "http://example.com?s=${start}&e=${end}&d=${duration}";
354        let result = format_catchup_url(template, start, duration, None, 0);
355
356        assert!(result.contains(&format!("s={start}")));
357        assert!(result.contains(&format!("e={}", start + duration)));
358        assert!(result.contains(&format!("d={duration}")));
359    }
360
361    #[test]
362    fn duration_divisor_units() {
363        let start = fixed_start();
364        let duration = 7200i64; // 2 hours = 120 minutes
365        let template = "http://example.com?dur={duration:60}";
366        let result = format_catchup_url(template, start, duration, None, 0);
367
368        assert_eq!(result, "http://example.com?dur=120");
369    }
370
371    #[test]
372    fn duration_divisor_seconds() {
373        let start = fixed_start();
374        let duration = 3600i64;
375        let template = "http://example.com?dur={duration:1}";
376        let result = format_catchup_url(template, start, duration, None, 0);
377
378        assert_eq!(result, "http://example.com?dur=3600");
379    }
380
381    #[test]
382    fn named_time_format_utc() {
383        let start = fixed_start();
384        let template = "http://example.com?t={utc:Y-m-d H:M:S}";
385        let result = format_catchup_url(template, start, 3600, None, 0);
386
387        // Verify braces are gone and format is applied
388        assert!(!result.contains("{utc:"));
389        // Should contain date-like pattern (digits and separators)
390        assert!(result.contains("?t="));
391        // Verify it contains hyphens and colons from the format
392        let time_part = result.split("?t=").nth(1).unwrap();
393        assert!(time_part.contains('-'));
394        assert!(time_part.contains(':'));
395    }
396
397    #[test]
398    fn named_time_format_with_dollar_prefix() {
399        let start = fixed_start();
400        let template = "http://example.com?t=${start:Y-m-d}";
401        let result = format_catchup_url(template, start, 3600, None, 0);
402
403        assert!(!result.contains("${start:"));
404        let time_part = result.split("?t=").nth(1).unwrap();
405        assert!(time_part.contains('-'));
406    }
407
408    #[test]
409    fn catchup_id_substitution() {
410        let start = fixed_start();
411        let template = "http://example.com/{catchup-id}";
412        let result = format_catchup_url(template, start, 3600, Some("prog_12345"), 0);
413
414        assert_eq!(result, "http://example.com/prog_12345");
415    }
416
417    #[test]
418    fn catchup_id_no_substitution_when_none() {
419        let start = fixed_start();
420        let template = "http://example.com/{catchup-id}";
421        let result = format_catchup_url(template, start, 3600, None, 0);
422
423        assert_eq!(result, "http://example.com/{catchup-id}");
424    }
425
426    #[test]
427    fn timezone_offset_applied() {
428        let start = fixed_start();
429        let duration = 3600i64;
430        // Apply a 2-hour timezone shift
431        let tz_shift = 7200;
432        let template = "http://example.com?start={utc}";
433        let result = format_catchup_url(template, start, duration, None, tz_shift);
434
435        let expected_shifted = start - tz_shift as i64;
436        assert!(result.contains(&format!("start={expected_shifted}")));
437    }
438
439    #[test]
440    fn xtream_codes_full_template() {
441        let start = fixed_start();
442        let duration = 3600i64;
443        let template =
444            "http://list.tv:8080/timeshift/user/pass/{duration:60}/{Y}-{m}-{d}:{H}-{M}/1477.ts";
445        let result = format_catchup_url(template, start, duration, None, 0);
446
447        // Duration in minutes
448        assert!(result.contains("/60/"));
449        // No unresolved placeholders
450        assert!(!result.contains("{duration"));
451        assert!(!result.contains("{Y}"));
452        assert!(!result.contains("{m}"));
453        assert!(!result.contains("{d}"));
454        assert!(!result.contains("{H}"));
455        assert!(!result.contains("{M}"));
456    }
457
458    #[test]
459    fn catchup_window_within() {
460        let now = Utc::now().timestamp();
461        // 1 hour ago is within a 7-day window
462        assert!(is_within_catchup_window(now - 3600, 7));
463    }
464
465    #[test]
466    fn catchup_window_outside() {
467        let now = Utc::now().timestamp();
468        // 8 days ago is outside a 7-day window
469        assert!(!is_within_catchup_window(now - 8 * 86400, 7));
470    }
471
472    #[test]
473    fn catchup_window_ignore() {
474        // IGNORE_CATCHUP_DAYS (-1) always returns true
475        assert!(is_within_catchup_window(0, -1));
476    }
477
478    #[test]
479    fn catchup_window_zero_days() {
480        let now = Utc::now().timestamp();
481        assert!(!is_within_catchup_window(now, 0));
482    }
483
484    #[test]
485    fn format_now_only_basic() {
486        let template = "http://example.com?now=${now}";
487        let result = format_now_only(template, 0, 0, 0);
488
489        // ${now} should be replaced with a timestamp
490        assert!(!result.contains("${now}"));
491        let time_str = result.split("now=").nth(1).unwrap();
492        let _ts: i64 = time_str.parse().expect("should be a number");
493    }
494
495    #[test]
496    fn format_now_only_with_programme() {
497        let start = fixed_start();
498        let duration = 3600i64;
499        let template = "http://example.com?s={utc}&d={duration}";
500        let result = format_now_only(template, 0, start, duration);
501
502        assert!(result.contains(&format!("s={start}")));
503        assert!(result.contains(&format!("d={duration}")));
504    }
505
506    #[test]
507    fn offset_units_specifier() {
508        let start = fixed_start();
509        let template = "http://example.com?o={offset:1}";
510        let result = format_catchup_url(template, start, 3600, None, 0);
511
512        // {offset:1} should be replaced with seconds since start
513        assert!(!result.contains("{offset:"));
514        let offset_str = result.split("o=").nth(1).unwrap();
515        let offset: i64 = offset_str.parse().expect("should be a number");
516        assert!(offset >= 0);
517    }
518
519    #[test]
520    fn multiple_same_char_specifiers() {
521        let start = fixed_start();
522        let template = "http://example.com/{Y}/{Y}";
523        let result = format_catchup_url(template, start, 3600, None, 0);
524
525        // Both {Y} should be replaced
526        assert!(!result.contains("{Y}"));
527        let parts: Vec<&str> = result
528            .trim_start_matches("http://example.com/")
529            .split('/')
530            .collect();
531        assert_eq!(parts.len(), 2);
532        assert_eq!(parts[0], parts[1]); // both should be the same year
533    }
534
535    #[test]
536    fn negative_duration_clamped_to_zero() {
537        let start = fixed_start();
538        let template = "http://example.com?dur={duration:60}";
539        let result = format_catchup_url(template, start, -120, None, 0);
540
541        // Negative duration divided by 60 = negative, clamped to 0
542        assert_eq!(result, "http://example.com?dur=0");
543    }
544
545    // -----------------------------------------------------------------------
546    // Granularity clamping tests
547    // -----------------------------------------------------------------------
548
549    #[test]
550    fn granularity_60_clamps_90s_to_60s() {
551        let start = fixed_start();
552        // 90s duration with 60s granularity → clamped to 60s
553        let template = "http://example.com?dur=${duration}";
554        let result = format_catchup_url_with_granularity(template, start, 90, None, 0, 60);
555
556        assert!(result.contains("dur=60"));
557    }
558
559    #[test]
560    fn granularity_1_no_clamping() {
561        let start = fixed_start();
562        // 90s duration with 1s granularity → no clamping, stays 90s
563        let template = "http://example.com?dur=${duration}";
564        let result = format_catchup_url_with_granularity(template, start, 90, None, 0, 1);
565
566        assert!(result.contains("dur=90"));
567    }
568
569    #[test]
570    fn granularity_60_clamps_duration_units_too() {
571        let start = fixed_start();
572        // 150s with granularity=60 → clamped to 120s. {duration:60} = 120/60 = 2 minutes
573        let template = "http://example.com?dur={duration:60}";
574        let result = format_catchup_url_with_granularity(template, start, 150, None, 0, 60);
575
576        assert_eq!(result, "http://example.com?dur=2");
577    }
578}