Code restructuring

This commit is contained in:
euzu
2025-02-05 20:01:49 +01:00
parent dc607d5520
commit bbf6d6e014
82 changed files with 616 additions and 507 deletions
+51
View File
@@ -0,0 +1,51 @@
use crate::model::api_proxy::ProxyUserCredentials;
use std::str;
use crate::model::config::TargetType;
// /hlsr/{token}/{username}/{password}/{channel}/{hash}/{chunk}
#[derive(Debug)]
pub struct HlsrPath {
token: String,
// username: String,
// password: String,
// channel: String,
hash: String,
chunk: String,
}
fn parse_hlsr_path(input: &str) -> Option<HlsrPath> {
let parts: Vec<&str> = input.split('/').collect();
if parts.len() != 8 || !parts[0].is_empty() || parts[1] != "hlsr" {
return None;
}
Some(HlsrPath {
token: parts[2].to_string(),
// username: parts[3].to_string(),
// password: parts[4].to_string(),
// channel: parts[5].to_string(),
hash: parts[6].to_string(),
chunk: parts[7].to_string(),
})
}
pub const M3U_HLSR_PREFIX: &str = "mhlsr";
pub fn rewrite_hls_url(stream_id: u32, username: &str, password: &str, hlsr: &HlsrPath, target_type: &TargetType) -> String {
let prefix = if *target_type == TargetType::Xtream { "hlsr" } else { M3U_HLSR_PREFIX };
format!("/{prefix}/{}/{username}/{password}/{stream_id}/{}/{}", hlsr.token, hlsr.hash, hlsr.chunk)
}
pub fn rewrite_hls(content: &str, virtual_id: u32, user: &ProxyUserCredentials, target_type: &TargetType) -> String {
content.lines().map(|line| {
if line.starts_with('#') {
line.to_string()
} else {
match parse_hlsr_path(line) {
None => line.to_string(),
Some(hlsr) => rewrite_hls_url(virtual_id, &user.username, &user.password, &hlsr, target_type)
}
}
}).collect::<Vec<_>>()
.join("\r\n")
}
+232
View File
@@ -0,0 +1,232 @@
use std::borrow::BorrowMut;
use std::cell::RefCell;
use std::rc::Rc;
use crate::model::config::{Config, ConfigInput};
use crate::model::playlist::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster};
use crate::utils::hash_utils::extract_id_from_url;
use crate::utils::string_utils;
#[inline]
fn token_value(it: &mut std::str::Chars) -> String {
// Use .find() to skip until the first double quote (") character.
if it.any(|ch| ch == '"') {
// If a quote is found, call get_value to extract the value.
return get_value(it);
}
// If no double quote is found, return an empty string.
String::new()
}
fn get_value(it: &mut std::str::Chars) -> String {
let mut result = String::with_capacity(128);
for oc in it.by_ref() {
if oc == '"' {
break;
}
result.push(oc);
}
result.shrink_to_fit();
result
}
fn token_till(it: &mut std::str::Chars, stop_char: char, start_with_alpha: bool) -> Option<String> {
let mut result = String::with_capacity(128);
let mut skip_non_alpha = start_with_alpha;
for ch in it.by_ref() {
if ch == stop_char {
break;
}
if result.is_empty() && ch.is_whitespace() {
continue;
}
if skip_non_alpha {
if ch.is_alphabetic() {
skip_non_alpha = false;
} else {
continue;
}
}
result.push(ch);
}
if result.is_empty() {
None
} else {
result.shrink_to_fit();
Some(result)
}
}
#[inline]
fn skip_digit(it: &mut std::str::Chars) -> Option<char> {
loop {
match it.next() {
Some(c) => {
if !(c == '-' || c == '+' || c.is_ascii_digit()) {
return Some(c);
}
}
None => return None,
}
}
}
fn create_empty_playlistitem_header(input_name: &str, url: &str) -> PlaylistItemHeader {
PlaylistItemHeader {
url: Rc::new(url.to_owned()),
category_id: 0,
input_name: Rc::new(input_name.to_string()),
..Default::default()
}
}
macro_rules! process_header_fields {
($header:expr, $token:expr, $(($prop:ident, $field:expr)),*; $val:expr) => {
match $token {
$(
$field => $header.$prop = Rc::new($val),
)*
_ => {}
}
};
}
fn process_header(input: &ConfigInput, video_suffixes: &[&str], content: &str, url: &str) -> PlaylistItemHeader {
let mut plih = create_empty_playlistitem_header(input.name.as_str(), url);
let mut it = content.chars();
let line_token = token_till(&mut it, ':', false);
if line_token.as_deref() == Some("#EXTINF") {
let mut c = skip_digit(&mut it);
loop {
if c.is_none() {
break;
}
if c.unwrap() == ',' {
plih.title = Rc::new(get_value(&mut it));
} else {
let token = token_till(&mut it, '=', true);
if let Some(t) = token {
let value = token_value(&mut it);
process_header_fields!(plih, t.to_lowercase().as_str(),
(id, "tvg-id"),
(group, "group-title"),
(name, "tvg-name"),
(chno, "tvg-chno"),
(parent_code, "parent-code"),
(audio_track, "audio-track"),
(logo, "tvg-logo"),
(logo_small, "tvg-logo-small"),
(time_shift, "timeshift"),
(rec, "tvg-rec"); value);
}
}
c = it.next();
}
if plih.id.is_empty() {
if let Some(chanid) = extract_id_from_url(url) {
plih.id = Rc::new(chanid);
}
}
// plih.virtual_id = plih.id;
plih.epg_channel_id = Some(Rc::clone(&plih.id));
}
if video_suffixes.iter().any(|suffix| url.ends_with(suffix)) {
// TODO find Series based on group or configured names
plih.xtream_cluster = XtreamCluster::Video;
plih.item_type = PlaylistItemType::Video;
}
{
let header = plih.borrow_mut();
if header.name.is_empty() {
if !header.title.is_empty() {
header.name = header.title.clone();
} else if !header.id.is_empty() {
header.name = header.id.clone();
header.title = header.id.clone();
}
}
}
plih
}
pub fn consume_m3u<'a, I, F: FnMut(PlaylistItem)>(cfg: &Config, input: &ConfigInput, lines: I, mut visit: F)
where
I: Iterator<Item=&'a str>,
{
let mut header: Option<String> = None;
let mut group: Option<String> = None;
let video_suffixes = cfg.video.as_ref().unwrap().extensions.iter().map(String::as_str).collect::<Vec<&str>>();
for line in lines {
if line.starts_with("#EXTINF") {
header = Some(String::from(line));
continue;
}
if line.starts_with("#EXTGRP") {
group = Some(String::from(&line[8..]));
continue;
}
if line.starts_with('#') {
continue;
}
if let Some(header_value) = header {
let item = PlaylistItem { header: RefCell::new(process_header(input, &video_suffixes, &header_value, line)) };
let mut header = item.header.borrow_mut();
if header.group.is_empty() {
if let Some(group_value) = group {
header.group = Rc::new(group_value);
} else {
let current_title = header.title.clone();
header.group = Rc::new(string_utils::get_title_group(current_title.as_str()));
}
}
drop(header);
visit(item);
}
header = None;
group = None;
}
}
pub fn parse_m3u<'a, I>(cfg: &Config, input: &ConfigInput, lines: I) -> Vec<PlaylistGroup>
where
I: Iterator<Item=&'a str>,
{
let mut sort_order: Vec<Vec<PlaylistItem>> = vec![];
let mut sort_order_idx: usize = 0;
let mut group_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
consume_m3u(cfg, input, lines, |item| {
// keep the original sort order for groups and group the playlist items
let key = {
let header = item.header.borrow();
format!("{}{}", &header.xtream_cluster, &header.group)
};
match group_map.entry(key) {
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(sort_order_idx);
sort_order.push(vec![item]);
sort_order_idx += 1;
}
std::collections::hash_map::Entry::Occupied(o) => {
sort_order.get_mut(*o.get()).unwrap().push(item);
}
}
});
let mut grp_id = 0;
let result: Vec<PlaylistGroup> = sort_order.into_iter().map(|channels| {
// create a group based on the first playlist item
let channel = channels.first();
let (cluster, group_title) = channel.map(|pli|
(pli.header.borrow().xtream_cluster, Rc::clone(&pli.header.borrow().group))).unwrap();
grp_id += 1;
PlaylistGroup { id: grp_id, xtream_cluster: cluster, title: Rc::clone(&group_title), channels }
}).collect();
result
}
+4
View File
@@ -0,0 +1,4 @@
pub mod m3u;
pub mod xtream;
pub mod xmltv;
pub mod hls;
+196
View File
@@ -0,0 +1,196 @@
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use quick_xml::events::Event;
use quick_xml::Reader;
use crate::model::xmltv::{Epg, EPG_ATTRIB_CHANNEL, EPG_ATTRIB_ID, EPG_TAG_TV, EPG_TAG_CHANNEL, EPG_TAG_PROGRAMME, TVGuide, XmlTag};
use crate::utils::compression::compressed_file_reader::CompressedFileReader;
impl TVGuide {
pub fn filter(&self, channel_ids: &HashSet<Rc<String>>) -> Option<Epg> {
if channel_ids.is_empty() {
return None;
}
match CompressedFileReader::new(&self.file) {
Ok(mut reader) => {
let mut children: Vec<XmlTag> = vec![];
let mut tv_attributes: Option<Rc<HashMap<String, String>>> = None;
let mut filter_tags = |tag: XmlTag| {
if match tag.name.as_str() {
EPG_TAG_CHANNEL => {
tag.get_attribute_value(EPG_ATTRIB_ID).is_some_and(|val| channel_ids.contains(val))
}
EPG_TAG_PROGRAMME => {
tag.get_attribute_value(EPG_ATTRIB_CHANNEL).is_some_and(|val| channel_ids.contains(val))
},
EPG_TAG_TV => {
tv_attributes.clone_from(&tag.attributes);
false
},
_ => false,
} {
children.push(tag);
}
};
parse_tvguide(&mut reader, &mut filter_tags);
if children.is_empty() {
return None;
}
Some(Epg {
attributes: tv_attributes,
children,
})
}
Err(_) => None
}
}
}
pub fn parse_tvguide<R, F>(content: R, callback: &mut F)
where
R: std::io::BufRead,
F: FnMut(XmlTag),
{
let mut stack: Vec<XmlTag> = vec![];
let mut reader = Reader::from_reader(content);
let mut buf = Vec::<u8>::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let is_tv_tag = name == EPG_TAG_TV;
let attributes = e.attributes().filter_map(Result::ok)
.filter_map(|a| {
let key = String::from_utf8_lossy(a.key.as_ref()).to_string();
let value = String::from(a.unescape_value().unwrap().as_ref());
if value.is_empty() {
None
} else {
Some((key, value))
}
}).collect::<HashMap<String, String>>();
let tag = XmlTag {
name,
value: None,
attributes: if attributes.is_empty() { None } else { Some(Rc::new(attributes)) },
children: None,
};
if is_tv_tag {
callback(tag);
} else {
stack.push(tag);
}
}
Ok(Event::End(_e)) => {
if !stack.is_empty() {
if let Some(tag) = stack.pop() {
if tag.name == EPG_TAG_CHANNEL {
if let Some(chan_id) = tag.get_attribute_value(EPG_ATTRIB_ID) {
if !chan_id.is_empty() {
callback(tag);
}
}
} else if tag.name == EPG_TAG_PROGRAMME {
if let Some(chan_id) = tag.get_attribute_value(EPG_ATTRIB_CHANNEL) {
if !chan_id.is_empty() {
callback(tag);
}
}
} else if !stack.is_empty() {
if let Some(old_tag) = stack.pop().map(|mut r| {
let rc_tag = Rc::new(tag);
r.children = Some(
r.children.map_or_else(|| vec![rc_tag.clone()], |mut c| {
c.push(rc_tag.clone());
c
}));
r
}) {
stack.push(old_tag);
}
}
}
}
}
Ok(Event::Text(e)) => {
let text = e.unescape().unwrap().trim().to_owned();
if !text.is_empty() && !stack.is_empty() {
stack.last_mut().unwrap().value = Some(text);
}
}
_ => {}
}
}
}
pub fn flatten_tvguide(tv_guides: &[Epg]) -> Option<Epg> {
if tv_guides.is_empty() {
None
} else {
let mut epg = Epg {
attributes: None,
children: vec![],
};
let mut channel_ids: Vec<&String> = vec![];
for guide in tv_guides {
if epg.attributes.is_none() {
epg.attributes.clone_from(&guide.attributes);
}
guide.children.iter().for_each(|c| {
if c.name.as_str() == EPG_TAG_CHANNEL {
if let Some(chan_id) = c.get_attribute_value(EPG_ATTRIB_ID) {
if !channel_ids.contains(&chan_id) {
channel_ids.push(chan_id);
epg.children.push(c.clone());
}
}
}
});
guide.children.iter().for_each(|c| {
if c.name.as_str() == EPG_TAG_PROGRAMME {
if let Some(chan_id) = c.get_attribute_value(EPG_TAG_CHANNEL) {
if channel_ids.contains(&chan_id) {
epg.children.push(c.clone());
}
}
}
});
}
Some(epg)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::io;
use std::path::PathBuf;
use std::rc::Rc;
use crate::model::xmltv::{TVGuide};
#[test]
fn parse_test() -> io::Result<()> {
let file_path = "/tmp/epg.xml.gz";
let tv_guide = TVGuide { file: PathBuf::from(file_path) };
let channel_ids = vec!["channel.1", "channel.2", "channel.3"];
let channel_ids : HashSet<Rc<String>> = channel_ids.into_iter().map(|s| Rc::new(s.to_string())).collect();
match tv_guide.filter(&channel_ids) {
None => assert!(false, "No epg filtered"),
Some(epg) => {
assert_eq!(epg.children.len(), channel_ids.len() * 2, "Epg size does not match")
}
}
Ok(())
}
}
+177
View File
@@ -0,0 +1,177 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use serde_json::Value;
use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind, create_m3u_filter_error_result};
use crate::model::config::ConfigInput;
use crate::model::playlist::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster};
use crate::model::xtream::{XtreamCategory, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamStream};
use crate::utils::hash_utils::generate_playlist_uuid;
use crate::utils::network::xtream::{get_xtream_stream_url_base, ACTION_GET_SERIES_INFO};
fn map_to_xtream_category(categories: &Value) -> Result<Vec<XtreamCategory>, M3uFilterError> {
match serde_json::from_value::<Vec<XtreamCategory>>(categories.to_owned()) {
Ok(xtream_categories) => Ok(xtream_categories),
Err(err) => {
create_m3u_filter_error_result!(M3uFilterErrorKind::Notify, "Failed to process categories {}", &err)
}
}
}
fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: &Value) -> Result<Vec<XtreamStream>, M3uFilterError> {
match serde_json::from_value::<Vec<XtreamStream>>(streams.to_owned()) {
Ok(stream_list) => Ok(stream_list),
Err(err) => {
create_m3u_filter_error_result!(M3uFilterErrorKind::Notify, "Failed to map to xtream streams {:?}: {}", xtream_cluster, &err)
}
}
}
fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &XtreamSeriesInfoEpisode) -> Rc<String> {
if episode.direct_source.is_empty() {
let ext = episode.container_extension.clone();
let stream_base_url = format!("{url}/series/{username}/{password}/{}.{ext}", episode.id);
Rc::new(stream_base_url)
} else {
Rc::new(episode.direct_source.clone())
}
}
pub fn parse_xtream_series_info(info: &Value, group_title: &str, series_name: &str, input: &ConfigInput) -> Result<Option<Vec<(XtreamSeriesInfoEpisode, PlaylistItem)>>, M3uFilterError> {
let url = input.url.as_str();
let username = input.username.as_ref().map_or("", |v| v);
let password = input.password.as_ref().map_or("", |v| v);
match serde_json::from_value::<XtreamSeriesInfo>(info.to_owned()) {
Ok(series_info) => {
if let Some(episodes) = &series_info.episodes {
let result: Vec<(XtreamSeriesInfoEpisode, PlaylistItem)> = episodes.values().flatten().map(|episode| {
let episode_url = create_xtream_series_episode_url(url, username, password, episode);
(episode.clone(),
PlaylistItem {
header: RefCell::new(PlaylistItemHeader {
id: Rc::new(episode.id.to_string()),
uuid: Rc::new(generate_playlist_uuid(&input.name, &episode.id, PlaylistItemType::Series, &episode_url)),
name: Rc::new(series_name.to_string()),
logo: Rc::new(episode.info.as_ref().map_or_else(String::new, |info| info.movie_image.to_string())),
group: Rc::new(group_title.to_string()),
title: Rc::new(episode.title.clone()),
url: episode_url,
item_type: PlaylistItemType::Series,
xtream_cluster: XtreamCluster::Series,
additional_properties: episode.get_additional_properties(&series_info),
category_id: 0,
input_name: Rc::new(input.name.to_string()),
..Default::default()
})
})
}).collect();
return if result.is_empty() { Ok(None) } else { Ok(Some(result)) };
}
Ok(None)
}
Err(err) => {
create_m3u_filter_error_result!(M3uFilterErrorKind::Notify, "Failed to process series info for {series_name} {err}")
}
}
}
pub fn create_xtream_url(xtream_cluster: XtreamCluster, url: &str, username: &str, password: &str,
stream: &XtreamStream, live_stream_use_prefix: bool, live_stream_without_extension: bool) -> Rc<String> {
if stream.direct_source.is_empty() {
let stream_base_url = match xtream_cluster {
XtreamCluster::Live => {
let ctx_path = if live_stream_use_prefix {"live/"} else { "" };
let suffix = if live_stream_without_extension { "" } else { ".ts" };
format!("{url}/{ctx_path}{username}/{password}/{}{suffix}", &stream.get_stream_id())
},
XtreamCluster::Video => {
let ext = stream.container_extension.as_ref().map_or("mp4", |e| e.as_str());
format!("{url}/movie/{username}/{password}/{}.{ext}", &stream.get_stream_id())
}
XtreamCluster::Series =>
format!("{}&action={ACTION_GET_SERIES_INFO}&series_id={}", get_xtream_stream_url_base(url, username, password), &stream.get_stream_id())
};
Rc::new(stream_base_url)
} else {
Rc::clone(&stream.direct_source)
}
}
pub fn parse_xtream(input: &ConfigInput,
xtream_cluster: XtreamCluster,
categories: &Value,
streams: &Value) -> Result<Option<Vec<PlaylistGroup>>, M3uFilterError> {
match map_to_xtream_category(categories) {
Ok(xtream_categories) => {
let input_name = Rc::new(input.name.to_string());
let url = input.url.as_str();
let username = input.username.as_ref().map_or("", |v| v);
let password = input.password.as_ref().map_or("", |v| v);
match map_to_xtream_streams(xtream_cluster, streams) {
Ok(xtream_streams) => {
let mut group_map: HashMap::<Rc<String>, RefCell<XtreamCategory>> =
xtream_categories.into_iter().map(|category|
(Rc::clone(&category.category_id), RefCell::new(category))
).collect();
let unknown_grp = RefCell::new(XtreamCategory {
category_id: Rc::new("0".to_string()),
category_name: Rc::new("Unknown".to_string()),
channels: vec![],
});
let (live_stream_use_prefix, live_stream_without_extension) = input.options.as_ref()
.map_or((true, false), |o| (o.xtream_live_stream_use_prefix, o.xtream_live_stream_without_extension));
for stream in xtream_streams {
let group = group_map.get(&stream.category_id).unwrap_or(&unknown_grp);
let mut grp = group.borrow_mut();
let category_name = &grp.category_name;
let stream_url = create_xtream_url(xtream_cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension);
let item_type = PlaylistItemType::from(xtream_cluster);
let item = PlaylistItem {
header: RefCell::new(PlaylistItemHeader {
id: Rc::new(stream.get_stream_id().to_string()),
uuid: Rc::new(generate_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type, &stream_url)),
name: Rc::clone(&stream.name),
logo: Rc::clone(&stream.stream_icon),
group: Rc::clone(category_name),
title: Rc::clone(&stream.name),
url: stream_url,
epg_channel_id: stream.epg_channel_id.clone(),
item_type,
xtream_cluster,
additional_properties: stream.get_additional_properties(),
category_id: 0,
input_name: Rc::clone(&input_name),
..Default::default()
}),
};
grp.add(item);
}
let has_channels = !unknown_grp.borrow().channels.is_empty();
if has_channels {
group_map.insert(Rc::new("0".to_string()), unknown_grp);
}
Ok(Some(group_map.values().filter(|category| !category.borrow().channels.is_empty())
.map(|category| {
let cat = category.borrow();
PlaylistGroup {
id: cat.category_id.parse::<u32>().unwrap_or(0),
xtream_cluster,
title: Rc::clone(&cat.category_name),
channels: cat.channels.clone(),
}
}).collect()))
}
Err(err) => Err(err)
}
}
Err(err) => Err(err)
}
}