mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-12 10:52:13 +02:00
261 lines
11 KiB
Rust
261 lines
11 KiB
Rust
use std::fs::File;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use actix_web::{HttpRequest, HttpResponse, web, http::header};
|
|
use log::{debug, info};
|
|
use quick_xml::{Reader, Writer};
|
|
use url::{ParseError};
|
|
use flate2::write::GzEncoder;
|
|
use flate2::Compression;
|
|
use quick_xml::events::{BytesStart, Event};
|
|
use std::io::{BufReader};
|
|
use chrono::{Duration, NaiveDateTime, TimeDelta};
|
|
|
|
use crate::api::api_model::{AppState, UserApiRequest};
|
|
use crate::api::api_utils::{get_user_target, serve_file};
|
|
use crate::model::api_proxy::{ProxyType, ProxyUserCredentials};
|
|
use crate::model::config::{Config, ConfigInput, ConfigTarget};
|
|
use crate::model::config::TargetType;
|
|
use crate::repository::m3u_repository::m3u_get_epg_file_path;
|
|
use crate::repository::storage::get_target_storage_path;
|
|
use crate::repository::xtream_repository::{xtream_get_epg_file_path, xtream_get_storage_path};
|
|
use crate::utils::{file_utils, request_utils};
|
|
|
|
fn time_correct(date_time: &str, correction: &TimeDelta) -> String {
|
|
// Split the dateTime string into date and time parts
|
|
let date_time_split: Vec<&str> = date_time.split(' ').collect();
|
|
if date_time_split.len() != 2 {
|
|
return date_time.to_string();
|
|
}
|
|
|
|
// Parse the datetime string
|
|
match NaiveDateTime::parse_from_str(date_time_split[0], "%Y%m%d%H%M%S") {
|
|
Ok(native_dt) => {
|
|
let corrected_dt = native_dt + *correction;
|
|
// Format the corrected datetime back to string
|
|
let formatted_dt = corrected_dt.format("%Y%m%d%H%M%S").to_string();
|
|
let result = format!("{} {}", formatted_dt, date_time_split[1]);
|
|
result
|
|
}
|
|
Err(_) => date_time.to_string()
|
|
}
|
|
}
|
|
|
|
fn get_epg_path_for_target_of_type(target_name: &str, epg_path: PathBuf) -> Option<PathBuf> {
|
|
if file_utils::path_exists(&epg_path) {
|
|
return Some(epg_path);
|
|
}
|
|
info!("Cant find epg file for {target_name} target: {}", epg_path.to_str().unwrap_or("?"));
|
|
None
|
|
}
|
|
|
|
fn get_epg_path_for_target(config: &Config, target: &ConfigTarget) -> Option<PathBuf> {
|
|
// TODO if we share the same virtual_id for epg, can we store an epg file for the target ?
|
|
for output in &target.output {
|
|
match output.target {
|
|
TargetType::M3u => {
|
|
if let Some(target_path) = get_target_storage_path(config, &target.name) {
|
|
return get_epg_path_for_target_of_type(&target.name, m3u_get_epg_file_path(&target_path));
|
|
}
|
|
}
|
|
TargetType::Xtream => {
|
|
if let Some(storage_path) = xtream_get_storage_path(config, &target.name) {
|
|
return get_epg_path_for_target_of_type(&target.name, xtream_get_epg_file_path(&storage_path));
|
|
}
|
|
}
|
|
TargetType::Strm => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn parse_timeshift(time_shift: Option<&String>) -> Option<i32> {
|
|
match time_shift {
|
|
None => None,
|
|
Some(offset) => {
|
|
let sign_factor = if offset.starts_with('-') { -1 } else { 1 };
|
|
let offset = offset.trim_start_matches(&['-', '+'][..]); // Remove the sign for parsing
|
|
|
|
let total_minutes = if offset.contains(':') {
|
|
// Handle the case with hours and minutes (e.g., "-2:30", "1:45", "+0:15", ":30")
|
|
let parts: Vec<&str> = offset.split(':').collect();
|
|
|
|
let hours: i32 = if parts[0].is_empty() {
|
|
0 // Treat empty hour part as 0 hours
|
|
} else {
|
|
parts[0].parse().unwrap_or(0)
|
|
};
|
|
|
|
let minutes: i32 = if parts.len() > 1 {
|
|
parts[1].parse().unwrap_or(0)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
// Convert hours to minutes and add the minute part
|
|
hours * 60 + minutes
|
|
} else {
|
|
// Handle single number case (e.g., "2" or "+2")
|
|
let num: i32 = offset.parse().unwrap_or(0);
|
|
num * 60
|
|
};
|
|
|
|
if total_minutes > 0 {
|
|
Some(sign_factor * total_minutes)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn serve_epg(epg_path: &Path, req: &HttpRequest, user: &ProxyUserCredentials) -> HttpResponse {
|
|
match File::open(epg_path) {
|
|
Ok(epg_file) => {
|
|
match parse_timeshift(user.epg_timeshift.as_ref()) {
|
|
None => serve_file(epg_path, req, mime::TEXT_XML).await,
|
|
Some(duration) => {
|
|
serve_epg_with_timeshift(epg_file, duration)
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
HttpResponse::NoContent().finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn serve_epg_with_timeshift(epg_file: File, offset_minutes: i32) -> HttpResponse {
|
|
let reader = BufReader::new(epg_file);
|
|
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
let mut xml_reader = Reader::from_reader(reader);
|
|
let mut xml_writer = Writer::new(encoder);
|
|
let mut buf = Vec::new();
|
|
let duration = Duration::minutes(i64::from(offset_minutes));
|
|
|
|
loop {
|
|
match xml_reader.read_event_into(&mut buf) {
|
|
Ok(Event::Start(ref e)) if e.name().as_ref() == b"programme" => {
|
|
// Modify the attributes
|
|
let mut elem = BytesStart::from(e.name());
|
|
for attr in e.attributes() {
|
|
match attr {
|
|
Ok(attr) if attr.key.as_ref() == b"start" => {
|
|
let start_value = attr.decode_and_unescape_value(xml_reader.decoder())
|
|
.expect("Failed to decode start attribute");
|
|
// Modify the start attribute value as needed
|
|
elem.push_attribute(("start", time_correct(&start_value, &duration).as_str()));
|
|
}
|
|
Ok(attr) if attr.key.as_ref() == b"stop" => {
|
|
let stop_value = attr.decode_and_unescape_value(xml_reader.decoder())
|
|
.expect("Failed to decode stop attribute");
|
|
// Modify the stop attribute value as needed
|
|
elem.push_attribute(("stop", time_correct(&stop_value, &duration).as_str()));
|
|
}
|
|
Ok(attr) => {
|
|
// Copy any other attributes as they are
|
|
elem.push_attribute(attr);
|
|
}
|
|
Err(e) => {
|
|
println!("Error parsing attribute: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write the modified start event
|
|
xml_writer.write_event(Event::Start(elem)).expect("Failed to write event");
|
|
}
|
|
Ok(Event::Eof) => break, // End of file
|
|
Ok(event) => {
|
|
// Write any other event as is
|
|
xml_writer.write_event(event).expect("Failed to write event");
|
|
}
|
|
Err(e) => {
|
|
println!("Error: {e}");
|
|
break;
|
|
}
|
|
}
|
|
|
|
buf.clear();
|
|
}
|
|
|
|
let compressed_data = xml_writer.into_inner().finish().unwrap();
|
|
HttpResponse::Ok()
|
|
.content_type("application/octet-stream")
|
|
.insert_header((header::CONTENT_ENCODING, "gzip")) // Set Content-Encoding header
|
|
.body(compressed_data)
|
|
}
|
|
|
|
async fn xmltv_api(
|
|
api_req: web::Query<UserApiRequest>,
|
|
req: HttpRequest,
|
|
app_state: web::Data<AppState>,
|
|
) -> HttpResponse {
|
|
if let Some((user, target)) = get_user_target(&api_req, &app_state) {
|
|
match get_epg_path_for_target(&app_state.config, target) {
|
|
None => {
|
|
// No epg configured, we try to figure out an epg url and redirect.
|
|
// No processing or timeshift
|
|
if let Some(value) = get_xmltv_raw_epg(&app_state.config, &user, &target.name).await {
|
|
return value;
|
|
}
|
|
}
|
|
Some(epg_path) => return serve_epg(&epg_path, &req, &user).await
|
|
}
|
|
}
|
|
HttpResponse::Ok().content_type(mime::TEXT_XML).body(
|
|
r#"<?xml version="1.0" encoding="utf-8" ?><!DOCTYPE tv SYSTEM "xmltv.dtd"><tv generator-info-name="Xtream Codes" generator-info-url=""></tv>"#)
|
|
}
|
|
|
|
fn get_xmltv_epg_url(input: &ConfigInput) -> Result<String, ParseError> {
|
|
let epg_url = input.epg_url.as_ref().map_or(String::new(), std::borrow::ToOwned::to_owned);
|
|
if epg_url.is_empty() {
|
|
if let Some(user_info) = input.get_user_info() {
|
|
let url = user_info.base_url.as_str();
|
|
let username = user_info.username.as_str();
|
|
let password = user_info.password.as_str();
|
|
Ok(format!("{url}/xmltv.php?username={username}&password={password}"))
|
|
} else {
|
|
Err(ParseError::EmptyHost)
|
|
}
|
|
} else {
|
|
Ok(epg_url)
|
|
}
|
|
}
|
|
|
|
async fn get_xmltv_raw_epg(config: &Config, user: &ProxyUserCredentials, target_name: &str) -> Option<HttpResponse> {
|
|
// If no epg_url is provided for input, we did not process the xmltv for our channels.
|
|
// We are now delivering the original untouched xmltv.
|
|
// If you want to use xmltv then provide the url in the config to filter unnecessary content.
|
|
// If you have multiple xtream sources, no response because of mapped ids
|
|
// if you want epg for multi xtream input, then provide epg_url.
|
|
if let Some(inputs) = config.get_inputs_for_target(target_name) {
|
|
if inputs.len() == 1 {
|
|
if let Some(&input) = inputs.first() {
|
|
if let Ok(url) = get_xmltv_epg_url(input) {
|
|
if user.proxy == ProxyType::Redirect {
|
|
debug!("Redirecting epg request to {}", url.as_str());
|
|
return Some(HttpResponse::Found().insert_header(("Location", url.as_str())).finish());
|
|
}
|
|
match request_utils::download_text_content(input, url.as_str(), None).await {
|
|
Ok(content) => return Some(HttpResponse::Ok().content_type(mime::TEXT_XML).body(content)),
|
|
Err(err) => {
|
|
debug!("Could not generate epg url for {target_name} {err}");
|
|
}
|
|
}
|
|
} else {
|
|
debug!("Could not generate epg url for {target_name}");
|
|
}
|
|
}
|
|
} else {
|
|
debug!("No epg_url is provided for target {target_name}, multi input requires epg_url");
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn xmltv_api_register(cfg: &mut web::ServiceConfig) {
|
|
cfg.service(web::resource("/xmltv.php").route(web::get().to(xmltv_api)))
|
|
.service(web::resource("/epg").route(web::get().to(xmltv_api)));
|
|
}
|