1use chrono::{DateTime, Datelike, Local, TimeZone, Timelike, Utc};
10use regex::Regex;
11
12pub 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
40pub 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 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 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 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 if let Some(id) = catchup_id {
111 result = result.replace("{catchup-id}", id);
112 }
113
114 result
115}
116
117pub 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
179pub fn is_within_catchup_window(requested_time: i64, catchup_days: i32) -> bool {
187 if catchup_days < 0 {
188 return true; }
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
197fn timestamp_to_local(epoch: i64) -> DateTime<Local> {
203 Local
204 .timestamp_opt(epoch, 0)
205 .single()
206 .unwrap_or_else(Local::now)
207}
208
209fn 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
235fn 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
245fn 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
265fn 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 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 let total_end = end + 1; url.replace_range(found..total_end, &formatted);
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use chrono::TimeZone;
309
310 fn fixed_start() -> i64 {
313 Utc.with_ymd_and_hms(2024, 3, 15, 14, 30, 45)
314 .unwrap()
315 .timestamp()
316 }
317
318 #[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 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; 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 assert!(!result.contains("{utc:"));
389 assert!(result.contains("?t="));
391 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 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 assert!(result.contains("/60/"));
449 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 assert!(is_within_catchup_window(now - 3600, 7));
463 }
464
465 #[test]
466 fn catchup_window_outside() {
467 let now = Utc::now().timestamp();
468 assert!(!is_within_catchup_window(now - 8 * 86400, 7));
470 }
471
472 #[test]
473 fn catchup_window_ignore() {
474 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 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 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 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]); }
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 assert_eq!(result, "http://example.com?dur=0");
543 }
544
545 #[test]
550 fn granularity_60_clamps_90s_to_60s() {
551 let start = fixed_start();
552 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 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 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}