mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-29 20:42:31 +02:00
project shared added
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
use crate::model::ProxyUserCredentials;
|
||||
use crate::utils::{CONSTANTS, HLS_PREFIX};
|
||||
use crate::utils::{deobfuscate_text, obfuscate_text};
|
||||
use std::str;
|
||||
|
||||
const TOKEN_SEPARATOR: char = '\x1F';
|
||||
|
||||
fn create_hls_session_token_and_url(secret: &[u8], session_token: &str, stream_url: &str) -> Option<String> {
|
||||
if let Ok(cookie_value) = obfuscate_text(secret, &format!("{session_token}{TOKEN_SEPARATOR}{stream_url}")) {
|
||||
return Some(cookie_value);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_hls_session_token_and_url_from_token(secret: &[u8], token: &str) -> Option<(Option<String>, String)> {
|
||||
if let Ok(decrypted) = deobfuscate_text(secret, token) {
|
||||
let parts: Vec<&str> = decrypted.split(TOKEN_SEPARATOR).collect();
|
||||
if !parts.is_empty() && parts.len() == 2 {
|
||||
let session_token: String = parts[0].to_string();
|
||||
let stream_url: String = parts[1].to_string();
|
||||
return Some((Some(session_token), stream_url));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
pub struct RewriteHlsProps<'a> {
|
||||
pub secret: &'a [u8; 16],
|
||||
pub base_url: &'a str,
|
||||
pub content: &'a str,
|
||||
pub hls_url: String,
|
||||
pub virtual_id: u32,
|
||||
pub input_id: u16,
|
||||
pub user_token: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn rewrite_hls_url(input: &str, replacement: &str) -> String {
|
||||
if replacement.starts_with('/') {
|
||||
let parts = input.splitn(4, '/').collect::<Vec<&str>>();
|
||||
if parts.len() < 4 {
|
||||
return replacement.to_string(); // less than 3 Slashes → replace all
|
||||
}
|
||||
format!("{}/{}/{}{}", parts[0], parts[1], parts[2], replacement)
|
||||
} else {
|
||||
match input.rsplitn(2, '/').collect::<Vec<&str>>().as_slice() {
|
||||
[_after, before] => format!("{before}/{replacement}"),
|
||||
[_only] => replacement.to_string(),
|
||||
_ => input.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_uri_attrib(line: &str, props: &RewriteHlsProps) -> String {
|
||||
if let Some(caps) = CONSTANTS.re_hls_uri.captures(line) {
|
||||
let uri = &caps[1];
|
||||
let target_url = &rewrite_hls_url(&props.hls_url, uri);
|
||||
if let Some(user_token) = &props.user_token {
|
||||
if let Some(token) = create_hls_session_token_and_url(props.secret, user_token, target_url) {
|
||||
return CONSTANTS.re_hls_uri.replace(line, format!(r#"URI="{token}""#)).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
line.to_string()
|
||||
}
|
||||
|
||||
pub fn rewrite_hls(user: &ProxyUserCredentials, props: &RewriteHlsProps) -> String {
|
||||
let username = &user.username;
|
||||
let password = &user.password;
|
||||
let mut result = Vec::new();
|
||||
for line in props.content.lines() {
|
||||
// skip comments
|
||||
if line.starts_with('#') {
|
||||
let rewritten = rewrite_uri_attrib(line, props);
|
||||
result.push(rewritten);
|
||||
continue;
|
||||
}
|
||||
|
||||
// target url
|
||||
let target_url = if line.starts_with("http") {
|
||||
line.to_string()
|
||||
} else {
|
||||
rewrite_hls_url(&props.hls_url, line)
|
||||
};
|
||||
if let Some(user_token) = &props.user_token {
|
||||
if let Some(token) = create_hls_session_token_and_url(props.secret, user_token, &target_url) {
|
||||
let url = format!(
|
||||
"{}/{HLS_PREFIX}/{}/{}/{}/{}/{}",
|
||||
props.base_url,
|
||||
username,
|
||||
password,
|
||||
props.input_id,
|
||||
props.virtual_id,
|
||||
token
|
||||
);
|
||||
result.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push("\r\n".to_string());
|
||||
result.join("\r\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use rand::RngCore;
|
||||
use crate::utils::u32_to_base64;
|
||||
|
||||
#[test]
|
||||
fn test_token_size() {
|
||||
for _i in 0..10_000 {
|
||||
let session_token = rand::rng().next_u32();
|
||||
assert_eq!(u32_to_base64(session_token).len(), 6);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use std::borrow::BorrowMut;
|
||||
use shared::model::{PlaylistItemType, XtreamCluster};
|
||||
use crate::model::{Config, ConfigInput};
|
||||
use crate::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader};
|
||||
use crate::utils::{extract_id_from_url, get_title_group};
|
||||
|
||||
#[inline]
|
||||
fn token_value(stack: &mut String, 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(stack, it);
|
||||
}
|
||||
// If no double quote is found, return an empty string.
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn get_value(stack: &mut String, it: &mut std::str::Chars) -> String {
|
||||
for c in it.skip_while(|c| c.is_whitespace()) {
|
||||
if c == '"' {
|
||||
break;
|
||||
}
|
||||
stack.push(c);
|
||||
}
|
||||
|
||||
let result = (*stack).to_string();
|
||||
stack.clear();
|
||||
result
|
||||
}
|
||||
|
||||
fn token_till(stack: &mut String, it: &mut std::str::Chars, stop_char: char, start_with_alpha: bool) -> Option<String> {
|
||||
let mut skip_non_alpha = start_with_alpha;
|
||||
|
||||
for ch in it.by_ref() {
|
||||
if ch == stop_char {
|
||||
break;
|
||||
}
|
||||
if stack.is_empty() && ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if skip_non_alpha {
|
||||
if ch.is_alphabetic() {
|
||||
skip_non_alpha = false;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stack.push(ch);
|
||||
}
|
||||
|
||||
if stack.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let result = (*stack).to_string();
|
||||
stack.clear();
|
||||
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: url.to_owned(),
|
||||
category_id: 0,
|
||||
input_name: 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 = $val,
|
||||
)*
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn process_header(input_name: &str, video_suffixes: &[&str], content: &str, url: &str) -> PlaylistItemHeader {
|
||||
let mut plih = create_empty_playlistitem_header(input_name, url);
|
||||
let mut it = content.chars();
|
||||
let mut stack = String::with_capacity(64);
|
||||
let line_token = token_till(&mut stack, &mut it, ':', false);
|
||||
if line_token.as_deref() == Some("#EXTINF") {
|
||||
let mut c = skip_digit(&mut it);
|
||||
loop {
|
||||
if c.is_none() {
|
||||
break;
|
||||
}
|
||||
let chr = c.unwrap();
|
||||
if chr.is_whitespace() {
|
||||
// skip
|
||||
} else if chr == ',' {
|
||||
plih.title = get_value(&mut stack, &mut it);
|
||||
} else {
|
||||
stack.push(chr);
|
||||
let token = token_till(&mut stack, &mut it, '=', true);
|
||||
if let Some(t) = token {
|
||||
let value = token_value(&mut stack, &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() {
|
||||
plih.epg_channel_id = None;
|
||||
if let Some(chanid) = extract_id_from_url(url) {
|
||||
plih.id = chanid;
|
||||
}
|
||||
} else {
|
||||
plih.epg_channel_id = Some(plih.id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
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 input_name = input.name.as_str();
|
||||
|
||||
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 mut item = PlaylistItem { header: process_header(input_name, &video_suffixes, &header_value, line) };
|
||||
let header = &mut item.header;
|
||||
if header.group.is_empty() {
|
||||
if let Some(group_value) = group {
|
||||
header.group = group_value;
|
||||
} else {
|
||||
let current_title = header.title.clone();
|
||||
header.group = get_title_group(current_title.as_str());
|
||||
}
|
||||
}
|
||||
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;
|
||||
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.xtream_cluster, &pli.header.group)).unwrap();
|
||||
grp_id += 1;
|
||||
PlaylistGroup { id: grp_id, xtream_cluster: cluster, title: group_title.to_string(), channels }
|
||||
}).collect();
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::processing::parser::m3u::process_header;
|
||||
|
||||
#[test]
|
||||
fn test_process_header_1() {
|
||||
let input: &str = "hello";
|
||||
let video_suffixes = Vec::new();
|
||||
let url = "http://hello.de/hello.ts";
|
||||
let line = r#"#EXTINF:-1 channel-id="abc-seven" tvg-id="abc-seven" tvg-logo="https://abc.nz/.images/seven.png" tvg-chno="7" group-title="Sydney" , Seven"#;
|
||||
|
||||
let pli = process_header(input, &video_suffixes, line, url);
|
||||
assert_eq!(pli.title, "Seven");
|
||||
assert_eq!(pli.id, "abc-seven");
|
||||
assert_eq!(pli.logo, "https://abc.nz/.images/seven.png");
|
||||
assert_eq!(pli.chno, "7");
|
||||
assert_eq!(pli.group, "Sydney");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_header_2() {
|
||||
let input: &str = "hello";
|
||||
let video_suffixes = Vec::new();
|
||||
let url = "http://hello.de/hello.ts";
|
||||
let line = r#"#EXTINF:-1 channel-id="abc-seven" tvg-id="abc-seven" tvg-logo="https://abc.nz/.images/seven.png" tvg-chno="7" group-title="Sydney", Seven"#;
|
||||
|
||||
let pli = process_header(input, &video_suffixes, line, url);
|
||||
assert_eq!(pli.title, "Seven");
|
||||
assert_eq!(pli.id, "abc-seven");
|
||||
assert_eq!(pli.logo, "https://abc.nz/.images/seven.png");
|
||||
assert_eq!(pli.chno, "7");
|
||||
assert_eq!(pli.group, "Sydney");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod m3u;
|
||||
pub mod xtream;
|
||||
pub mod xmltv;
|
||||
pub mod hls;
|
||||
@@ -0,0 +1,580 @@
|
||||
use crate::model::{Epg, TVGuide, XmlTag, XmlTagIcon, EPG_ATTRIB_CHANNEL, EPG_ATTRIB_ID, EPG_TAG_CHANNEL, EPG_TAG_DISPLAY_NAME, EPG_TAG_ICON, EPG_TAG_PROGRAMME, EPG_TAG_TV};
|
||||
use crate::model::{EpgNamePrefix, EpgSmartMatchConfig, PersistedEpgSource};
|
||||
use crate::processing::processor::epg::EpgIdCache;
|
||||
use crate::utils::compressed_file_reader::CompressedFileReader;
|
||||
use crate::utils::CONSTANTS;
|
||||
use deunicode::deunicode;
|
||||
use quick_xml::events::{BytesStart, BytesText, Event};
|
||||
use quick_xml::Reader;
|
||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::min;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::mem;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
/// Splits a string at the first delimiter if the prefix matches a known country code.
|
||||
///
|
||||
/// Returns a tuple containing the country code prefix (if found) and the remainder of the string, both trimmed. If no valid prefix is found, returns `None` and the original input.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let delimiters = vec!['.', '-', '_'];
|
||||
/// let (prefix, rest) = split_by_first_match("US.HBO", &delimiters);
|
||||
/// assert_eq!(prefix, Some("US"));
|
||||
/// assert_eq!(rest, "HBO");
|
||||
///
|
||||
/// let (prefix, rest) = split_by_first_match("HBO", &delimiters);
|
||||
/// assert_eq!(prefix, None);
|
||||
/// assert_eq!(rest, "HBO");
|
||||
/// ```
|
||||
fn split_by_first_match<'a>(input: &'a str, delimiters: &[char]) -> (Option<&'a str>, &'a str) {
|
||||
let content = input.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
for delim in delimiters {
|
||||
if let Some(index) = content.find(*delim) {
|
||||
let (left, right) = content.split_at(index);
|
||||
let right = &right[delim.len_utf8()..].trim();
|
||||
if !right.is_empty() {
|
||||
let prefix = left.trim();
|
||||
if CONSTANTS.country_codes.contains(&prefix) {
|
||||
return (Some(prefix), right.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, input)
|
||||
}
|
||||
|
||||
|
||||
fn name_prefix<'a>(name: &'a str, smart_config: &EpgSmartMatchConfig) -> (&'a str, Option<&'a str>) {
|
||||
if smart_config.name_prefix != EpgNamePrefix::Ignore {
|
||||
let (prefix, suffix) = split_by_first_match(name, &smart_config.t_name_prefix_separator.clone());
|
||||
if prefix.is_some() {
|
||||
return (suffix, prefix);
|
||||
}
|
||||
}
|
||||
(name, None)
|
||||
}
|
||||
|
||||
fn combine(join: &str, left: &str, right: &str) -> String {
|
||||
let mut combined = String::with_capacity(left.len() + join.len() + right.len());
|
||||
combined.push_str(left);
|
||||
combined.push('.');
|
||||
combined.push_str(right);
|
||||
combined
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
pub fn normalize_channel_name(name: &str, normalize_config: &EpgSmartMatchConfig) -> String {
|
||||
let normalized = deunicode(name.trim()).to_lowercase();
|
||||
let (channel_name, suffix) = name_prefix(&normalized, normalize_config);
|
||||
// Remove all non-alphanumeric characters (except dashes and underscores).
|
||||
let cleaned_name = normalize_config.t_normalize_regex.as_ref().unwrap().replace_all(channel_name, "");
|
||||
// Remove terms like resolution
|
||||
let cleaned_name = normalize_config.t_strip.iter().fold(cleaned_name.to_string(), |acc, term| {
|
||||
acc.replace(term, "")
|
||||
});
|
||||
match suffix {
|
||||
None => cleaned_name,
|
||||
Some(sfx) => {
|
||||
match &normalize_config.name_prefix {
|
||||
EpgNamePrefix::Ignore => cleaned_name,
|
||||
EpgNamePrefix::Suffix(sep) => combine(sep, &cleaned_name, sfx),
|
||||
EpgNamePrefix::Prefix(sep) => combine(sep, sfx, &cleaned_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl TVGuide {
|
||||
pub fn merge(mut epgs: Vec<Epg>) -> Option<Epg> {
|
||||
if let Some(first_epg) = epgs.get_mut(0) {
|
||||
let first_epg_attributes = first_epg.attributes.take();
|
||||
let merged_children: Vec<XmlTag> = epgs.into_iter().flat_map(|epg| epg.children).collect();
|
||||
Some(Epg {
|
||||
logo_override: false,
|
||||
priority: 0,
|
||||
attributes: first_epg_attributes,
|
||||
children: merged_children,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_tag(id_cache: &mut EpgIdCache, tag: &mut XmlTag, smart_match: bool) {
|
||||
if let Some(children) = &mut tag.children {
|
||||
for child in children {
|
||||
match child.name.as_str() {
|
||||
EPG_TAG_DISPLAY_NAME => {
|
||||
if smart_match {
|
||||
if let Some(name) = &child.value {
|
||||
tag.normalized_epg_ids
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push(normalize_channel_name(name, &id_cache.smart_match_config));
|
||||
}
|
||||
}
|
||||
}
|
||||
EPG_TAG_ICON => {
|
||||
if let Some(src) = child.get_attribute_value("src") {
|
||||
if !src.is_empty() {
|
||||
tag.icon = XmlTagIcon::Src(src.to_string());
|
||||
child.icon = XmlTagIcon::Exists;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fuzzy_matching(id_cache: &mut EpgIdCache, epg_id: &str, tag: &XmlTag, fuzzy_matching: bool) -> bool {
|
||||
let mut matched = tag
|
||||
.normalized_epg_ids
|
||||
.as_ref()
|
||||
.is_some_and(|ids| id_cache.match_with_normalized(epg_id, ids));
|
||||
if !matched && fuzzy_matching {
|
||||
let (fuzzy_matched, matched_normalized_name) = Self::find_best_fuzzy_match(id_cache, tag);
|
||||
if fuzzy_matched {
|
||||
let key = matched_normalized_name.unwrap();
|
||||
let id = epg_id.to_string();
|
||||
id_cache.normalized.entry(key).and_modify(|entry| {
|
||||
entry.replace(id.clone());
|
||||
id_cache.channel_epg_id.insert(Cow::Owned(id));
|
||||
matched = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
matched
|
||||
}
|
||||
|
||||
/// Finds the best fuzzy match for a channel's normalized EPG ID using phonetic encoding and Jaro-Winkler similarity.
|
||||
///
|
||||
/// Iterates over the tag's normalized EPG IDs, computes their phonetic codes, and searches for candidates in the phonetics map.
|
||||
/// For each candidate, calculates the Jaro-Winkler similarity score and tracks the best match above the configured threshold.
|
||||
/// Returns a tuple indicating whether a suitable match was found and the matched normalized EPG ID if available.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A tuple where the first element is `true` if a match above the threshold was found, and the second element is the matched normalized EPG ID.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let (found, matched) = find_best_fuzzy_match(&mut id_cache, &tag);
|
||||
/// if found {
|
||||
/// println!("Best match: {:?}", matched);
|
||||
/// }
|
||||
/// ```
|
||||
fn find_best_fuzzy_match(id_cache: &mut EpgIdCache, tag: &XmlTag) -> (bool, Option<String>) {
|
||||
let early_exit_flag = Arc::new(AtomicBool::new(false));
|
||||
let data: Mutex<(u16, Option<Cow<str>>)> = Mutex::new((0, None));
|
||||
|
||||
let match_threshold = id_cache.smart_match_config.match_threshold;
|
||||
let best_match_threshold = id_cache.smart_match_config.best_match_threshold;
|
||||
|
||||
if let Some(normalized_epg_ids) = tag.normalized_epg_ids.as_ref() {
|
||||
for tag_normalized in normalized_epg_ids {
|
||||
let tag_code = id_cache.phonetic(tag_normalized);
|
||||
if let Some(normalized) = id_cache.phonetics.get(&tag_code) {
|
||||
normalized.par_iter().find_any(|norm_key| {
|
||||
let match_jw = strsim::jaro_winkler(norm_key, tag_normalized);
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
let mjw = min(100, (match_jw * 100.0).round() as u16);
|
||||
if mjw >= match_threshold {
|
||||
let mut lock = data.lock().unwrap();
|
||||
if lock.0 < mjw {
|
||||
*lock = (mjw, Some(Cow::Borrowed(norm_key)));
|
||||
}
|
||||
if mjw > best_match_threshold {
|
||||
return true; // (true, matched_normalized_epg_id.map(|s| s.to_string()));
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// is there an early exit strategy ???
|
||||
|
||||
if early_exit_flag.load(Ordering::SeqCst) {
|
||||
let result = data.lock().unwrap().1.take();
|
||||
return (true, result.as_ref().map(std::string::ToString::to_string));
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
|
||||
/// Parses and filters a compressed EPG XML file, extracting relevant channel and program tags based on smart and fuzzy matching criteria.
|
||||
///
|
||||
/// Returns an `Epg` containing filtered tags and TV attributes if any matching channels are found; otherwise, returns `None`.
|
||||
/// The returned `Epg` will include the priority from the source, which is used for merging multiple EPG sources.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let mut id_cache = EpgIdCache::default();
|
||||
/// let epg_source = PersistedEpgSource { file_path: Path::new("guide.xml.gz"), priority: 0 };
|
||||
/// if let Some(epg) = process_epg_file(&mut id_cache, &epg_source) {
|
||||
/// assert!(!epg.children.is_empty());
|
||||
/// }
|
||||
/// ```
|
||||
fn process_epg_file(id_cache: &mut EpgIdCache, epg_source: &PersistedEpgSource) -> Option<Epg> {
|
||||
match CompressedFileReader::new(&epg_source.file_path) {
|
||||
Ok(mut reader) => {
|
||||
let mut children: Vec<XmlTag> = vec![];
|
||||
let mut tv_attributes: Option<HashMap<String, String>> = None;
|
||||
let smart_match = id_cache.smart_match_config.enabled;
|
||||
let fuzzy_matching = smart_match && id_cache.smart_match_config.fuzzy_matching;
|
||||
let mut filter_tags = |mut tag: XmlTag| {
|
||||
match tag.name.as_str() {
|
||||
EPG_TAG_CHANNEL => {
|
||||
let epg_id = tag.get_attribute_value(EPG_ATTRIB_ID).map_or_else(String::new, std::string::ToString::to_string);
|
||||
if !epg_id.is_empty() && !id_cache.processed.contains(&epg_id) {
|
||||
Self::prepare_tag(id_cache, &mut tag, smart_match);
|
||||
if smart_match {
|
||||
if Self::try_fuzzy_matching(id_cache, &epg_id, &tag, fuzzy_matching) {
|
||||
children.push(tag);
|
||||
id_cache.processed.insert(epg_id);
|
||||
}
|
||||
} else {
|
||||
let borrowed_epg_id = Cow::Borrowed(epg_id.as_str());
|
||||
if id_cache.channel_epg_id.contains(&borrowed_epg_id) {
|
||||
children.push(tag);
|
||||
id_cache.processed.insert(epg_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EPG_TAG_PROGRAMME => {
|
||||
if let Some(epg_id) = tag.get_attribute_value(EPG_ATTRIB_CHANNEL) {
|
||||
if id_cache.processed.contains(epg_id) {
|
||||
let borrowed_epg_id = Cow::Borrowed(epg_id.as_str());
|
||||
if id_cache.channel_epg_id.contains(&borrowed_epg_id) {
|
||||
children.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EPG_TAG_TV => {
|
||||
tv_attributes = tag.attributes.take();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
|
||||
parse_tvguide(&mut reader, &mut filter_tags);
|
||||
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Epg {
|
||||
logo_override: epg_source.logo_override,
|
||||
priority: epg_source.priority,
|
||||
attributes: tv_attributes,
|
||||
children,
|
||||
})
|
||||
}
|
||||
Err(_) => None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter(&self, id_cache: &mut EpgIdCache) -> Option<Vec<Epg>> {
|
||||
if id_cache.channel_epg_id.is_empty() && id_cache.normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut epg_sources: Vec<Epg> = self.get_epg_sources().iter()
|
||||
.filter_map(|epg_source| Self::process_epg_file(id_cache, epg_source))
|
||||
.collect();
|
||||
epg_sources.sort_by(|a, b| a.priority.cmp(&b.priority));
|
||||
Some(epg_sources)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn handle_tag_start<F>(callback: &mut F, stack: &mut Vec<XmlTag>, e: &BytesStart)
|
||||
where
|
||||
F: FnMut(XmlTag),
|
||||
{
|
||||
let name = String::from_utf8_lossy(e.name().as_ref()).as_ref().to_owned();
|
||||
let (is_tv_tag, is_channel, is_program) = get_tag_types(&name);
|
||||
let attributes = collect_tag_attributes(e, is_channel, is_program);
|
||||
let attribs = if attributes.is_empty() { None } else { Some(attributes) };
|
||||
let tag = XmlTag::new(name, attribs);
|
||||
|
||||
if is_tv_tag {
|
||||
callback(tag);
|
||||
} else {
|
||||
stack.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn handle_tag_end<F>(callback: &mut F, stack: &mut Vec<XmlTag>)
|
||||
where
|
||||
F: FnMut(XmlTag),
|
||||
{
|
||||
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| {
|
||||
r.children = Some(match r.children.take() {
|
||||
None => vec![tag],
|
||||
Some(mut tags) => {
|
||||
tags.push(tag);
|
||||
tags
|
||||
}
|
||||
});
|
||||
r
|
||||
}) {
|
||||
stack.push(old_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn handle_text_tag(stack: &mut [XmlTag], e: &BytesText) {
|
||||
if !stack.is_empty() {
|
||||
if let Ok(text) = e.unescape() {
|
||||
let t = text.trim();
|
||||
if !t.is_empty() {
|
||||
stack.last_mut().unwrap().value = Some(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)) => handle_tag_start(callback, &mut stack, &e),
|
||||
Ok(Event::Empty(e)) => {
|
||||
handle_tag_start(callback, &mut stack, &e);
|
||||
handle_tag_end(callback, &mut stack);
|
||||
},
|
||||
Ok(Event::End(_e)) => handle_tag_end(callback, &mut stack),
|
||||
Ok(Event::Text(e)) => handle_text_tag(&mut stack, &e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tag_types(name: &str) -> (bool, bool, bool) {
|
||||
let (is_tv_tag, is_channel, is_program) = match name {
|
||||
EPG_TAG_TV => (true, false, false),
|
||||
EPG_TAG_CHANNEL => (false, true, false),
|
||||
EPG_TAG_PROGRAMME => (false, false, true),
|
||||
_ => (false, false, false)
|
||||
};
|
||||
(is_tv_tag, is_channel, is_program)
|
||||
}
|
||||
|
||||
fn collect_tag_attributes(e: &BytesStart, is_channel: bool, is_program: bool) -> HashMap<String, String> {
|
||||
let attributes = e.attributes().filter_map(Result::ok)
|
||||
.filter_map(|a| {
|
||||
let key = String::from_utf8_lossy(a.key.as_ref()).to_string();
|
||||
let mut value = String::from(a.unescape_value().unwrap().as_ref());
|
||||
if (is_channel && key == EPG_ATTRIB_ID) || (is_program && key == EPG_ATTRIB_CHANNEL) {
|
||||
value = value.to_lowercase().to_string();
|
||||
}
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key, value))
|
||||
}
|
||||
}).collect::<HashMap<String, String>>();
|
||||
attributes
|
||||
}
|
||||
|
||||
pub fn flatten_tvguide(tv_guides: &[Epg]) -> Option<Epg> {
|
||||
if tv_guides.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let epg_children = Mutex::new(Vec::new());
|
||||
let epg_attributes = tv_guides.first().and_then(|t| t.attributes.clone());
|
||||
let count = tv_guides.iter().map(|tvg| tvg.children.len()).sum();
|
||||
let channel_mapping: RwLock<HashMap<String, i16>> = RwLock::new(HashMap::with_capacity(count));
|
||||
|
||||
let mut sorted_guides = tv_guides.to_vec();
|
||||
// sort by priority
|
||||
sorted_guides.sort_by(|a, b| a.priority.cmp(&b.priority));
|
||||
// if executed parallel it does not matter how we sort.
|
||||
sorted_guides.par_iter().for_each(|guide| {
|
||||
let mut children = vec![];
|
||||
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) {
|
||||
let should_add = {
|
||||
let channel_map = channel_mapping.read().unwrap();
|
||||
// if not stored
|
||||
!channel_map.contains_key(chan_id) ||
|
||||
// or if priority is higher (less means higher priority)
|
||||
channel_map.get(chan_id).is_none_or(|&priority| guide.priority < priority)
|
||||
};
|
||||
if should_add {
|
||||
let mut channel_map = channel_mapping.write().unwrap();
|
||||
match channel_map.entry(chan_id.to_string()) {
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(guide.priority);
|
||||
children.push(c.clone());
|
||||
}
|
||||
Entry::Occupied(mut e) if guide.priority < *e.get() => {
|
||||
e.insert(guide.priority);
|
||||
children.push(c.clone());
|
||||
}
|
||||
Entry::Occupied(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
guide.children.iter().for_each(|c| {
|
||||
if c.name.as_str() == EPG_TAG_PROGRAMME {
|
||||
if let Some(chan_id) = c.get_attribute_value(EPG_ATTRIB_CHANNEL) {
|
||||
let channel_map = channel_mapping.read().unwrap();
|
||||
if let Some(&stored_priority) = channel_map.get(chan_id) {
|
||||
if stored_priority == guide.priority {
|
||||
children.push(c.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
epg_children.lock().unwrap().extend(children);
|
||||
});
|
||||
let epg = Epg {
|
||||
logo_override: false,
|
||||
priority: 0,
|
||||
attributes: epg_attributes,
|
||||
children: mem::take(&mut *epg_children.lock().unwrap()),
|
||||
};
|
||||
Some(epg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::model::{EpgNamePrefix, EpgSmartMatchConfig};
|
||||
use crate::processing::parser::xmltv::normalize_channel_name;
|
||||
|
||||
#[test]
|
||||
/// Tests normalization of a channel name using the default smart match configuration.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// parse_normalize().unwrap();
|
||||
/// ```
|
||||
fn parse_normalize() -> Result<(), TuliproxError> {
|
||||
let epg_normalize = EpgSmartMatchConfig::new()?;
|
||||
let normalized = normalize_channel_name("Love Nature", &epg_normalize);
|
||||
assert_eq!(normalized, "lovenature".to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// #[test]
|
||||
// fn parse_test() -> io::Result<()> {
|
||||
// let file_path = PathBuf::from("/tmp/epg.xml.gz");
|
||||
//
|
||||
// if file_path.exists() {
|
||||
// let tv_guide = TVGuide { file: file_path };
|
||||
//
|
||||
// let mut channel_ids = HashSet::from(["channel.1".to_string(), "channel.2".to_string(), "channel.3".to_string()]);
|
||||
// let mut nomalized = HashMap::new();
|
||||
// match tv_guide.filter(&mut channel_ids, &mut nomalized) {
|
||||
// None => assert!(false, "No epg filtered"),
|
||||
// Some(epg) => {
|
||||
// assert_eq!(epg.children.len(), channel_ids.len() * 2, "Epg size does not match")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
#[test]
|
||||
/// Tests normalization of channel names with various prefixes, suffixes, and special characters using a configured `EpgSmartMatchConfig`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// normalize();
|
||||
/// // This will assert that various channel names are normalized as expected.
|
||||
/// ```
|
||||
fn normalize() {
|
||||
let mut epg_smart_cfg = EpgSmartMatchConfig { enabled: true, name_prefix: EpgNamePrefix::Suffix(".".to_string()), ..Default::default() };
|
||||
let _ = epg_smart_cfg.prepare();
|
||||
println!("{epg_smart_cfg:?}");
|
||||
assert_eq!("supersport6.ru", normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_smart_cfg));
|
||||
assert_eq!("odisea.sat", normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_smart_cfg));
|
||||
assert_eq!("odisea.4k", normalize_channel_name("4K: ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
|
||||
assert_eq!("odisea", normalize_channel_name("ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
|
||||
assert_eq!("odisea.bu", normalize_channel_name("BU | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
|
||||
assert_eq!("odisea.bg", normalize_channel_name("BG | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
|
||||
}
|
||||
|
||||
use crate::tuliprox_error::TuliproxError;
|
||||
use rphonetic::{Encoder, Metaphone};
|
||||
|
||||
#[test]
|
||||
/// Demonstrates phonetic encoding (Metaphone) of normalized channel names with various prefixes and suffixes.
|
||||
///
|
||||
/// This test prints the Metaphone-encoded representations of several normalized channel names using a configured `EpgSmartMatchConfig`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// test_metaphone();
|
||||
/// // Output will show the Metaphone encodings for different channel name variants.
|
||||
/// ```
|
||||
fn test_metaphone() {
|
||||
let metaphone = Metaphone::default();
|
||||
let mut epg_smart_cfg = EpgSmartMatchConfig { enabled: true, name_prefix: EpgNamePrefix::Suffix(".".to_string()), ..Default::default() };
|
||||
let _ = epg_smart_cfg.prepare();
|
||||
println!("{epg_smart_cfg:?}");
|
||||
// assert_eq!("supersport6.ru", metaphone.encode(&normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_normalize_cfg)));
|
||||
// assert_eq!("odisea.sat", metaphone.encode(&normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_normalize_cfg)));
|
||||
// assert_eq!("odisea", metaphone.encode(&normalize_channel_name("4K: ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
|
||||
// assert_eq!("odisea", metaphone.encode(&normalize_channel_name("ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
|
||||
// assert_eq!("odisea.bu", metaphone.encode(&normalize_channel_name("BU | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
|
||||
// assert_eq!("odisea.bg", metaphone.encode(&normalize_channel_name("BG | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
|
||||
|
||||
println!("{}", metaphone.encode(&normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_smart_cfg)));
|
||||
println!("{}", metaphone.encode(&normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_smart_cfg)));
|
||||
println!("{}", metaphone.encode(&normalize_channel_name("4K: ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
|
||||
println!("{}", metaphone.encode(&normalize_channel_name("ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
|
||||
println!("{}", metaphone.encode(&normalize_channel_name("BU | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
|
||||
println!("{}", metaphone.encode(&normalize_channel_name("BG | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use crate::tuliprox_error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind};
|
||||
use crate::model::ConfigInput;
|
||||
use crate::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader};
|
||||
use crate::model::{XtreamCategory, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamStream};
|
||||
use crate::utils::generate_playlist_uuid;
|
||||
use crate::utils::xtream::{get_xtream_stream_url_base};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use shared::model::{PlaylistItemType, XtreamCluster};
|
||||
|
||||
fn map_to_xtream_category(categories: &Value) -> Result<Vec<XtreamCategory>, TuliproxError> {
|
||||
match serde_json::from_value::<Vec<XtreamCategory>>(categories.to_owned()) {
|
||||
Ok(xtream_categories) => Ok(xtream_categories),
|
||||
Err(err) => {
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to process categories {}", &err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: &Value) -> Result<Vec<XtreamStream>, TuliproxError> {
|
||||
match serde_json::from_value::<Vec<XtreamStream>>(streams.to_owned()) {
|
||||
Ok(stream_list) => Ok(stream_list),
|
||||
Err(err) => {
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to map to xtream streams {:?}: {}", xtream_cluster, &err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &XtreamSeriesInfoEpisode) -> Arc<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);
|
||||
Arc::new(stream_base_url)
|
||||
} else {
|
||||
Arc::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)>>, TuliproxError> {
|
||||
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: PlaylistItemHeader {
|
||||
id: episode.id.to_string(),
|
||||
uuid: generate_playlist_uuid(&input.name, &episode.id, PlaylistItemType::Series, &episode_url),
|
||||
name: series_name.to_string(),
|
||||
logo: episode.info.as_ref().map_or_else(String::new, |info| info.movie_image.to_string()),
|
||||
group: group_title.to_string(),
|
||||
title: episode.title.clone(),
|
||||
url: episode_url.to_string(),
|
||||
item_type: PlaylistItemType::Series,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
additional_properties: episode.get_additional_properties(&series_info),
|
||||
category_id: 0,
|
||||
input_name: input.name.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
}).collect();
|
||||
return if result.is_empty() { Ok(None) } else { Ok(Some(result)) };
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to process series info for {series_name} {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn get_xtream_url(xtream_cluster: XtreamCluster, url: &str,
|
||||
username: &str, password: &str,
|
||||
stream_id: u32, container_extension: Option<&String>,
|
||||
live_stream_use_prefix: bool, live_stream_without_extension: bool) -> String {
|
||||
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}/{stream_id}{suffix}")
|
||||
}
|
||||
XtreamCluster::Video => {
|
||||
let ext = container_extension.as_ref().map_or("mp4", |e| e.as_str());
|
||||
format!("{url}/movie/{username}/{password}/{stream_id}.{ext}")
|
||||
}
|
||||
XtreamCluster::Series =>
|
||||
format!("{}&action={}&series_id={stream_id}", get_xtream_stream_url_base(url, username, password), crate::model::XC_ACTION_GET_SERIES_INFO)
|
||||
};
|
||||
stream_base_url
|
||||
}
|
||||
|
||||
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) -> String {
|
||||
if stream.direct_source.is_empty() {
|
||||
get_xtream_url(xtream_cluster, url, username, password, stream.get_stream_id(),
|
||||
stream.container_extension.as_ref().map(std::string::ToString::to_string).as_ref(),
|
||||
live_stream_use_prefix, live_stream_without_extension)
|
||||
} else {
|
||||
stream.direct_source.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_xtream(input: &ConfigInput,
|
||||
xtream_cluster: XtreamCluster,
|
||||
categories: &Value,
|
||||
streams: &Value) -> Result<Option<Vec<PlaylistGroup>>, TuliproxError> {
|
||||
match map_to_xtream_category(categories) {
|
||||
Ok(xtream_categories) => {
|
||||
let input_name = Arc::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(mut xtream_streams) => {
|
||||
let mut group_map: HashMap<String, XtreamCategory> =
|
||||
xtream_categories.into_iter().map(|category|
|
||||
(category.category_id.to_string(), category)
|
||||
).collect();
|
||||
let mut unknown_grp = XtreamCategory {
|
||||
category_id: "0".to_string(),
|
||||
category_name: "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 &mut xtream_streams {
|
||||
let group = group_map.get_mut(&stream.category_id).unwrap_or(&mut unknown_grp);
|
||||
let category_name = &group.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);
|
||||
// EPG Channel id fix, remove empty
|
||||
stream.epg_channel_id = if let XtreamCluster::Live = xtream_cluster {
|
||||
stream.epg_channel_id.as_ref()
|
||||
.filter(|epg_id| !epg_id.trim().is_empty())
|
||||
.map(|epg_id| epg_id.to_lowercase())
|
||||
.or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let item = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: stream.get_stream_id().to_string(),
|
||||
uuid: generate_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type, &stream_url),
|
||||
name: stream.name.to_string(),
|
||||
logo: stream.stream_icon.to_string(),
|
||||
group: category_name.to_string(),
|
||||
title: stream.name.to_string(),
|
||||
url: stream_url.to_string(),
|
||||
epg_channel_id: stream.epg_channel_id.clone(),
|
||||
item_type,
|
||||
xtream_cluster,
|
||||
additional_properties: stream.get_additional_properties(),
|
||||
category_id: 0,
|
||||
input_name: input_name.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
group.add(item);
|
||||
}
|
||||
let has_channels = !unknown_grp.channels.is_empty();
|
||||
if has_channels {
|
||||
group_map.insert("0".to_string(), unknown_grp);
|
||||
}
|
||||
|
||||
Ok(Some(group_map.values().filter(|category| !category.channels.is_empty())
|
||||
.map(|category| {
|
||||
PlaylistGroup {
|
||||
id: category.category_id.parse::<u32>().unwrap_or(0),
|
||||
xtream_cluster,
|
||||
title: category.category_name.to_string(),
|
||||
channels: category.channels.clone(),
|
||||
}
|
||||
}).collect()))
|
||||
}
|
||||
Err(err) => Err(err)
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user