Files
tuliprox/shared/src/model/playlist.rs
T
euzu 2fa7a01c53 To align input definitions with the SourceEditor, inputs are now defined globally in the inputs section of the config file.
Each source can reference one or more inputs by their name in the inputs attribute.
2026-01-03 19:43:43 +01:00

1463 lines
61 KiB
Rust

use crate::model::info_doc_utils::InfoDocUtils;
use crate::model::{xtream_const, ClusterFlags, CommonPlaylistItem, ConfigTargetOptions, EpisodeStreamProperties, LiveStreamProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties};
use crate::utils::{extract_extension_from_url, generate_playlist_uuid, get_provider_id, parse_uuid_hex};
use enum_iterator::Sequence;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::borrow::Cow;
use std::fmt::Write;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use hex::FromHex;
// https://de.wikipedia.org/wiki/M3U
// https://siptv.eu/howto/playlist.html
pub type VirtualId = u32;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct UUIDType(pub [u8; 32]);
impl UUIDType {
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Converts the first 16 bytes of this `UUIDType` into a valid UUID v4 string.
///
/// Note:
/// - Only the first 16 bytes are used, because a standard UUID is 16 bytes.
/// - The remaining 16 bytes of the 32-byte `UUIDType` are ignored in this operation.
/// - This conversion is **not reversible**, calling `from_valid_uuid` on the resulting string
/// will not recover the original 32-byte `UUIDType`.
pub fn to_valid_uuid(&self) -> String {
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&self.0[0..16]);
// Set UUID version (v4)
bytes[6] = (bytes[6] & 0x0F) | 0x40;
// Set UUID variant (10xxxxxx)
bytes[8] = (bytes[8] & 0x3F) | 0x80;
format!(
"{}-{}-{}-{}-{}",
hex::encode_upper(&bytes[0..4]),
hex::encode_upper(&bytes[4..6]),
hex::encode_upper(&bytes[6..8]),
hex::encode_upper(&bytes[8..10]),
hex::encode_upper(&bytes[10..16]),
)
}
/// Creates a `UUIDType` from a valid UUID string.
///
/// Implementation details:
/// - A standard UUID is 16 bytes.
/// - The first 16 bytes of the resulting `UUIDType` are taken from the parsed UUID.
/// - The remaining 16 bytes are filled by hashing the first 16 bytes using Blake3.
/// - This ensures the resulting `UUIDType` is 32 bytes, but this operation is **not reversible**
/// to the original 32-byte `UUIDType` if the input was previously generated with `to_valid_uuid`.
pub fn from_valid_uuid(uuid: &str) -> Self {
let bytes = if let Some(parsed_uuid) = parse_uuid_hex(uuid) {
let mut bytes = [0u8; 32];
// die 16 UUID Bytes
bytes[..16].copy_from_slice(&parsed_uuid);
// die restlichen 16 Bytes = Hash der UUID (optional)
let hash = blake3::hash(&parsed_uuid);
bytes[16..].copy_from_slice(&hash.as_bytes()[..16]);
bytes
} else {
// fallback
*blake3::hash(uuid.as_bytes()).as_bytes()
};
UUIDType(bytes)
}
}
impl AsRef<[u8]> for UUIDType {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl FromStr for UUIDType {
type Err = hex::FromHexError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = <[u8; 32]>::from_hex(s)?;
Ok(UUIDType(bytes))
}
}
impl std::fmt::Display for UUIDType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
#[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 | PlaylistItemType::LocalVideo => Ok(Self::Video),
PlaylistItemType::Series | PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo => Ok(Self::Series),
}
}
}
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default, Sequence)]
#[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
LocalVideo = 9,
LocalSeries = 10,
LocalSeriesInfo = 11,
}
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),
"LocalVideo" => Ok(PlaylistItemType::LocalVideo),
"Series" => Ok(PlaylistItemType::Series),
"SeriesInfo" => Ok(PlaylistItemType::SeriesInfo),
"LocalSeries" => Ok(PlaylistItemType::LocalSeries),
"LocalSeriesInfo" => Ok(PlaylistItemType::LocalSeriesInfo),
"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";
pub fn is_local(&self) -> bool {
matches!(self, PlaylistItemType::LocalVideo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo)
}
pub fn as_u8(self) -> u8 {
self as u8
}
}
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::LocalVideo => Self::VIDEO,
Self::Series | Self::LocalSeries => Self::SERIES,
Self::SeriesInfo | Self::LocalSeriesInfo => Self::SERIES_INFO,
Self::Catchup => Self::CATCHUP,
})
}
}
#[derive(Copy, Clone, Default, Debug)]
pub struct PlaylistItemTypeSet(u16);
impl PlaylistItemTypeSet {
#[inline]
pub fn empty() -> Self {
Self(0)
}
#[inline]
pub fn from_item(item: PlaylistItemType) -> Self {
let bit = 1u16 << ((item as u8) - 1);
Self(bit)
}
#[inline]
pub fn insert(&mut self, item: PlaylistItemType) {
self.0 |= 1u16 << ((item as u8) - 1);
}
#[inline]
pub fn remove(&mut self, item: PlaylistItemType) {
self.0 &= !(1u16 << ((item as u8) - 1));
}
#[inline]
pub fn is_set(&self, item: PlaylistItemType) -> bool {
(self.0 & (1u16 << ((item as u8) - 1))) != 0
}
#[inline]
pub fn bits(self) -> u16 {
self.0
}
}
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) -> Cow<'_, str>;
fn get_uuid(&self) -> UUIDType;
fn get_item_type(&self) -> PlaylistItemType;
fn get_group(&self) -> Cow<'_, str>;
fn get_name(&self) -> Cow<'_, str>;
fn get_resolved_info_document(&self, options: &XtreamMappingOptions) -> Option<Value>;
fn get_additional_properties(&self) -> Option<&StreamProperties>;
fn get_additional_properties_mut(&mut self) -> Option<&mut StreamProperties>;
}
#[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: u32,
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<StreamProperties>,
#[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_container_extension(&self) -> Option<Cow<'_, str>> {
self.additional_properties.as_ref().and_then(|a| a.get_container_extension())
}
}
macro_rules! to_m3u_non_empty_fields {
($header:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => {
$(
if !$header.$prop.is_empty() {
let _ = write!($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() {
let _ = write!($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)),
)*
"chno" => Some(Cow::Owned(self.chno.to_string())),
"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
}
)*
"chno" => {
if let Ok(val) = value.parse::<u32>() {
self.chno = val;
true
} else {
false
}
},
"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, 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: u32,
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 = String::with_capacity(256);
let _ = write!(&mut line, "#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"););
}
}
if self.chno != 0 {
let _ = write!(line, " tvg-chno=\"{}\"", self.chno);
}
to_m3u_non_empty_fields!(self, line,
(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 };
let _ = write!(&mut line, ",{}\n{}", self.title, url);
line
}
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,
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) -> Cow<'_, str> {
Cow::Borrowed(self.url.as_str())
}
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
}
#[inline]
fn get_group(&self) -> Cow<'_, str> {
Cow::Borrowed(self.group.as_str())
}
#[inline]
fn get_name(&self) -> Cow<'_, str> {
if self.title.is_empty() {
Cow::Borrowed(self.name.as_str())
} else {
Cow::Borrowed(self.title.as_str())
}
}
#[inline]
fn get_resolved_info_document(&self, _options: &XtreamMappingOptions) -> Option<Value> {
None
}
#[inline]
fn get_additional_properties(&self) -> Option<&StreamProperties> {
None
}
#[inline]
fn get_additional_properties_mut(&mut self) -> Option<&mut StreamProperties> {
None
}
}
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)),
)*
"chno" => Some(Cow::Owned(self.chno.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,
}
}
}
}
}
generate_field_accessor_impl_for_m3u_playlist_item!(provider_id, name, 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()
}
}
#[allow(clippy::struct_excessive_bools)]
pub struct XtreamMappingOptions {
pub skip_live_direct_source: bool,
pub skip_video_direct_source: bool,
pub skip_series_direct_source: bool,
pub rewrite_resource_url: bool,
pub force_redirect: Option<ClusterFlags>,
pub reverse_item_types: PlaylistItemTypeSet,
pub username: String,
pub password: String,
pub base_url: Option<String>,
}
impl XtreamMappingOptions {
#[inline]
pub fn is_reverse(&self, item_type: PlaylistItemType) -> bool {
self.reverse_item_types.is_set(item_type)
}
pub fn get_resource_url(&self, xtream_cluster: XtreamCluster, item_type: PlaylistItemType, virtual_id: VirtualId) -> Option<String> {
let is_reverse = self.is_reverse(item_type);
let resource_url = if is_reverse && self.rewrite_resource_url && self.base_url.is_some() {
let resource_url = format!("{}/resource/{}/{}/{}/{}", self.base_url.as_ref().map_or_else(String::new, |b| b.clone()),
xtream_cluster.as_stream_type(), self.username, self.password, virtual_id);
Some(resource_url)
} else {
None
};
resource_url
}
}
#[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<StreamProperties>,
pub item_type: PlaylistItemType,
pub category_id: u32,
pub input_name: String,
pub channel_no: u32,
}
impl XtreamPlaylistItem {
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,
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: String::new(),
time_shift: String::new(),
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),
}
}
pub fn get_container_extension(&self) -> Option<Cow<'_, str>> {
match self.additional_properties {
None => None,
Some(ref props) => {
match props {
StreamProperties::Live(_) => Some(Cow::Borrowed("ts")),
StreamProperties::Video(video) => Some(Cow::Borrowed(video.container_extension.as_str())),
StreamProperties::Series(_) => None,
StreamProperties::Episode(episode) => Some(Cow::Borrowed(episode.container_extension.as_str())),
}
}
}
}
#[inline]
pub fn has_details(&self) -> bool {
self.additional_properties.as_ref().is_some_and(|p| p.has_details())
}
pub fn to_info_document(&self, options: &XtreamMappingOptions) -> Value {
if self.has_details() {
if let Some(doc) = self.additional_properties.as_ref()
.map(|p| p.to_info_document(options, self.item_type, self.virtual_id, self.category_id)) {
return doc;
}
}
let resource_url = options.get_resource_url(self.xtream_cluster, self.item_type, self.virtual_id);
self.to_info_document_no_props(resource_url)
}
fn to_info_document_no_props(&self, resource_url: Option<String>) -> Value {
match self.xtream_cluster {
XtreamCluster::Live => {
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), InfoDocUtils::build_u32(self.channel_no)),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("stream_id".to_string(), InfoDocUtils::build_u32(self.virtual_id)),
("stream_icon".to_string(), Value::String(stream_icon)),
("epg_channel_id".to_string(), InfoDocUtils::build_string(self.epg_channel_id.as_deref())),
("added".to_string(), InfoDocUtils::empty_string()),
("is_adult".to_string(), InfoDocUtils::build_u32(0u32)),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
("custom_sid".to_string(), Value::Null),
("direct_source".to_string(), InfoDocUtils::empty_string()),
("tv_archive".to_string(), InfoDocUtils::build_u32(0u32)),
("tv_archive_duration".to_string(), InfoDocUtils::build_u32(0u32)),
]))
}
XtreamCluster::Video => {
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("stream_type".to_string(), Value::String("movie".to_string())),
("stream_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("stream_icon".to_string(), Value::String(stream_icon)),
("rating".to_string(), InfoDocUtils::empty_string()),
("rating_5based".to_string(), InfoDocUtils::empty_string()),
("tmdb".to_string(), InfoDocUtils::empty_string()),
("trailer".to_string(), InfoDocUtils::empty_string()),
("added".to_string(), InfoDocUtils::empty_string()),
("is_adult".to_string(), Value::Number(serde_json::Number::from(0u32))),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
("container_extension".to_string(), InfoDocUtils::empty_string()),
("custom_sid".to_string(), Value::Null),
("direct_source".to_string(), InfoDocUtils::empty_string()),
]))
}
XtreamCluster::Series => {
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("series_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("cover".to_string(), Value::String(stream_icon)),
("plot".to_string(), InfoDocUtils::empty_string()),
("cast".to_string(), InfoDocUtils::empty_string()),
("director".to_string(), InfoDocUtils::empty_string()),
("genre".to_string(), InfoDocUtils::empty_string()),
("release_date".to_string(), InfoDocUtils::empty_string()),
("releaseDate".to_string(), InfoDocUtils::empty_string()),
("last_modified".to_string(), InfoDocUtils::empty_string()),
("rating".to_string(), InfoDocUtils::empty_string()),
("rating_5based".to_string(), InfoDocUtils::empty_string()),
("backdrop_path".to_string(), Value::Array(Vec::new())),
("youtube_trailer".to_string(), InfoDocUtils::empty_string()),
("tmdb".to_string(), InfoDocUtils::empty_string()),
("episode_runtime".to_string(), InfoDocUtils::empty_string()),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
]))
}
}
}
pub fn to_document(&self, options: &XtreamMappingOptions) -> serde_json::Value {
if let Some(props) = self.additional_properties.as_ref() {
match props {
StreamProperties::Live(live) => {
self.live_to_document(options, live)
}
StreamProperties::Video(video) => {
self.video_to_document(options, video)
}
StreamProperties::Series(series) => {
self.series_to_document(options, series)
}
StreamProperties::Episode(_episode) => {
Value::Object(serde_json::Map::from_iter([]))
}
}
} else {
let resource_url = options.get_resource_url(self.xtream_cluster, self.item_type, self.virtual_id);
self.to_document_no_props(resource_url)
}
}
fn series_to_document(&self, options: &XtreamMappingOptions, series: &SeriesStreamProperties) -> Value {
let resource_url = options.get_resource_url(self.xtream_cluster, self.item_type, self.virtual_id);
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())),
("series_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("cover".to_string(), Value::String(InfoDocUtils::make_resource_url(resource_url.as_deref(), &series.cover, "cover"))),
("plot".to_string(), InfoDocUtils::build_string(series.plot.as_deref())),
("cast".to_string(), Value::String(series.cast.clone())),
("director".to_string(), Value::String(series.director.clone())),
("genre".to_string(), InfoDocUtils::build_string(series.genre.as_deref())),
("release_date".to_string(), InfoDocUtils::build_string(series.release_date.as_deref())),
("releaseDate".to_string(), InfoDocUtils::build_string(series.release_date.as_deref())),
("last_modified".to_string(), InfoDocUtils::build_string(series.last_modified.as_deref())),
("rating".to_string(), Value::String(InfoDocUtils::limited(series.rating))),
("rating_5based".to_string(), Value::String(InfoDocUtils::limited(series.rating_5based))),
("backdrop_path".to_string(), Value::Array(
series.backdrop_path.as_ref().map_or_else(Vec::new, |b| b.iter().enumerate().map(|(idx, p)|
Value::String(
InfoDocUtils::make_bdpath_resource_url(resource_url.as_deref(), p, idx, "")
)
).collect())
)),
("youtube_trailer".to_string(), Value::String(series.youtube_trailer.clone())),
("tmdb".to_string(), Value::String(series.tmdb.as_ref().map_or_else(String::new, ToString::to_string))),
("episode_runtime".to_string(), InfoDocUtils::build_string(series.episode_run_time.as_deref())),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
]))
}
fn video_to_document(&self, options: &XtreamMappingOptions, video: &VideoStreamProperties) -> Value {
let resource_url = options.get_resource_url(self.xtream_cluster, self.item_type, self.virtual_id);
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("stream_type".to_string(), Value::String(video.stream_type.as_ref().map_or_else(|| "movie".to_string(), Clone::clone))),
("stream_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("stream_icon".to_string(), Value::String(stream_icon)),
("rating".to_string(), Value::String(InfoDocUtils::limited(video.rating.unwrap_or_default()))),
("rating_5based".to_string(), Value::Number(serde_json::Number::from_f64(video.rating_5based.unwrap_or_default()).unwrap_or_else(|| serde_json::Number::from(0)))),
("tmdb".to_string(), Value::String(video.tmdb.as_ref().map_or_else(String::new, ToString::to_string))),
("trailer".to_string(), InfoDocUtils::build_string(video.trailer.as_deref())),
("added".to_string(), Value::String(video.added.clone())),
("is_adult".to_string(), Value::Number(serde_json::Number::from(video.is_adult))),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
("container_extension".to_string(), Value::String(video.container_extension.clone())),
("custom_sid".to_string(), video.custom_sid.as_ref().map_or(Value::Null, |s| Value::String(s.clone()))),
("direct_source".to_string(), if options.skip_video_direct_source { InfoDocUtils::empty_string() } else { Value::String(video.direct_source.clone()) }),
]))
}
fn live_to_document(&self, options: &XtreamMappingOptions, live: &LiveStreamProperties) -> Value {
let resource_url = options.get_resource_url(self.xtream_cluster, self.item_type, self.virtual_id);
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("added".to_string(), InfoDocUtils::build_string(live.added.as_deref())),
("stream_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("stream_icon".to_string(), Value::String(stream_icon)),
("stream_type".to_string(), Value::String(live.stream_type.as_ref().map_or_else(|| "live".to_string(), Clone::clone))),
("epg_channel_id".to_string(), InfoDocUtils::build_string(self.epg_channel_id.as_deref())),
("is_adult".to_string(), Value::Number(serde_json::Number::from(live.is_adult))),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
("custom_sid".to_string(), live.custom_sid.as_ref().map_or(Value::Null, |s| Value::String(s.clone()))),
("direct_source".to_string(), if options.skip_live_direct_source { InfoDocUtils::empty_string() } else { Value::String(live.direct_source.clone()) }),
("tv_archive".to_string(), Value::Number(serde_json::Number::from(live.tv_archive.unwrap_or_default()))),
("tv_archive_duration".to_string(), Value::Number(serde_json::Number::from(live.tv_archive_duration.unwrap_or_default()))),
]))
}
fn to_document_no_props(&self, resource_url: Option<String>) -> Value {
match self.xtream_cluster {
XtreamCluster::Live => {
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("stream_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("stream_icon".to_string(), Value::String(stream_icon)),
("epg_channel_id".to_string(), InfoDocUtils::build_string(self.epg_channel_id.as_deref())),
("added".to_string(), InfoDocUtils::empty_string()),
("is_adult".to_string(), Value::Number(serde_json::Number::from(0u32))),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
("custom_sid".to_string(), Value::Null),
("direct_source".to_string(), InfoDocUtils::empty_string()),
("tv_archive".to_string(), Value::Number(serde_json::Number::from(0u32))),
("tv_archive_duration".to_string(), Value::Number(serde_json::Number::from(0u32))),
]))
}
XtreamCluster::Video => {
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("stream_type".to_string(), Value::String("movie".to_string())),
("stream_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("stream_icon".to_string(), Value::String(stream_icon)),
("rating".to_string(), InfoDocUtils::empty_string()),
("rating_5based".to_string(), InfoDocUtils::empty_string()),
("tmdb".to_string(), InfoDocUtils::empty_string()),
("trailer".to_string(), InfoDocUtils::empty_string()),
("added".to_string(), InfoDocUtils::empty_string()),
("is_adult".to_string(), Value::Number(serde_json::Number::from(0u32))),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
("container_extension".to_string(), InfoDocUtils::empty_string()),
("custom_sid".to_string(), Value::Null),
("direct_source".to_string(), InfoDocUtils::empty_string()),
]))
}
XtreamCluster::Series => {
let stream_icon = if !self.logo.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo, "logo")
} else if !self.logo_small.is_empty() {
InfoDocUtils::make_resource_url(resource_url.as_deref(), &self.logo_small, "logo_small")
} else {
String::new()
};
Value::Object(serde_json::Map::from_iter([
("num".to_string(), Value::Number(serde_json::Number::from(self.channel_no))),
("name".to_string(), Value::String(self.title.clone())), // name or title ?
("series_id".to_string(), Value::Number(serde_json::Number::from(self.virtual_id))),
("cover".to_string(), Value::String(stream_icon)),
("plot".to_string(), InfoDocUtils::empty_string()),
("cast".to_string(), InfoDocUtils::empty_string()),
("director".to_string(), InfoDocUtils::empty_string()),
("genre".to_string(), InfoDocUtils::empty_string()),
("release_date".to_string(), InfoDocUtils::empty_string()),
("releaseDate".to_string(), InfoDocUtils::empty_string()),
("last_modified".to_string(), InfoDocUtils::empty_string()),
("rating".to_string(), InfoDocUtils::empty_string()),
("rating_5based".to_string(), InfoDocUtils::empty_string()),
("backdrop_path".to_string(), Value::Array(Vec::new())),
("youtube_trailer".to_string(), InfoDocUtils::empty_string()),
("tmdb".to_string(), InfoDocUtils::empty_string()),
("episode_runtime".to_string(), InfoDocUtils::empty_string()),
("category_id".to_string(), Value::String(self.category_id.to_string())),
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(self.category_id))]))),
]))
}
}
}
pub fn resolve_resource_url<'a>(&'a self, field: &str) -> Option<Cow<'a, str>> {
self.additional_properties.as_ref().and_then(|a| a.resolve_resource_url(field))
}
}
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> {
Some(self.category_id)
}
#[inline]
fn get_provider_url(&self) -> Cow<'_, str> {
Cow::Borrowed(self.url.as_str())
}
#[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
}
#[inline]
fn get_group(&self) -> Cow<'_, str> {
Cow::Borrowed(self.group.as_str())
}
#[inline]
fn get_name(&self) -> Cow<'_, str> {
if self.title.is_empty() {
Cow::Borrowed(self.name.as_str())
} else {
Cow::Borrowed(self.title.as_str())
}
}
fn get_resolved_info_document(&self, options: &XtreamMappingOptions) -> Option<Value> {
if self.has_details() {
self.additional_properties.as_ref()
.map(|p| p.to_info_document(options, self.get_item_type(),
self.get_virtual_id(), self.get_category_id().unwrap_or(0)))
} else {
None
}
}
#[inline]
fn get_additional_properties(&self) -> Option<&StreamProperties> {
self.additional_properties.as_ref()
}
#[inline]
fn get_additional_properties_mut(&mut self) -> Option<&mut StreamProperties> {
self.additional_properties.as_mut()
}
}
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
{
match self.additional_properties.as_ref() {
Some(additional_properties) => match additional_properties {
StreamProperties::Live(_) => None,
StreamProperties::Video(video) => {
if field == xtream_const::XC_PROP_COVER {
video.details.as_ref().and_then(|details| {
details.cover_big.as_ref()
.or(details.movie_image.as_ref())
.or_else(|| details.backdrop_path.as_ref().and_then(|p| p.first()))
.map(|s| Cow::<str>::Borrowed(s))
})
} else {
video.details.as_ref().and_then(|details| {
details.backdrop_path.as_ref().and_then(|p| p.first())
.or(details.movie_image.as_ref())
.or(details.cover_big.as_ref())
.map(|s| Cow::<str>::Borrowed(s))
})
}
}
StreamProperties::Series(series) => {
if field == xtream_const::XC_PROP_COVER {
if series.cover.is_empty() {
series.backdrop_path.as_ref().and_then(|p| p.first()).map(|s| Cow::<str>::Borrowed(s))
} else {
Some(Cow::Borrowed(&series.cover))
}
} else {
match series.backdrop_path.as_ref() {
None => if series.cover.is_empty() { None } else { Some(Cow::Borrowed(&series.cover)) }
Some(p) => p.first().map(|s| Cow::<str>::Borrowed(s))
}
}
}
StreamProperties::Episode(episode) => {
Some(Cow::<str>::Borrowed(&episode.movie_image))
}
},
None => None,
}
} else {
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 {
fn get_additional_properties(header: &PlaylistItemHeader) -> Option<StreamProperties> {
match &header.additional_properties {
Some(props) => Some(props.clone()),
None => {
match header.xtream_cluster {
XtreamCluster::Live => None,
XtreamCluster::Video => {
let container_extension = extract_extension_from_url(&header.url).map(|e| e.strip_prefix('.').unwrap_or(e).to_string()).unwrap_or_default();
Some(StreamProperties::Video(Box::new(VideoStreamProperties {
name: header.name.clone(),
category_id: header.category_id,
stream_id: header.virtual_id,
stream_icon: "".to_string(),
direct_source: "".to_string(),
custom_sid: None,
added: String::new(),
container_extension,
rating: None,
rating_5based: None,
stream_type: Some("movie".to_string()),
trailer: None,
tmdb: None,
is_adult: 0,
details: None,
})))
}
XtreamCluster::Series => {
if header.item_type == PlaylistItemType::Series {
let container_extension = extract_extension_from_url(&header.url).map(|e| e.strip_prefix('.').unwrap_or(e).to_string()).unwrap_or_default();
// TODO maybe from link ? like s01e02 or something like this
Some(StreamProperties::Episode(EpisodeStreamProperties {
episode_id: 0,
episode: 0,
season: 0,
added: None,
release_date: None,
tmdb: None,
movie_image: String::new(),
container_extension,
audio: None,
video: None,
}))
} else if header.item_type == PlaylistItemType::SeriesInfo {
Some(StreamProperties::Series(Box::new(SeriesStreamProperties {
name: header.name.clone(),
category_id: header.category_id,
tmdb: None,
series_id: 0,
backdrop_path: None,
cast: String::new(),
cover: String::new(),
director: String::new(),
episode_run_time: None,
genre: None,
last_modified: None,
plot: None,
rating: 0.0,
rating_5based: 0.0,
release_date: None,
youtube_trailer: String::new(),
details: None,
})))
} else {
None
}
}
}
}
}
}
pub fn has_details(&self) -> bool {
self.header.additional_properties.as_ref().is_some_and(|p| p.has_details())
}
pub fn get_tmdb_id(&self) -> Option<u32> {
self.header.additional_properties.as_ref().and_then(|p| p.get_tmdb_id())
}
}
impl From<&PlaylistItem> for XtreamPlaylistItem {
fn from(item: &PlaylistItem) -> Self {
let header = &item.header;
let provider_id = header.id.parse::<u32>().unwrap_or_default();
let additional_properties = PlaylistItem::get_additional_properties(header);
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,
}
}
}
impl From<&PlaylistItem> for M3uPlaylistItem {
fn from(item: &PlaylistItem) -> Self {
let header = &item.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,
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,
}
}
}
impl From<&PlaylistItem> for CommonPlaylistItem {
fn from(item: &PlaylistItem) -> Self {
let header = &item.header;
let additional_properties = PlaylistItem::get_additional_properties(header);
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,
}
}
}
impl From<&XtreamPlaylistItem> for PlaylistItem {
fn from(item: &XtreamPlaylistItem) -> Self {
let header = PlaylistItemHeader {
uuid: item.get_uuid(),
virtual_id: item.virtual_id,
id: item.provider_id.to_string(), // u32 → String
name: item.name.clone(),
title: item.title.clone(),
logo: item.logo.clone(),
logo_small: item.logo_small.clone(),
group: item.group.clone(),
parent_code: item.parent_code.clone(),
rec: item.rec.clone(),
url: item.url.clone(),
epg_channel_id: item.epg_channel_id.clone(),
xtream_cluster: item.xtream_cluster,
item_type: item.item_type,
category_id: item.category_id,
input_name: item.input_name.clone(),
chno: item.channel_no,
audio_track: String::new(),
time_shift: String::new(),
additional_properties: item.additional_properties.clone(),
};
PlaylistItem {
header
}
}
}
impl From<&M3uPlaylistItem> for PlaylistItem {
fn from(item: &M3uPlaylistItem) -> Self {
let header = PlaylistItemHeader {
uuid: item.get_uuid(),
virtual_id: item.virtual_id,
id: item.provider_id.clone(),
name: item.name.clone(),
title: item.title.clone(),
logo: item.logo.clone(),
logo_small: item.logo_small.clone(),
group: item.group.clone(),
parent_code: item.parent_code.clone(),
rec: item.rec.clone(),
url: item.url.clone(),
epg_channel_id: item.epg_channel_id.clone(),
xtream_cluster: XtreamCluster::Live, // TODO based on file ending
item_type: item.item_type,
category_id: 0,
input_name: item.input_name.clone(),
chno: item.chno,
audio_track: String::new(),
time_shift: String::new(),
additional_properties: None,
};
PlaylistItem {
header
}
}
}
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> {
Some(self.header.category_id)
}
#[inline]
fn get_provider_url(&self) -> Cow<'_, str> {
Cow::Borrowed(self.header.url.as_str())
}
#[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
}
#[inline]
fn get_group(&self) -> Cow<'_, str> {
Cow::Borrowed(self.header.group.as_str())
}
#[inline]
fn get_name(&self) -> Cow<'_, str> {
if self.header.title.is_empty() {
Cow::Borrowed(self.header.name.as_str())
} else {
Cow::Borrowed(self.header.title.as_str())
}
}
fn get_resolved_info_document(&self, options: &XtreamMappingOptions) -> Option<Value> {
if self.has_details() {
self.header.additional_properties.as_ref().map(|p|
p.to_info_document(options, self.get_item_type(), self.get_virtual_id(),
self.get_category_id().unwrap_or(0)))
} else {
None
}
}
fn get_additional_properties(&self) -> Option<&StreamProperties> {
self.header.additional_properties.as_ref()
}
#[inline]
fn get_additional_properties_mut(&mut self) -> Option<&mut StreamProperties> {
self.header.additional_properties.as_mut()
}
}
#[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();
pl.header.category_id = self.id;
}
}
#[inline]
pub fn filter_count<F>(&self, filter: F) -> usize
where
F: Fn(&PlaylistItem) -> bool,
{
self.channels.iter().filter(|&c| filter(c)).count()
}
}