Files
tuliprox/shared/src/model/playlist.rs
T
2025-12-13 20:28:21 +01:00

777 lines
27 KiB
Rust

use crate::model::{xtream_const, CommonPlaylistItem, ConfigTargetOptions};
use crate::utils::{extract_extension_from_url, generate_playlist_uuid, get_provider_id, get_string_from_serde_value, get_u32_from_serde_value, get_u64_from_serde_value};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::borrow::Cow;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use serde_json::value::RawValue;
// https://de.wikipedia.org/wiki/M3U
// https://siptv.eu/howto/playlist.html
pub type UUIDType = [u8; 32];
pub type VirtualId = u32;
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum XtreamCluster {
#[default]
Live = 1,
Video = 2,
Series = 3,
}
impl XtreamCluster {
pub const fn as_str(&self) -> &str {
match self {
Self::Live => "Live",
Self::Video => "Video",
Self::Series => "Series",
}
}
pub const fn as_stream_type(&self) -> &str {
match self {
Self::Live => "live",
Self::Video => "movie",
Self::Series => "series",
}
}
}
impl FromStr for XtreamCluster {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"live" => Ok(XtreamCluster::Live),
"video" | "vod" | "movie" => Ok(XtreamCluster::Video),
"series" => Ok(XtreamCluster::Series),
_ => Err(format!("Invalid XtreamCluster: {s}")),
}
}
}
impl Display for XtreamCluster {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<PlaylistItemType> for XtreamCluster {
type Error = String;
fn try_from(item_type: PlaylistItemType) -> Result<Self, Self::Error> {
match item_type {
PlaylistItemType::Live | PlaylistItemType::LiveHls | PlaylistItemType::LiveDash | PlaylistItemType::LiveUnknown => Ok(Self::Live),
PlaylistItemType::Catchup | PlaylistItemType::Video => Ok(Self::Video),
PlaylistItemType::Series | PlaylistItemType::SeriesInfo => Ok(Self::Series),
}
}
}
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum PlaylistItemType {
#[default]
Live = 1,
Video = 2,
Series = 3, // xtream series description
SeriesInfo = 4, // xtream series info fetched for series description
Catchup = 5,
LiveUnknown = 6, // No Provider id
LiveHls = 7, // m3u8 entry
LiveDash = 8, // mpd
}
impl From<XtreamCluster> for PlaylistItemType {
fn from(xtream_cluster: XtreamCluster) -> Self {
match xtream_cluster {
XtreamCluster::Live => Self::Live,
XtreamCluster::Video => Self::Video,
XtreamCluster::Series => Self::SeriesInfo,
}
}
}
impl FromStr for PlaylistItemType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Live" => Ok(PlaylistItemType::Live),
"Video" => Ok(PlaylistItemType::Video),
"Series" => Ok(PlaylistItemType::Series),
"SeriesInfo" => Ok(PlaylistItemType::SeriesInfo),
"Catchup" => Ok(PlaylistItemType::Catchup),
"LiveUnknown" => Ok(PlaylistItemType::LiveUnknown),
"LiveHls" => Ok(PlaylistItemType::LiveHls),
"LiveDash" => Ok(PlaylistItemType::LiveDash),
_ => Err(format!("Invalid PlaylistItemType: {s}")),
}
}
}
impl PlaylistItemType {
const LIVE: &'static str = "live";
const VIDEO: &'static str = "video";
const SERIES: &'static str = "series";
const SERIES_INFO: &'static str = "series-info";
const CATCHUP: &'static str = "catchup";
}
impl Display for PlaylistItemType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self {
Self::Live | Self::LiveHls | Self::LiveDash | Self::LiveUnknown => Self::LIVE,
Self::Video => Self::VIDEO,
Self::Series => Self::SERIES,
Self::SeriesInfo => Self::SERIES_INFO,
Self::Catchup => Self::CATCHUP,
})
}
}
pub trait FieldGetAccessor {
fn get_field(&self, field: &str) -> Option<Cow<'_, str>>;
}
pub trait FieldSetAccessor {
fn set_field(&mut self, field: &str, value: &str) -> bool;
}
pub trait PlaylistEntry: Send + Sync {
fn get_virtual_id(&self) -> VirtualId;
fn get_provider_id(&self) -> Option<u32>;
fn get_category_id(&self) -> Option<u32>;
fn get_provider_url(&self) -> String;
fn get_uuid(&self) -> UUIDType;
fn get_item_type(&self) -> PlaylistItemType;
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlaylistItemHeader {
#[serde(skip)]
pub uuid: UUIDType, // calculated
pub id: String, // provider id
pub virtual_id: VirtualId, // virtual id
pub name: String,
pub chno: String,
pub logo: String,
pub logo_small: String,
pub group: String,
pub title: String,
pub parent_code: String,
pub audio_track: String,
pub time_shift: String,
pub rec: String,
pub url: String,
pub epg_channel_id: Option<String>,
pub xtream_cluster: XtreamCluster,
pub additional_properties: Option<Box<RawValue>>,
#[serde(default)]
pub item_type: PlaylistItemType,
#[serde(default)]
pub category_id: u32,
pub input_name: String,
}
impl PlaylistItemHeader {
pub fn gen_uuid(&mut self) {
self.uuid = generate_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url);
}
pub const fn get_uuid(&self) -> &UUIDType {
&self.uuid
}
pub fn get_provider_id(&mut self) -> Option<u32> {
match get_provider_id(&self.id, &self.url) {
None => None,
Some(newid) => {
self.id = newid.to_string();
Some(newid)
}
}
}
pub fn get_additional_property(&self, field: &str) -> Option<Value> {
let raw = self.additional_properties.as_ref()?;
let value: Value = serde_json::from_str(raw.get()).ok()?;
match value {
Value::Object(map) => map.get(field).cloned(),
_ => None,
}
}
pub fn get_additional_property_as_u32(&self, field: &str) -> Option<u32> {
match self.get_additional_property(field) {
Some(value) => get_u32_from_serde_value(&value),
None => None
}
}
pub fn get_additional_property_as_u64(&self, field: &str) -> Option<u64> {
match self.get_additional_property(field) {
Some(value) => get_u64_from_serde_value(&value),
None => None
}
}
pub fn get_additional_property_as_str(&self, field: &str) -> Option<String> {
match self.get_additional_property(field) {
Some(value) => get_string_from_serde_value(&value),
None => None
}
}
}
macro_rules! to_m3u_non_empty_fields {
($header:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => {
$(
if !$header.$prop.is_empty() {
$line = format!("{} {}=\"{}\"", $line, $field, $header.$prop);
}
)*
};
}
macro_rules! to_m3u_resource_non_empty_fields {
($header:expr, $url:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => {
$(
if !$header.$prop.is_empty() {
$line = format!("{} {}=\"{}/{}\"", $line, $field, $url, stringify!($prop));
}
)*
};
}
macro_rules! generate_field_accessor_impl_for_playlist_item_header {
($($prop:ident),*;) => {
impl crate::model::FieldGetAccessor for crate::model::PlaylistItemHeader {
fn get_field(&self, field: &str) -> Option<Cow<'_, str>> {
let field = field.to_lowercase();
match field.as_str() {
$(
stringify!($prop) => Some(Cow::Borrowed(&self.$prop)),
)*
"input" => Some(Cow::Borrowed(self.input_name.as_str())),
"type" => Some(Cow::Owned(self.item_type.to_string())),
"caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }),
"epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())),
_ => None,
}
}
}
impl crate::model::FieldSetAccessor for crate::model::PlaylistItemHeader {
fn set_field(&mut self, field: &str, value: &str) -> bool {
let field = field.to_lowercase();
let val = String::from(value);
match field.as_str() {
$(
stringify!($prop) => {
self.$prop = val;
true
}
)*
"caption" => {
self.title = val.clone();
self.name = val;
true
}
"epg_channel_id" | "epg_id" => {
self.epg_channel_id = Some(value.to_owned());
true
}
_ => false,
}
}
}
}
}
generate_field_accessor_impl_for_playlist_item_header!(id, /*virtual_id,*/ name, chno, logo, logo_small, group, title, parent_code, audio_track, time_shift, rec, url;);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct M3uPlaylistItem {
pub virtual_id: VirtualId,
pub provider_id: String,
pub name: String,
pub chno: String,
pub logo: String,
pub logo_small: String,
pub group: String,
pub title: String,
pub parent_code: String,
pub audio_track: String,
pub time_shift: String,
pub rec: String,
pub url: String,
pub epg_channel_id: Option<String>,
pub input_name: String,
pub item_type: PlaylistItemType,
#[serde(skip)]
pub t_stream_url: String,
#[serde(skip)]
pub t_resource_url: Option<String>,
}
impl M3uPlaylistItem {
#[allow(clippy::missing_panics_doc)]
pub fn to_m3u(&self, target_options: Option<&ConfigTargetOptions>, rewrite_urls: bool) -> String {
let options = target_options.as_ref();
let ignore_logo = options.is_some_and(|o| o.ignore_logo);
let mut line = format!("#EXTINF:-1 tvg-id=\"{}\" tvg-name=\"{}\" group-title=\"{}\"",
self.epg_channel_id.as_ref().map_or("", |o| o.as_ref()),
self.name, self.group);
if !ignore_logo {
if let (true, Some(resource_url)) = (rewrite_urls, self.t_resource_url.as_ref()) {
to_m3u_resource_non_empty_fields!(self, resource_url, line, (logo, "tvg-logo"), (logo_small, "tvg-logo-small"););
} else {
to_m3u_non_empty_fields!(self, line, (logo, "tvg-logo"), (logo_small, "tvg-logo-small"););
}
}
to_m3u_non_empty_fields!(self, line,
(chno, "tvg-chno"),
(parent_code, "parent-code"),
(audio_track, "audio-track"),
(time_shift, "timeshift"),
(rec, "tvg-rec"););
let url = if self.t_stream_url.is_empty() { &self.url } else { &self.t_stream_url };
format!("{line},{}\n{url}", self.title, )
}
pub fn to_common(&self) -> CommonPlaylistItem {
CommonPlaylistItem {
virtual_id: self.virtual_id,
provider_id: self.provider_id.to_string(),
name: self.name.clone(),
chno: self.chno.to_string(),
logo: self.logo.clone(),
logo_small: self.logo_small.clone(),
group: self.group.clone(),
title: self.title.clone(),
parent_code: self.parent_code.clone(),
audio_track: self.audio_track.to_string(),
time_shift: self.time_shift.to_string(),
rec: self.rec.clone(),
url: self.url.clone(),
input_name: self.input_name.clone(),
item_type: self.item_type,
epg_channel_id: self.epg_channel_id.clone(),
xtream_cluster: XtreamCluster::try_from(self.item_type).ok(),
additional_properties: None,
category_id: None,
}
}
}
impl PlaylistEntry for M3uPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> VirtualId {
self.virtual_id
}
fn get_provider_id(&self) -> Option<u32> {
get_provider_id(&self.provider_id, &self.url)
}
#[inline]
fn get_category_id(&self) -> Option<u32> {
None
}
#[inline]
fn get_provider_url(&self) -> String {
self.url.to_string()
}
fn get_uuid(&self) -> UUIDType {
generate_playlist_uuid(&self.input_name, &self.provider_id, self.item_type, &self.url)
}
#[inline]
fn get_item_type(&self) -> PlaylistItemType {
self.item_type
}
}
macro_rules! generate_field_accessor_impl_for_m3u_playlist_item {
($($prop:ident),*;) => {
impl crate::model::FieldGetAccessor for M3uPlaylistItem {
fn get_field(&self, field: &str) -> Option<Cow<'_, str>> {
let field = field.to_lowercase();
match field.as_str() {
$(
stringify!($prop) => Some(Cow::Borrowed(&self.$prop)),
)*
"caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }),
"epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())),
_ => None,
}
}
}
}
}
generate_field_accessor_impl_for_m3u_playlist_item!(provider_id, name, chno, logo, logo_small, group, title, parent_code, audio_track, time_shift, rec, url;);
impl From<M3uPlaylistItem> for CommonPlaylistItem {
fn from(item: M3uPlaylistItem) -> Self {
item.to_common()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XtreamPlaylistItem {
pub virtual_id: VirtualId,
pub provider_id: u32,
pub name: String,
pub logo: String,
pub logo_small: String,
pub group: String,
pub title: String,
pub parent_code: String,
pub rec: String,
pub url: String,
pub epg_channel_id: Option<String>,
pub xtream_cluster: XtreamCluster,
pub additional_properties: Option<String>,
pub item_type: PlaylistItemType,
pub category_id: u32,
pub input_name: String,
pub channel_no: u32,
}
impl XtreamPlaylistItem {
pub fn get_additional_property(&self, field: &str) -> Option<Value> {
if let Some(json) = self.additional_properties.as_ref() {
if let Ok(Value::Object(props)) = serde_json::from_str(json) {
return props.get(field).cloned();
}
}
None
}
pub fn to_common(&self) -> CommonPlaylistItem {
CommonPlaylistItem {
virtual_id: self.virtual_id,
provider_id: self.provider_id.to_string(),
name: self.name.clone(),
chno: self.channel_no.to_string(),
logo: self.logo.clone(),
logo_small: self.logo_small.clone(),
group: self.group.clone(),
title: self.title.clone(),
parent_code: self.parent_code.clone(),
audio_track: "".to_string(),
time_shift: "".to_string(),
rec: self.rec.clone(),
url: self.url.clone(),
input_name: self.input_name.clone(),
item_type: self.item_type,
epg_channel_id: self.epg_channel_id.clone(),
xtream_cluster: Some(self.xtream_cluster),
additional_properties: self.additional_properties.clone(),
category_id: Some(self.category_id),
}
}
}
impl PlaylistEntry for XtreamPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> VirtualId {
self.virtual_id
}
#[inline]
fn get_provider_id(&self) -> Option<u32> {
Some(self.provider_id)
}
#[inline]
fn get_category_id(&self) -> Option<u32> {
None
}
#[inline]
fn get_provider_url(&self) -> String {
self.url.to_string()
}
#[inline]
fn get_uuid(&self) -> UUIDType {
generate_playlist_uuid(&self.input_name, &self.provider_id.to_string(), self.item_type, &self.url)
}
#[inline]
fn get_item_type(&self) -> PlaylistItemType {
self.item_type
}
}
pub fn get_backdrop_path_value<'a>(field: &'a str, value: Option<&'a Value>) -> Option<Cow<'a, str>> {
match value {
Some(Value::String(url)) => Some(Cow::Borrowed(url)),
Some(Value::Array(values)) => {
match values.as_slice() {
[Value::String(single)] => Some(Cow::Borrowed(single)),
multiple if !multiple.is_empty() => {
if let Some(index) = field.rfind('_') {
if let Ok(bd_index) = field[index + 1..].parse::<usize>() {
if let Some(Value::String(selected)) = multiple.get(bd_index) {
return Some(Cow::Borrowed(selected));
}
}
}
if let Value::String(url) = &multiple[0] {
Some(Cow::Borrowed(url))
} else {
None
}
}
_ => None,
}
}
_ => None,
}
}
macro_rules! generate_field_accessor_impl_for_xtream_playlist_item {
($($prop:ident),*;) => {
impl crate::model::FieldGetAccessor for crate::model::XtreamPlaylistItem {
fn get_field(&self, field: &str) -> Option<Cow<'_, str>> {
let field = field.to_lowercase();
match field.as_str() {
$(
stringify!($prop) => Some(Cow::Borrowed(&self.$prop)),
)*
"caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }),
"epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())),
_ => {
if field.starts_with(xtream_const::XC_PROP_BACKDROP_PATH) || field == xtream_const::XC_PROP_COVER {
let props = self.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::<Map<String, Value>>(add_props).ok());
return match props {
Some(doc) => {
return if field == xtream_const::XC_PROP_COVER {
doc.get(&field).and_then(|value| value.as_str()).map(|s| Cow::<str>::Owned(s.to_owned()))
} else {
get_backdrop_path_value(&field, doc.get(xtream_const::XC_PROP_BACKDROP_PATH)).map(|s| Cow::<str>::Owned(s.to_string()))
}
}
_=> None,
}
}
None
},
}
}
}
}
}
impl From<XtreamPlaylistItem> for CommonPlaylistItem {
fn from(item: XtreamPlaylistItem) -> Self {
item.to_common()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistItem {
#[serde(flatten)]
pub header: PlaylistItemHeader,
}
generate_field_accessor_impl_for_xtream_playlist_item!(name, logo, logo_small, group, title, parent_code, rec, url;);
impl PlaylistItem {
pub fn to_m3u(&self) -> M3uPlaylistItem {
let header = &self.header;
M3uPlaylistItem {
virtual_id: header.virtual_id,
provider_id: header.id.to_string(),
name: if header.item_type == PlaylistItemType::Series { &header.title } else { &header.name }.to_string(),
chno: header.chno.to_string(),
logo: header.logo.to_string(),
logo_small: header.logo_small.to_string(),
group: header.group.to_string(),
title: header.title.to_string(),
parent_code: header.parent_code.to_string(),
audio_track: header.audio_track.to_string(),
time_shift: header.time_shift.to_string(),
rec: header.rec.to_string(),
url: header.url.to_string(),
epg_channel_id: header.epg_channel_id.clone(),
input_name: header.input_name.to_string(),
item_type: header.item_type,
t_stream_url: header.url.to_string(),
t_resource_url: None,
}
}
pub fn to_xtream(&self) -> XtreamPlaylistItem {
let header = &self.header;
let provider_id = header.id.parse::<u32>().unwrap_or_default();
let mut additional_properties: Option<String> = None;
if header.xtream_cluster != XtreamCluster::Live {
let add_ext = match header.get_additional_property("container_extension") {
None => true,
Some(ext) => ext.as_str().is_none_or(str::is_empty),
};
if add_ext {
if let Some(cont_ext) = extract_extension_from_url(&header.url) {
let ext = cont_ext.strip_prefix('.').unwrap_or(cont_ext);
// parse RawValue into Value on-demand
let mut result: Map<String, Value> = match header.additional_properties.as_ref() {
None => Map::new(),
Some(props) => {
serde_json::from_str(props.get()).unwrap_or_else(|_| Map::new())
}
};
result.insert("container_extension".to_string(), Value::String(ext.to_string()));
additional_properties = serde_json::to_string(&Value::Object(result)).ok();
}
}
}
if additional_properties.is_none() {
additional_properties = header.additional_properties.as_ref().and_then(|props| {
serde_json::to_string(props.get()).ok()
});
}
XtreamPlaylistItem {
virtual_id: header.virtual_id,
provider_id,
name: if header.item_type == PlaylistItemType::Series { &header.title } else { &header.name }.to_string(),
logo: header.logo.to_string(),
logo_small: header.logo_small.to_string(),
group: header.group.to_string(),
title: header.title.to_string(),
parent_code: header.parent_code.to_string(),
rec: header.rec.to_string(),
url: header.url.to_string(),
epg_channel_id: header.epg_channel_id.clone(),
xtream_cluster: header.xtream_cluster,
additional_properties,
item_type: header.item_type,
category_id: header.category_id,
input_name: header.input_name.to_string(),
channel_no: header.chno.parse::<u32>().unwrap_or(0),
}
}
pub fn to_common(&self) -> CommonPlaylistItem {
let header = &self.header;
let mut additional_properties: Option<String> = None;
if header.xtream_cluster != XtreamCluster::Live {
let add_ext = match header.get_additional_property("container_extension") {
None => true,
Some(ext) => ext.as_str().is_none_or(str::is_empty),
};
if add_ext {
if let Some(cont_ext) = extract_extension_from_url(&header.url) {
let ext = cont_ext.strip_prefix('.').unwrap_or(cont_ext);
// Parse RawValue on-demand
let mut result: Map<String, Value> = match header.additional_properties.as_ref() {
None => Map::new(),
Some(props) => {
serde_json::from_str(props.get()).unwrap_or_else(|_| Map::new())
}
};
result.insert("container_extension".to_string(), Value::String(ext.to_string()));
additional_properties = serde_json::to_string(&Value::Object(result)).ok();
}
}
}
if additional_properties.is_none() {
additional_properties = header.additional_properties.as_ref().and_then(|props| {
serde_json::to_string(props.get()).ok()
});
}
CommonPlaylistItem {
virtual_id: header.virtual_id,
provider_id: header.id.clone(),
name: if header.item_type == PlaylistItemType::Series { &header.title } else { &header.name }.clone(),
logo: header.logo.clone(),
logo_small: header.logo_small.clone(),
group: header.group.clone(),
title: header.title.clone(),
parent_code: header.parent_code.clone(),
audio_track: header.audio_track.clone(),
time_shift: header.time_shift.clone(),
rec: header.rec.clone(),
url: header.url.clone(),
epg_channel_id: header.epg_channel_id.clone(),
xtream_cluster: Some(header.xtream_cluster),
additional_properties,
item_type: header.item_type,
category_id: Some(header.category_id),
input_name: header.input_name.clone(),
chno: header.chno.clone(),
}
}
}
impl PlaylistEntry for PlaylistItem {
#[inline]
fn get_virtual_id(&self) -> VirtualId {
self.header.virtual_id
}
fn get_provider_id(&self) -> Option<u32> {
let header = &self.header;
get_provider_id(&header.id, &header.url)
}
#[inline]
fn get_category_id(&self) -> Option<u32> {
None
}
#[inline]
fn get_provider_url(&self) -> String {
self.header.url.to_string()
}
#[inline]
fn get_uuid(&self) -> UUIDType {
let header = &self.header;
generate_playlist_uuid(&header.input_name, &header.id, header.item_type, &header.url)
}
#[inline]
fn get_item_type(&self) -> PlaylistItemType {
self.header.item_type
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistGroup {
pub id: u32,
pub title: String,
pub channels: Vec<PlaylistItem>,
#[serde(skip)]
pub xtream_cluster: XtreamCluster,
}
impl PlaylistGroup {
#[inline]
pub fn on_load(&mut self) {
for pl in &mut self.channels {
pl.header.gen_uuid();
}
}
#[inline]
pub fn filter_count<F>(&self, filter: F) -> usize
where
F: Fn(&PlaylistItem) -> bool,
{
self.channels.iter().filter(|&c| filter(c)).count()
}
}