epg timeshift refactoring (#550)

epg timeshift refactoring
This commit is contained in:
euzu
2026-01-29 18:08:22 +01:00
committed by GitHub
parent 2a63fdc155
commit 0be9258fc0
8 changed files with 316 additions and 558 deletions
+80 -262
View File
@@ -1,5 +1,5 @@
use std::sync::Arc;
use chrono::{DateTime, Offset, TimeZone, Utc};
use chrono::{DateTime, TimeZone, Utc};
use chrono_tz::Tz;
use crate::model::{ConfigTarget, ProxyUserCredentials};
use shared::model::PlaylistItemType;
@@ -9,17 +9,15 @@ use crate::api::model::AppState;
/// Parses user-defined EPG timeshift configuration.
/// Supports either a numeric offset (e.g. "+2:30", "-1:15")
/// or a timezone name (e.g. "`Europe/Berlin`", "`UTC`", "`America/New_York`").
///
/// Returns the total offset in minutes (i32).
fn parse_timeshift(time_shift: Option<&str>) -> Option<i32> {
time_shift.and_then(|offset| {
fn parse_timeshift(time_shift: Option<&str>) -> EpgTimeShift {
if let Some(offset) = time_shift {
if offset.is_empty() {
return EpgTimeShift::None;
}
// Try to parse as timezone name first
if let Ok(tz) = offset.parse::<Tz>() {
// Determine the current UTC offset of that timezone (including DST)
let now = Utc::now();
let local_time = tz.from_utc_datetime(&now.naive_utc());
let offset_minutes = local_time.offset().fix().local_minus_utc() / 60;
return Some(offset_minutes);
return EpgTimeShift::TimeZone(tz);
}
// If not a timezone, try to parse as numeric offset
@@ -31,15 +29,28 @@ fn parse_timeshift(time_shift: Option<&str>) -> Option<i32> {
let minutes: i32 = parts.get(1).and_then(|m| m.parse().ok()).unwrap_or(0);
let total_minutes = hours * 60 + minutes;
(total_minutes > 0).then_some(sign_factor * total_minutes)
})
if total_minutes > 0 {
EpgTimeShift::Fixed(sign_factor * total_minutes)
} else {
EpgTimeShift::None
}
} else {
EpgTimeShift::None
}
}
#[derive(Debug, Clone)]
pub enum EpgTimeShift {
None,
Fixed(i32),
TimeZone(Tz),
}
#[derive(Debug, Clone)]
pub struct EpgProcessingOptions {
pub rewrite_urls: bool,
pub offset_minutes: i32,
pub time_shift: EpgTimeShift,
pub encrypt_secret: [u8; 16],
}
@@ -53,227 +64,14 @@ pub fn get_epg_processing_options(app_state: &Arc<AppState>, user: &ProxyUserCre
let redirect = user.proxy.is_redirect(PlaylistItemType::Live) || target.is_force_redirect(PlaylistItemType::Live);
let rewrite_urls = !redirect && rewrite_resources;
// Use 0 for timeshift if None
let timeshift = parse_timeshift(user.epg_timeshift.as_deref()).unwrap_or(0);
let timeshift = parse_timeshift(user.epg_timeshift.as_deref());
EpgProcessingOptions {
rewrite_urls,
offset_minutes: timeshift,
time_shift: timeshift,
encrypt_secret,
}
}
//
// pub trait EpgConsumer: Send {
// fn handle_event(&mut self, event: &Event<'_>, decoder: quick_xml::Decoder) -> impl std::future::Future<Output = Result<(), TuliproxError>> + Send;
// }
//
// pub struct EpgProcessor<R: AsyncBufRead + Send + Unpin> {
// reader: Reader<R>,
// epg_processing_options: EpgProcessingOptions,
// rewrite_base_url: String,
// filter_channel_id: Option<std::sync::Arc<str>>,
// limit: u32
// }
//
// impl<R: AsyncRead + Send + Unpin> EpgProcessor<BufReader<R>> {
// pub fn new(
// reader: R,
// epg_processing_options: EpgProcessingOptions,
// rewrite_base_url: String,
// filter_channel_id: Option<std::sync::Arc<str>>,
// limit: u32
// ) -> Self {
// Self {
// reader: Reader::from_reader(BufReader::new(reader)),
// epg_processing_options,
// rewrite_base_url,
// filter_channel_id,
// limit
// }
// }
// }
//
// #[allow(clippy::too_many_lines)]
// impl<R: AsyncBufRead + Send + Unpin> EpgProcessor<R> {
// pub async fn process<C: EpgConsumer>(&mut self, consumer: &mut C) -> Result<(), TuliproxError> {
// let mut buf = Vec::with_capacity(4096);
// let duration = Duration::minutes(i64::from(self.epg_processing_options.offset_minutes));
// let mut skip_depth = None;
//
// loop {
// buf.clear();
// let event = match self.reader.read_event_into_async(&mut buf).await {
// Ok(e) => e,
// Err(e) => {
// error!("Error reading epg XML event: {e}");
// return Err(info_err!("Error reading epg XML event: {}", e));
// }
// };
//
// if let Some(flt) = &self.filter_channel_id {
// match &event {
// Event::Start(e) => {
// if skip_depth.is_none() {
// let should_skip = match e.name().as_ref() {
// b"channel" => {
// e.attributes()
// .filter_map(Result::ok)
// .find(|a| a.key.as_ref() == b"id")
// .and_then(|a| a.unescape_value().ok())
// .is_some_and(|v| flt.as_ref() != v.as_ref())
// }
// b"programme" => {
// e.attributes()
// .filter_map(Result::ok)
// .find(|a| a.key.as_ref() == b"channel")
// .and_then(|a| a.unescape_value().ok())
// .is_some_and(|v| flt.as_ref() != v.as_ref())
// }
// _ => false,
// };
//
// if should_skip {
// skip_depth = Some(1);
// continue;
// }
// } else {
// skip_depth = skip_depth.map(|d| d + 1);
// continue;
// }
// }
// Event::End(_) => {
// if let Some(depth) = skip_depth {
// if depth == 1 {
// skip_depth = None;
// } else {
// skip_depth = Some(depth - 1);
// }
// continue;
// }
// }
// Event::Empty(_) => {
// if skip_depth.is_some() {
// continue;
// }
// }
// _ => {}
// }
//
// if skip_depth.is_some() {
// continue;
// }
// }
//
// match event {
// Event::Start(ref e) if self.epg_processing_options.offset_minutes != 0 && e.name().as_ref() == b"programme" => {
// let mut elem = BytesStart::new(EPG_TAG_PROGRAMME);
// for attr in e.attributes() {
// match attr {
// Ok(attr) if attr.key.as_ref() == b"start" => {
// if let Ok(start_value) = attr.decode_and_unescape_value(self.reader.decoder()) {
// elem.push_attribute(("start", time_correct(&start_value, &duration).as_str()));
// } else {
// elem.push_attribute(attr);
// }
// }
// Ok(attr) if attr.key.as_ref() == b"stop" => {
// if let Ok(stop_value) = attr.decode_and_unescape_value(self.reader.decoder()) {
// elem.push_attribute(("stop", time_correct(&stop_value, &duration).as_str()));
// } else {
// elem.push_attribute(attr);
// }
// }
// Ok(attr) => {
// elem.push_attribute(attr);
// }
// Err(e) => {
// error!("Error parsing epg attribute: {e}");
// }
// }
// }
// consumer.handle_event(&Event::Start(elem), self.reader.decoder()).await?;
// }
// ref event @ (Event::Empty(ref e) | Event::Start(ref e)) if self.epg_processing_options.rewrite_urls && e.name().as_ref() == b"icon" => {
// let mut elem = BytesStart::new(EPG_TAG_ICON);
// for attr in e.attributes() {
// match attr {
// Ok(attr) if attr.key.as_ref() == b"src" => {
// if let Some(icon) = get_attr_value_unescaped(&attr, self.reader.decoder()) {
// if icon.is_empty() {
// elem.push_attribute(attr);
// } else {
// let rewritten_url = if let Ok(encrypted) = obscure_text(&self.epg_processing_options.encrypt_secret, &icon) {
// format!("{}{}", self.rewrite_base_url, encrypted)
// } else {
// icon
// };
// elem.push_attribute(("src", rewritten_url.as_str()));
// }
// } else {
// elem.push_attribute(attr);
// }
// }
// Ok(attr) => {
// elem.push_attribute(attr);
// }
// Err(e) => {
// error!("Error parsing epg attribute: {e}");
// }
// }
// }
//
// let out_event = match event {
// Event::Empty(_) => Event::Empty(elem),
// Event::Start(_) => Event::Start(elem),
// _ => unreachable!(),
// };
// consumer.handle_event(&out_event, self.reader.decoder()).await?;
// }
// Event::Decl(_) | Event::DocType(_) => {},
// Event::Eof => break,
// ref event => {
// consumer.handle_event(event, self.reader.decoder()).await?;
// }
// }
// }
// Ok(())
// }
// }
//
// /// # Panics
// /// unwrap for `FixedOffset` should not panic!
// pub fn time_correct(original: &str, shift: &Duration) -> String {
// let (datetime_part, tz_part) = if let Some((dt, tz)) = original.trim().rsplit_once(' ') {
// (dt, tz)
// } else {
// (original.trim(), "+0000")
// };
//
// let Ok(naive_dt) = NaiveDateTime::parse_from_str(datetime_part, "%Y%m%d%H%M%S") else { return original.to_string() };
//
// let tz_offset_minutes = if tz_part.len() == 5 {
// let bytes = tz_part.as_bytes();
// let sign = if bytes.first() == Some(&b'-') { -1 } else { 1 };
// let hours: i32 = tz_part.get(1..3).and_then(|s| s.parse().ok()).unwrap_or(0);
// let mins: i32 = tz_part.get(3..5).and_then(|s| s.parse().ok()).unwrap_or(0);
// sign * (hours * 60 + mins)
// } else {
// 0
// };
//
// let tz = FixedOffset::east_opt(tz_offset_minutes * 60).unwrap_or(FixedOffset::east_opt(0).unwrap()); // should not panic
//
// let dt: DateTime<FixedOffset> = tz
// .from_local_datetime(&naive_dt)
// .single()
// .unwrap_or_else(|| tz.from_utc_datetime(&naive_dt));
//
// let shifted_dt = dt + *shift;
//
// format!("{} {}", shifted_dt.format("%Y%m%d%H%M%S"), format_offset(tz_offset_minutes))
// }
/// # Panics
/// unwrap for `FixedOffset` should not panic!
pub fn apply_offset(ts_utc: i64, offset_minutes: i32) -> i64 {
ts_utc + i64::from(offset_minutes) * 60
}
@@ -292,19 +90,23 @@ pub fn parse_xmltv_time(t: &str) -> Option<i64> {
.map(|dt| dt.with_timezone(&Utc).timestamp())
}
pub fn format_xmltv_time_utc(ts: i64, offset_minutes: i32) -> String {
pub fn format_xmltv_time_utc(ts: i64, time_shift: &EpgTimeShift) -> String {
let dt = Utc.timestamp_opt(ts, 0).unwrap();
if offset_minutes == 0 {
dt.format("%Y%m%d%H%M%S %z").to_string()
} else {
match chrono::FixedOffset::east_opt(offset_minutes * 60) {
Some(offset) => {
dt.with_timezone(&offset).format("%Y%m%d%H%M%S %z").to_string()
}
None => {
dt.format("%Y%m%d%H%M%S %z").to_string()
match time_shift {
EpgTimeShift::None => dt.format("%Y%m%d%H%M%S %z").to_string(),
EpgTimeShift::Fixed(minutes) => {
match chrono::FixedOffset::east_opt(minutes * 60) {
Some(offset) => {
dt.with_timezone(&offset).format("%Y%m%d%H%M%S %z").to_string()
}
None => {
dt.format("%Y%m%d%H%M%S %z").to_string()
}
}
}
EpgTimeShift::TimeZone(tz) => {
dt.with_timezone(tz).format("%Y%m%d%H%M%S %z").to_string()
}
}
}
@@ -314,36 +116,52 @@ mod tests {
#[test]
fn test_parse_timeshift() {
assert_eq!(parse_timeshift(Some(&String::from("2"))), Some(120));
assert_eq!(parse_timeshift(Some(&String::from("-1:30"))), Some(-90));
assert_eq!(parse_timeshift(Some(&String::from("+0:15"))), Some(15));
assert_eq!(parse_timeshift(Some(&String::from("1:45"))), Some(105));
assert_eq!(parse_timeshift(Some(&String::from(":45"))), Some(45));
assert_eq!(parse_timeshift(Some(&String::from("-:45"))), Some(-45));
assert_eq!(parse_timeshift(Some(&String::from("0:30"))), Some(30));
assert_eq!(parse_timeshift(Some(&String::from(":3"))), Some(3));
assert_eq!(parse_timeshift(Some(&String::from("2:"))), Some(120));
assert_eq!(parse_timeshift(Some(&String::from("+2:00"))), Some(120));
assert_eq!(parse_timeshift(Some(&String::from("-0:10"))), Some(-10));
assert_eq!(parse_timeshift(Some(&String::from("invalid"))), None);
assert_eq!(parse_timeshift(Some(&String::from("+abc"))), None);
assert_eq!(parse_timeshift(Some(&String::new())), None);
assert_eq!(parse_timeshift(None), None);
assert!(matches!(parse_timeshift(Some(&String::from("2"))), EpgTimeShift::Fixed(120)));
assert!(matches!(parse_timeshift(Some(&String::from("-1:30"))), EpgTimeShift::Fixed(-90)));
assert!(matches!(parse_timeshift(Some(&String::from("+0:15"))), EpgTimeShift::Fixed(15)));
assert!(matches!(parse_timeshift(Some(&String::from("1:45"))), EpgTimeShift::Fixed(105)));
assert!(matches!(parse_timeshift(Some(&String::from(":45"))), EpgTimeShift::Fixed(45)));
assert!(matches!(parse_timeshift(Some(&String::from("-:45"))), EpgTimeShift::Fixed(-45)));
assert!(matches!(parse_timeshift(Some(&String::from("0:30"))), EpgTimeShift::Fixed(30)));
assert!(matches!(parse_timeshift(Some(&String::from(":3"))), EpgTimeShift::Fixed(3)));
assert!(matches!(parse_timeshift(Some(&String::from("2:"))), EpgTimeShift::Fixed(120)));
assert!(matches!(parse_timeshift(Some(&String::from("+2:00"))), EpgTimeShift::Fixed(120)));
assert!(matches!(parse_timeshift(Some(&String::from("-0:10"))), EpgTimeShift::Fixed(-10)));
assert!(matches!(parse_timeshift(Some(&String::from("invalid"))), EpgTimeShift::None));
assert!(matches!(parse_timeshift(Some(&String::from("+abc"))), EpgTimeShift::None));
assert!(matches!(parse_timeshift(Some(&String::new())), EpgTimeShift::None));
assert!(matches!(parse_timeshift(None), EpgTimeShift::None));
}
#[test]
fn test_parse_timezone() {
// This will depend on current DST; we just check it’s within a valid range
let berlin = parse_timeshift(Some(&"Europe/Berlin".to_string())).unwrap();
assert!(berlin == 60 || berlin == 120, "Berlin offset should be 60 or 120, got {berlin}");
// Check timezone parsing creates the correct variant
let amterdam = parse_timeshift(Some(&"Europe/Amsterdam".to_string()));
if let EpgTimeShift::TimeZone(tz) = amterdam {
assert_eq!(tz.name(), "Europe/Amsterdam");
} else {
panic!("Expected TimeZone for Europe/Amsterdam");
}
let new_york = parse_timeshift(Some(&"America/New_York".to_string())).unwrap();
assert!(new_york == -300 || new_york == -240, "New York offset should be -300 or -240, got {new_york}");
let new_york = parse_timeshift(Some(&"America/New_York".to_string()));
if let EpgTimeShift::TimeZone(tz) = new_york {
assert_eq!(tz.name(), "America/New_York");
} else {
panic!("Expected TimeZone for America/New_York");
}
let tokyo = parse_timeshift(Some(&"Asia/Tokyo".to_string())).unwrap();
assert_eq!(tokyo, 540); // always UTC+9
let tokyo = parse_timeshift(Some(&"Asia/Tokyo".to_string()));
if let EpgTimeShift::TimeZone(tz) = tokyo {
assert_eq!(tz.name(), "Asia/Tokyo");
} else {
panic!("Expected TimeZone for Asia/Tokyo");
}
let utc = parse_timeshift(Some(&"UTC".to_string())).unwrap();
assert_eq!(utc, 0);
let utc = parse_timeshift(Some(&"UTC".to_string()));
if let EpgTimeShift::TimeZone(tz) = utc {
assert_eq!(tz.name(), "UTC");
} else {
panic!("Expected TimeZone for UTC");
}
}
}