1use serde::{Deserialize, Serialize};
7
8use crate::error::CatchupError;
9use crate::provider;
10
11#[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 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
45impl 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct CatchupConfig {
63 pub mode: CatchupMode,
65
66 pub source: String,
69
70 pub catchup_days: i32,
72
73 pub supports_timeshifting: bool,
75
76 pub terminates: bool,
78
79 pub granularity_seconds: i32,
81
82 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
100pub const IGNORE_CATCHUP_DAYS: i32 = -1;
102
103pub 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 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
191fn split_protocol_options(url: &str) -> (&str, &str) {
193 match url.find('|') {
194 Some(pos) => (&url[..pos], &url[pos..]),
195 None => (url, ""),
196 }
197}
198
199fn 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
218fn 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
229fn 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 (source.contains("{catchup-id}") && count == 1) || mode == CatchupMode::Vod {
239 return false;
240 }
241 return true;
242 }
243
244 false
245}
246
247fn 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
263fn 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); assert_eq!(cfg.granularity_seconds, 1); }
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); assert_eq!(cfg.granularity_seconds, 1); }
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); 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}