From c7bc0beb96029df14ca3acd73c2f4b486a7f120f Mon Sep 17 00:00:00 2001 From: euzu Date: Mon, 22 Dec 2025 17:25:11 +0100 Subject: [PATCH] stream properties refactored --- backend/src/api/endpoints/hdhomerun_api.rs | 6 +- backend/src/api/endpoints/xtream_api.rs | 48 +- backend/src/model/media_properties.rs | 24 +- backend/src/model/xtream.rs | 682 +----------------- backend/src/processing/parser/m3u.rs | 37 +- backend/src/processing/parser/xtream.rs | 189 +++-- backend/src/processing/processor/library.rs | 329 +++++---- backend/src/processing/processor/playlist.rs | 12 +- backend/src/processing/processor/trakt.rs | 30 +- backend/src/processing/processor/xtream.rs | 18 +- .../src/processing/processor/xtream_series.rs | 105 ++- .../src/processing/processor/xtream_vod.rs | 96 +-- backend/src/repository/playlist_repository.rs | 32 +- backend/src/repository/strm_repository.rs | 177 ++--- .../repository/xtream_playlist_iterator.rs | 22 +- backend/src/repository/xtream_repository.rs | 13 +- backend/src/utils/network/xtream.rs | 10 +- shared/src/model/mapping.rs | 1 - shared/src/model/mod.rs | 4 + shared/src/model/playlist.rs | 619 ++++++++++++---- shared/src/model/playlist_request.rs | 6 +- shared/src/model/stream_properties.rs | 501 +++++++++++++ shared/src/model/xtream.rs | 271 +++++++ shared/src/utils/hash_utils.rs | 27 +- shared/src/utils/serde_utils.rs | 47 ++ 25 files changed, 1844 insertions(+), 1462 deletions(-) create mode 100644 shared/src/model/stream_properties.rs create mode 100644 shared/src/model/xtream.rs diff --git a/backend/src/api/endpoints/hdhomerun_api.rs b/backend/src/api/endpoints/hdhomerun_api.rs index 0028896aa..694fd715e 100644 --- a/backend/src/api/endpoints/hdhomerun_api.rs +++ b/backend/src/api/endpoints/hdhomerun_api.rs @@ -15,7 +15,7 @@ use serde_json::json; use shared::model::{ M3uPlaylistItem, PlaylistItemType, TargetType, XtreamCluster, XtreamPlaylistItem, }; -use shared::utils::{concat_path, get_string_from_serde_value}; +use shared::utils::{concat_path}; use std::sync::Arc; #[derive(Serialize, Deserialize, Clone)] @@ -112,9 +112,7 @@ where o.xtream_live_stream_without_extension, ) }); - let container_extension = item - .get_additional_property("container_extension") - .map(|v| get_string_from_serde_value(&v).unwrap_or_default()); + let container_extension = item.get_container_extension(); let stream_url = match &base_url { None => item.url.clone(), Some(url) => get_xtream_url( diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 48985efb7..dea4a42a1 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -15,7 +15,7 @@ use crate::api::model::UserApiRequest; use crate::api::model::XtreamAuthorizationResponse; use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType}; use crate::auth::Fingerprint; -use crate::model::{AppConfig, ConfigTarget}; +use crate::model::{xtream_mapping_option_from_target_options, AppConfig, ConfigTarget}; use crate::model::{Config, ConfigInput}; use crate::model::{InputSource, ProxyUserCredentials}; use crate::repository::playlist_repository::get_target_id_mapping; @@ -67,12 +67,12 @@ impl ApiStreamContext { impl Display for ApiStreamContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", - match self { - Self::Live | Self::LiveAlt => Self::LIVE, - Self::Movie => Self::MOVIE, - Self::Series => Self::SERIES, - Self::Timeshift => Self::TIMESHIFT, - } + match self { + Self::Live | Self::LiveAlt => Self::LIVE, + Self::Movie => Self::MOVIE, + Self::Series => Self::SERIES, + Self::Timeshift => Self::TIMESHIFT, + } ) } } @@ -977,6 +977,16 @@ async fn xtream_player_api_timeshift_query_stream( .into_response() } +fn empty_json_response() -> axum::http::Result { + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header( + axum::http::header::CONTENT_TYPE, + mime::APPLICATION_JSON.to_string(), + ) + .body(axum::body::Body::from("{}".as_bytes())) +} + async fn xtream_get_stream_info_response( app_state: &Arc, user: &ProxyUserCredentials, @@ -996,16 +1006,12 @@ async fn xtream_get_stream_info_response( Some(cluster), ).await { if pli.item_type.is_local() { - if let Some(additional_properties) = pli.additional_properties.as_ref() { - let bytes = additional_properties.as_bytes().to_vec(); - return try_unwrap_body!(axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header( - axum::http::header::CONTENT_TYPE, - mime::APPLICATION_JSON.as_ref() - ) - .body(axum::body::Body::from(bytes))); - } + let Ok(xtream_output) = target.get_xtream_output().ok_or_else(|| info_err!(format!("Unexpected: xtream output required for target {}", target.name))) else { + return try_unwrap_body!(empty_json_response()); + }; + let server_info = app_state.app_config.get_user_server_info(user); + let options = xtream_mapping_option_from_target_options(target, xtream_output, &app_state.app_config, user, Some(server_info.get_base_url().as_str())); + return axum::Json(pli.to_document(&options)).into_response(); } if pli.provider_id > 0 { @@ -1064,13 +1070,7 @@ async fn xtream_get_stream_info_response( } }; } - try_unwrap_body!(axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header( - axum::http::header::CONTENT_TYPE, - mime::APPLICATION_JSON.to_string() - ) - .body(axum::body::Body::from("{}".as_bytes()))) + try_unwrap_body!(empty_json_response()) } async fn xtream_get_short_epg( diff --git a/backend/src/model/media_properties.rs b/backend/src/model/media_properties.rs index 1f8b725e2..2645dc0ea 100644 --- a/backend/src/model/media_properties.rs +++ b/backend/src/model/media_properties.rs @@ -1,8 +1,8 @@ // backend/src/model/media_properties.rs use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use std::fmt; -use serde_json::{Value, Map}; // Enum for Video Resolution #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -163,10 +163,10 @@ impl MediaQuality { /// Extracts media quality information from an `ffprobe` info block. /// The `info_block` is expected to be a `serde_json::Value` object. - pub fn from_ffprobe_info(info_block: &Value) -> Option { - let video_info = info_block.get("video")?.as_object()?; + pub fn from_ffprobe_info(audio: Option<&String>, video: Option<&String>) -> Option { + let video_info = video.and_then(|v| serde_json::from_str::>(v).ok())?; // Assuming the first audio stream is the primary one. - let audio_info = info_block.get("audio")?.as_object()?; + let audio_info = audio.and_then(|v| serde_json::from_str::>(v).ok())?; // Helper to get a value by trying a prioritized list of field names. let get_value = |obj: &Map, fields: &[&str]| -> Option { @@ -179,9 +179,9 @@ impl MediaQuality { } None }; - + // 1. Classify video resolution from width - let resolution = get_value(video_info, &["height", "coded_height"]) + let resolution = get_value(&video_info, &["height", "coded_height"]) .and_then(|v| v.as_u64()) .map_or(VideoResolution::default(), |h| match h { _ if h >= 4300 => VideoResolution::P4320, @@ -192,7 +192,7 @@ impl MediaQuality { }); // 2. Classify video codec - let video_codec = get_value(video_info, &["codec_name"]) + let video_codec = get_value(&video_info, &["codec_name"]) .and_then(|v| v.as_str().map(str::to_lowercase)) .map_or(VideoCodec::default(), |name| match name.as_str() { "h264" => VideoCodec::H264, @@ -204,13 +204,13 @@ impl MediaQuality { // 3. Classify dynamic range let dynamic_range = { - let tag_string = get_value(video_info, &["codec_tag_string"]) + let tag_string = get_value(&video_info, &["codec_tag_string"]) .and_then(|v| v.as_str().map(str::to_lowercase)); if tag_string == Some("dovi".to_string()) { VideoDynamicRange::DV } else { - get_value(video_info, &["color_transfer"]) + get_value(&video_info, &["color_transfer"]) .and_then(|v| v.as_str().map(str::to_lowercase)) .map_or(VideoDynamicRange::SDR, |ct| match ct.as_str() { "smpte2084" => VideoDynamicRange::HDR10, @@ -219,9 +219,9 @@ impl MediaQuality { }) } }; - + // 4. Classify audio codec - let audio_codec = get_value(audio_info, &["codec_name"]) + let audio_codec = get_value(&audio_info, &["codec_name"]) .and_then(|v| v.as_str().map(str::to_lowercase)) .map_or(AudioCodec::default(), |name| match name.as_str() { "aac" => AudioCodec::AAC, @@ -234,7 +234,7 @@ impl MediaQuality { }); // 5. Classify audio channels - let audio_channels = get_value(audio_info, &["channel_layout"]) + let audio_channels = get_value(&audio_info, &["channel_layout"]) .and_then(|v| v.as_str().map(str::to_lowercase)) .map_or(AudioChannels::default(), |layout| match layout.as_str() { l if l.starts_with("7.1") => AudioChannels::Surround71, diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index 727245f78..e96a97dfb 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -1,68 +1,10 @@ -use std::collections::BTreeMap; use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, XtreamTargetOutput}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::{Value}; -use shared::model::{xtream_const, PlaylistItem, ProxyUserStatus, XtreamPlaylistItem}; -use shared::model::{ClusterFlags, PlaylistEntry, XtreamCluster}; -use shared::utils::{deserialize_as_option_string, deserialize_as_string, deserialize_as_string_array, deserialize_number_from_string, - get_non_empty_str, opt_string_or_number_u32, string_default_on_null, string_or_number_f64, string_or_number_u32}; -use std::iter::FromIterator; -use serde::ser::SerializeMap; -use serde_json::value::RawValue; - -macro_rules! add_str_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $vec.insert(String::from($prop_name), Value::String($prop.to_string())); - } -} - -macro_rules! add_rc_str_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - if let Some(v) = $prop.as_ref() { - $vec.insert(String::from($prop_name), Value::String(v.to_string())); - } - } -} - -macro_rules! add_opt_i64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - if let Some(v) = $prop.as_ref() { - $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from(*v)))); - } - } -} - -macro_rules! add_opt_f64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $prop.as_ref().map(|v| $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from(*v)).unwrap_or_else(|| serde_json::Number::from(0))))); - } -} - -macro_rules! add_f64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from($prop)).unwrap_or_else(|| serde_json::Number::from(0)))); - } -} - -macro_rules! add_i64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from($prop)))); - } -} - -macro_rules! add_to_doc_str_property_if_not_exists { - ($document:expr, $prop_name:expr, $prop_value:expr) => { - match $document.get($prop_name) { - None => { - $document.insert(String::from($prop_name), $prop_value); - } - Some(value) => { if Value::is_null(value) { - $document.insert(String::from($prop_name), $prop_value); - }} - } - } -} +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use shared::model::{xtream_const, PlaylistItem, PlaylistItemType, PlaylistItemTypeSet, ProxyUserStatus, XtreamMappingOptions}; +use shared::utils::{deserialize_as_string, deserialize_number_from_string_or_zero}; +use enum_iterator::all; #[derive(Debug, Default)] pub struct XtreamLoginInfo { @@ -72,8 +14,8 @@ pub struct XtreamLoginInfo { #[derive(Deserialize, Default)] pub struct XtreamCategory { - #[serde(deserialize_with = "deserialize_as_string")] - pub category_id: String, + #[serde(deserialize_with = "deserialize_number_from_string_or_zero")] + pub category_id: u32, #[serde(deserialize_with = "deserialize_as_string")] pub category_name: String, //pub parent_id: i32, @@ -87,492 +29,30 @@ impl XtreamCategory { } } -#[derive(Serialize, Deserialize, Debug)] -pub struct XtreamStream { - #[serde(default, deserialize_with = "deserialize_as_string")] - pub name: String, - #[serde(default, deserialize_with = "deserialize_as_string")] - pub category_id: String, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub stream_id: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub series_id: Option, - #[serde(default, deserialize_with = "deserialize_as_string")] - pub stream_icon: String, - #[serde(default, deserialize_with = "deserialize_as_string")] - pub direct_source: String, - // optional attributes - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub custom_sid: Option, - #[serde(default, deserialize_with = "deserialize_as_string_array")] - pub backdrop_path: Option>, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub added: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub cast: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub container_extension: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub cover: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub director: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub episode_run_time: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub genre: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub last_modified: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub plot: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub rating: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub rating_5based: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub release_date: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub stream_type: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub title: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub year: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub trailer: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub youtube_trailer: Option, - #[serde(default, deserialize_with = "deserialize_as_option_string")] - pub epg_channel_id: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub tv_archive: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub tv_archive_duration: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub tmdb: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub tmdb_id: Option, - #[serde(default, deserialize_with = "deserialize_number_from_string")] - pub is_adult: Option, +pub fn xtream_mapping_option_from_target_options(target: &ConfigTarget, target_output: &XtreamTargetOutput, + cfg: &AppConfig, user: &ProxyUserCredentials, + resource_url: Option<&str>) -> XtreamMappingOptions { -} + let force_redirect = target.options.as_ref().and_then(|o| o.force_redirect); + let mut reverse_item_types = PlaylistItemTypeSet::empty(); -impl XtreamStream { - pub fn get_stream_id(&self) -> u32 { - self.stream_id.unwrap_or_else(|| self.series_id.unwrap_or(0)) - } - - pub fn get_additional_properties(&self) -> Option> { - - let mut result = serde_json::Map::new(); - if let Some(bdpath) = self.backdrop_path.as_ref() { - if !bdpath.is_empty() { - result.insert(String::from(xtream_const::XC_PROP_BACKDROP_PATH), Value::Array(Vec::from([Value::String(String::from(bdpath.first()?))]))); - } - } - - add_rc_str_property_if_exists!(result, self.tmdb_id.or(self.tmdb), "tmdb"); - add_rc_str_property_if_exists!(result, self.added, "added"); - add_rc_str_property_if_exists!(result, self.cast, "cast"); - add_rc_str_property_if_exists!(result, self.container_extension, "container_extension"); - add_rc_str_property_if_exists!(result, self.cover, "cover"); - add_rc_str_property_if_exists!(result, self.director, "director"); - add_rc_str_property_if_exists!(result, self.episode_run_time, "episode_run_time"); - add_rc_str_property_if_exists!(result, self.genre, "genre"); - add_rc_str_property_if_exists!(result, self.last_modified, "last_modified"); - add_rc_str_property_if_exists!(result, self.plot, "plot"); - add_opt_f64_property_if_exists!(result, self.rating, "rating"); - add_opt_f64_property_if_exists!(result, self.rating_5based, "rating_5based"); - add_rc_str_property_if_exists!(result, self.release_date, "release_date"); - add_rc_str_property_if_exists!(result, self.stream_type, "stream_type"); - add_rc_str_property_if_exists!(result, self.title, "title"); - add_rc_str_property_if_exists!(result, self.year, "year"); - add_rc_str_property_if_exists!(result, self.trailer, "trailer"); - add_rc_str_property_if_exists!(result, self.youtube_trailer, "youtube_trailer"); - add_rc_str_property_if_exists!(result, self.epg_channel_id, "epg_channel_id"); - add_opt_i64_property_if_exists!(result, self.tv_archive, "tv_archive"); - add_opt_i64_property_if_exists!(result, self.tv_archive_duration, "tv_archive_duration"); - add_opt_i64_property_if_exists!(result, self.is_adult, "is_adult"); - if result.is_empty() { - None - } else { - let s = serde_json::to_string(&Value::Object(result)).ok()?; - RawValue::from_string(s).ok() + for item_type in all::() { + if user.proxy.is_reverse(item_type) && !force_redirect.as_ref().is_some_and(|o| o.has_cluster(item_type)) { + reverse_item_types.insert(item_type); } } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamMovieInfo { - pub info: XtreamMovieInfoDetails, - pub movie_data: XtreamMovieData, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamMovieInfoDetails { - pub kinopoisk_url: Option, - pub tmdb_id: Option, - pub name: Option, - pub o_name: Option, - pub cover_big: Option, - pub movie_image: Option, - pub releasedate: Option, - pub episode_run_time: Option, - pub youtube_trailer: Option, - pub director: Option, - pub actors: Option, - pub cast: Option, - pub description: Option, - pub plot: Option, - pub age: Option, - pub mpaa_rating: Option, - #[serde(default)] - pub rating_count_kinopoisk: u32, - pub country: Option, - pub genre: Option, - #[serde(default)] - pub backdrop_path: Vec, - pub duration_secs: Option, - pub duration: Option, - #[serde(default)] - pub video: Vec, - #[serde(default)] - pub audio: Vec, - #[serde(default)] - pub bitrate: u32, - pub rating: Option, - pub runtime: Option, - pub status: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamStreamInfo { - #[serde(flatten)] - pub extra: std::collections::HashMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamMovieData { - pub stream_id: u32, - pub name: String, - pub added: Option, - pub category_id: u32, - #[serde(default)] - pub category_ids: Vec, - pub container_extension: Option, - pub custom_sid: Option, - pub direct_source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamSeriesInfoSeason { - #[serde(default, deserialize_with = "string_default_on_null")] - pub air_date: String, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub episode_count: u32, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub id: u32, - #[serde(default, deserialize_with = "string_default_on_null")] - pub name: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub overview: String, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub season_number: u32, - #[serde(default, deserialize_with = "string_or_number_f64")] - pub vote_average: f64, - #[serde(default, deserialize_with = "string_default_on_null")] - pub cover: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub cover_big: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[allow(non_snake_case)] -pub struct XtreamSeriesInfoInfo { - #[serde(default, deserialize_with = "string_default_on_null")] - pub(crate) name: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub cover: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub plot: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub cast: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub director: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub genre: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub release_date: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub releaseDate: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub releasedate: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub last_modified: String, - #[serde(default, deserialize_with = "string_or_number_f64")] - pub rating: f64, - #[serde(default, deserialize_with = "string_or_number_f64")] - pub rating_5based: f64, - #[serde(default, deserialize_with = "deserialize_as_string_array")] - pub backdrop_path: Option>, - #[serde(default, deserialize_with = "string_default_on_null")] - pub trailer: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub youtube_trailer: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub episode_run_time: String, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub category_id: u32, - #[serde(default, deserialize_with = "opt_string_or_number_u32")] - pub tmdb_id: Option, - #[serde(default, deserialize_with = "opt_string_or_number_u32")] - pub tmdb: Option, - #[serde(default, deserialize_with = "opt_string_or_number_u32")] - pub year: Option, -} - -#[allow(non_snake_case)] -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct XtreamSeriesInfoEpisodeInfo { - #[serde(default, deserialize_with = "opt_string_or_number_u32")] - pub id: Option, - #[serde(default, deserialize_with = "opt_string_or_number_u32")] - pub tmdb_id: Option, - #[serde(default, deserialize_with = "opt_string_or_number_u32")] - pub tmdb: Option, - #[serde(default, deserialize_with = "string_default_on_null")] - pub release_date: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub releaseDate: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub releasedate: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub plot: String, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub duration_secs: u32, - #[serde(default, deserialize_with = "string_default_on_null")] - pub duration: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub movie_image: String, - #[serde(default)] - pub video: Option>, - #[serde(default)] - pub audio: Option>, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub bitrate: u32, - #[serde(default, deserialize_with = "string_or_number_f64")] - pub rating: f64, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub season: u32, -} - -// Used for serde_json deserialization, can not be used with bincode -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamSeriesInfoEpisode { - #[serde(default, deserialize_with = "string_or_number_u32")] - pub id: u32, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub episode_num: u32, - #[serde(default, deserialize_with = "string_default_on_null")] - pub title: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub container_extension: String, - #[serde(default)] - pub info: Option, - #[serde(default, deserialize_with = "string_default_on_null")] - pub custom_sid: String, - #[serde(default, deserialize_with = "string_default_on_null")] - pub added: String, - #[serde(default, deserialize_with = "string_or_number_u32")] - pub season: u32, - #[serde(default, deserialize_with = "string_default_on_null")] - pub direct_source: String, -} - -impl XtreamSeriesInfoEpisode { - pub fn get_id(&self) -> u32 { - self.id - } -} - -//bincode does not support deserialize_with. We use this struct for db -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamSeriesEpisode { - pub id: u32, - pub episode_num: u32, - pub title: String, - pub container_extension: String, - pub custom_sid: String, - pub added: String, - pub season: u32, - pub tmdb_id: u32, - pub direct_source: String, -} - -impl XtreamSeriesEpisode { - pub fn from(info_episode: &XtreamSeriesInfoEpisode) -> Self { - Self { - id: info_episode.get_id(), - episode_num: info_episode.episode_num, - title: info_episode.title.clone(), - container_extension: info_episode.container_extension.clone(), - custom_sid: info_episode.custom_sid.clone(), - added: info_episode.added.clone(), - season: info_episode.season, - tmdb_id: info_episode.info.as_ref().and_then(|info| info.tmdb_id.or(info.tmdb)).unwrap_or(0), - direct_source: info_episode.direct_source.clone(), - } - } -} - -// sometimes episodes are a map with season as key, sometimes an array -fn deserialize_episodes<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - // read as generic value - let value = Value::deserialize(deserializer)?; - match value { - Value::Null => Ok(None), - Value::Array(array) => { - if array.is_empty() { - Ok(None) - } else { - let mut result = Vec::new(); - for inner in array { - if let Some(inner_array) = inner.as_array() { - for item in inner_array { - let ep: XtreamSeriesInfoEpisode = serde_json::from_value(item.clone()) - .map_err(serde::de::Error::custom)?; - result.push(ep); - } - } - } - Ok(Some(result)) - } - } - Value::Object(object) => { - if object.is_empty() { - Ok(None) - } else { - let mut result = Vec::new(); - for (_key, val) in object { - if let Some(inner_array) = val.as_array() { - for item in inner_array { - let ep: XtreamSeriesInfoEpisode = serde_json::from_value(item.clone()) - .map_err(serde::de::Error::custom)?; - result.push(ep); - } - } - } - Ok(Some(result)) - } - } - _ => Err(serde::de::Error::custom("Invalid format for episodes")), - } -} - -#[allow(clippy::ref_option)] -fn serialize_episodes( - episodes: &Option>, - serializer: S, -) -> Result -where - S: Serializer, -{ - match episodes { - None => { - let map = serializer.serialize_map(Some(0))?; - map.end() - }, - Some(list) => { - if list.is_empty() { - let map = serializer.serialize_map(Some(0))?; - return map.end(); - } - let mut seasons: BTreeMap> = BTreeMap::new(); - for ep in list { - seasons.entry(ep.season.to_string()).or_default().push(ep); - } - - seasons.serialize(serializer) - } - } -} - - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamSeriesInfo { - #[serde(default)] - pub seasons: Option>, - #[serde(default)] - pub info: Option, - #[serde(default, serialize_with = "serialize_episodes", deserialize_with = "deserialize_episodes")] - pub episodes: Option>, -} - -impl XtreamSeriesInfoEpisode { - pub fn get_additional_properties(&self, series_info: &XtreamSeriesInfo) -> Option> { - let mut result = serde_json::Map::new(); - let info = series_info.info.as_ref(); - let bdpath = info.and_then(|i| i.backdrop_path.as_ref()); - let bdpath_is_set = bdpath.as_ref().is_some_and(|bdpath| !bdpath.is_empty()); - if bdpath_is_set { - result.insert(String::from("backdrop_path"), Value::Array(Vec::from([Value::String(String::from(bdpath?.first()?))]))); - } - add_str_property_if_exists!(result, info.map_or("", |i| i.name.as_str()), "series_name"); - add_str_property_if_exists!(result, info.map_or("", |i| get_non_empty_str(i.release_date.as_str(), i.releaseDate.as_str(), i.releasedate.as_str())), "series_release_date"); - add_str_property_if_exists!(result, self.added.as_str(), "added"); - add_str_property_if_exists!(result, info.map_or("", |i| i.cast.as_str()), "cast"); - add_str_property_if_exists!(result, self.container_extension.as_str(), "container_extension"); - add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| info.movie_image.as_str()), "cover"); - add_str_property_if_exists!(result, info.map_or("", |i| i.director.as_str()), "director"); - add_str_property_if_exists!(result, info.map_or("", |i| i.episode_run_time.as_str()), "episode_run_time"); - add_str_property_if_exists!(result, info.map_or("", |i| i.last_modified.as_str()), "last_modified"); - add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| info.plot.as_str()), "plot"); - add_f64_property_if_exists!(result, info.map_or(0_f64, |i| i.rating), "rating"); - add_f64_property_if_exists!(result, info.map_or(0_f64, |i| i.rating_5based), "rating_5based"); - add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| get_non_empty_str(info.release_date.as_str(), info.releaseDate.as_str(), info.releasedate.as_str())), "release_date"); - add_str_property_if_exists!(result, self.title, "title"); - add_i64_property_if_exists!(result, self.season, "season"); - add_i64_property_if_exists!(result, self.episode_num, "episode"); - let series_tmdb_id = info.and_then(|i| i.tmdb_id.or(i.tmdb)); - add_opt_i64_property_if_exists!(result, self.info.as_ref().and_then(|info| info.tmdb_id.or(info.tmdb.or(series_tmdb_id))), "tmdb_id"); - - // Add the "info" section to the playlist item additional properties. - if let Some(episode_info) = &self.info { - if let Ok(info_value) = serde_json::to_value(episode_info) { - result.insert("info".to_string(), info_value); - } - } - - if result.is_empty() { - None - } else { - let s = serde_json::to_string(&Value::Object(result)).ok()?; - RawValue::from_string(s).ok() - } - } -} - -#[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, -} - -impl XtreamMappingOptions { - pub fn from_target_options(target: &ConfigTarget, target_output: &XtreamTargetOutput, cfg: &AppConfig) -> Self { - Self { - skip_live_direct_source: target_output.skip_live_direct_source, - skip_video_direct_source: target_output.skip_video_direct_source, - skip_series_direct_source: target_output.skip_series_direct_source, - rewrite_resource_url: cfg.is_reverse_proxy_resource_rewrite_enabled(), - force_redirect: target.options.as_ref().and_then(|o| o.force_redirect), - } + XtreamMappingOptions { + skip_live_direct_source: target_output.skip_live_direct_source, + skip_video_direct_source: target_output.skip_video_direct_source, + skip_series_direct_source: target_output.skip_series_direct_source, + rewrite_resource_url: cfg.is_reverse_proxy_resource_rewrite_enabled(), + force_redirect, + reverse_item_types, + username: user.username.clone(), + password: user.password.clone(), + base_url: resource_url.map(ToString::to_string).clone(), } } @@ -596,31 +76,6 @@ pub fn normalize_release_date(document: &mut serde_json::Map) { } } -fn append_mandatory_fields(document: &mut serde_json::Map, fields: &[&str]) { - for &field in fields { - if !document.contains_key(field) { - document.insert(field.to_string(), Value::Null); - } - } -} - -fn append_prepared_series_properties(add_props: Option<&serde_json::Map>, document: &mut serde_json::Map) { - if let Some(props) = add_props { - match props.get("rating") { - Some(value) => { - document.insert("rating".to_string(), match value { - Value::Number(val) => Value::String(format!("{:.0}", val.as_f64().unwrap_or(0f64))), - Value::String(val) => Value::String(val.clone()), - _ => Value::String("0".to_string()), - }); - } - None => { - document.insert("rating".to_string(), Value::String("0".to_string())); - } - } - } -} - fn make_bdpath_resource_url(resource_url: &str, bd_path: &str, index: usize, field_prefix: &str) -> String { if bd_path.starts_with("http") { format!("{resource_url}/{field_prefix}{}_{index}", xtream_const::XC_PROP_BACKDROP_PATH) @@ -629,93 +84,6 @@ fn make_bdpath_resource_url(resource_url: &str, bd_path: &str, index: usize, fie } } -pub fn xtream_playlistitem_to_document(pli: &XtreamPlaylistItem, url: &str, options: &XtreamMappingOptions, user: &ProxyUserCredentials) -> serde_json::Value { - let stream_id_value = Value::Number(serde_json::Number::from(pli.virtual_id)); - - let is_reverse = user.proxy.is_reverse(pli.item_type) && !options.force_redirect.as_ref().is_some_and(|o| o.has_cluster(pli.item_type)); - let (resource_url, logo, logo_small) = if is_reverse && options.rewrite_resource_url { - let resource_url = format!("{url}/resource/{}/{}/{}/{}", pli.xtream_cluster.as_stream_type(), user.username, user.password, pli.get_virtual_id()); - let logo_url = if pli.logo.is_empty() { String::new() } else { format!("{resource_url}/logo") }; - let logo_small_url = if pli.logo_small.is_empty() { String::new() } else { format!("{resource_url}/logo_small") }; - (Some(resource_url), logo_url, logo_small_url) - } else { - (None, pli.logo.clone(), pli.logo_small.clone()) - }; - let mut document = serde_json::Map::from_iter([ - ("category_id".to_string(), Value::String(format!("{}", &pli.category_id))), - ("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(pli.category_id))]))), - ("name".to_string(), Value::String(pli.name.clone())), - ("num".to_string(), Value::Number(serde_json::Number::from(pli.channel_no))), - ("title".to_string(), Value::String(pli.title.clone())), - ("stream_icon".to_string(), Value::String(logo)), - ]); - - match pli.xtream_cluster { - XtreamCluster::Live => { - document.insert("stream_id".to_string(), stream_id_value); - if options.skip_live_direct_source { - document.insert("direct_source".to_string(), Value::String(String::new())); - } else { - document.insert("direct_source".to_string(), Value::String(pli.url.clone())); - } - document.insert("thumbnail".to_string(), Value::String(logo_small)); - document.insert("custom_sid".to_string(), Value::String(String::new())); - document.insert("epg_channel_id".to_string(), pli.epg_channel_id.as_ref().map_or(Value::Null, |epg_id| Value::String(epg_id.clone()))); - } - XtreamCluster::Video => { - document.insert("stream_id".to_string(), stream_id_value); - if options.skip_video_direct_source { - document.insert("direct_source".to_string(), Value::String(String::new())); - } else { - document.insert("direct_source".to_string(), Value::String(pli.url.clone())); - } - document.insert("custom_sid".to_string(), Value::String(String::new())); - } - XtreamCluster::Series => { - document.insert("series_id".to_string(), stream_id_value); - } - } - - let props = pli.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::>(add_props).ok()); - - if let Some(ref add_props) = props { - let doc = if matches!(pli.xtream_cluster, XtreamCluster::Video | XtreamCluster::Series) { - match add_props.get("info") { - Some(Value::Object(info_doc)) => info_doc, - _ => add_props, - } - } else { - add_props - }; - for (field_name, field_value) in doc { - if !document.contains_key(field_name) { - document.insert(field_name.clone(), field_value.to_owned()); - } - } - } - - match pli.xtream_cluster { - XtreamCluster::Live => { - append_mandatory_fields(&mut document, xtream_const::LIVE_STREAM_FIELDS); - add_to_doc_str_property_if_not_exists!(document, "stream_type", Value::String(String::from("live"))); - add_to_doc_str_property_if_not_exists!(document, "added", Value::String(chrono::Utc::now().timestamp().to_string())); - } - XtreamCluster::Video => { - append_mandatory_fields(&mut document, xtream_const::VIDEO_STREAM_FIELDS); - add_to_doc_str_property_if_not_exists!(document, "stream_type", Value::String(String::from("movie"))); - add_to_doc_str_property_if_not_exists!(document, "added", Value::String(chrono::Utc::now().timestamp().to_string())); - } - XtreamCluster::Series => { - append_prepared_series_properties(props.as_ref(), &mut document); - append_mandatory_fields(&mut document, xtream_const::SERIES_STREAM_FIELDS); - } - } - - rewrite_doc_urls(resource_url.as_ref(), &mut document, xtream_const::XTREAM_VOD_REWRITE_URL_PROPS, ""); - - Value::Object(document) -} - pub fn rewrite_doc_urls(resource_url: Option<&String>, document: &mut serde_json::Map, fields: &[&str], field_prefix: &str) { if let Some(rewrite_url) = resource_url { if let Some(bdpath) = document.get(xtream_const::XC_PROP_BACKDROP_PATH) { diff --git a/backend/src/processing/parser/m3u.rs b/backend/src/processing/parser/m3u.rs index c910d0efd..f35520d6b 100644 --- a/backend/src/processing/parser/m3u.rs +++ b/backend/src/processing/parser/m3u.rs @@ -1,9 +1,9 @@ use crate::model::{Config, ConfigInput}; +use crate::utils::request::DynReader; use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster, DEFAULT_VIDEO_EXTENSIONS}; use shared::utils::extract_id_from_url; use std::borrow::BorrowMut; use tokio::io::AsyncBufReadExt; -use crate::utils::request::DynReader; // other implementations like calculating text_distance on all titles took too much time // we keep it now as simple as possible and less memory intensive. @@ -126,7 +126,7 @@ fn process_header(input_name: &str, video_suffixes: &[&str], content: &str, url: let mut c = skip_digit(&mut it); loop { match c { - None=> break, + None => break, Some(chr) => { if chr.is_whitespace() { // skip @@ -142,18 +142,19 @@ fn process_header(input_name: &str, video_suffixes: &[&str], content: &str, url: if !value.is_empty() { provider_id = Some(value); } + } else if token == "tvg-chno" { + plih.chno = value.parse::().unwrap_or(0); } else { process_header_fields!(plih, token.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); + (id, "tvg-id"), + (group, "group-title"), + (name, "tvg-name"), + (parent_code, "parent-code"), + (audio_track, "audio-track"), + (logo, "tvg-logo"), + (logo_small, "tvg-logo-small"), + (time_shift, "timeshift"), + (rec, "tvg-rec"); value); } } } @@ -202,10 +203,10 @@ pub async fn consume_m3u(cfg: &Config, input: &ConfigInp let input_name = input.name.as_str(); let video_suffixes = match cfg.video.as_ref() { - Some(config) => { - config.extensions.iter().map(String::as_str).collect::>() - }, - None => DEFAULT_VIDEO_EXTENSIONS.to_vec() + Some(config) => { + config.extensions.iter().map(String::as_str).collect::>() + } + None => DEFAULT_VIDEO_EXTENSIONS.to_vec() }; let mut lines = tokio::io::BufReader::new(lines).lines(); while let Ok(Some(line)) = lines.next_line().await { @@ -292,7 +293,7 @@ mod test { 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.chno, 7); assert_eq!(pli.group, "Sydney"); } @@ -307,7 +308,7 @@ mod test { 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.chno, 7); assert_eq!(pli.group, "Sydney"); } diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 59cae85c6..f8d7fa822 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -1,14 +1,15 @@ -use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; +use std::borrow::Cow; use crate::model::ConfigInput; -use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; -use crate::model::{XtreamCategory, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamStream}; -use shared::utils::{generate_playlist_uuid, trim_last_slash}; -use crate::utils::xtream::{get_xtream_stream_url_base}; -use serde_json::Value; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::task::spawn_blocking; +use crate::model::{XtreamCategory}; use crate::utils::request::DynReader; +use crate::utils::xtream::get_xtream_stream_url_base; +use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; +use shared::model::{EpisodeStreamProperties, LiveStreamProperties, PlaylistGroup, PlaylistItem, + PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, + SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster}; +use shared::utils::{generate_playlist_uuid, trim_last_slash}; +use std::collections::HashMap; +use tokio::task::spawn_blocking; async fn map_to_xtream_category(categories: DynReader) -> Result, TuliproxError> { spawn_blocking(move || { @@ -22,79 +23,70 @@ async fn map_to_xtream_category(categories: DynReader) -> Result Result, TuliproxError> { +async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader) -> Result, TuliproxError> { spawn_blocking(move || { - let reader = tokio_util::io::SyncIoBridge::new(streams); - match serde_json::from_reader::<_, Vec>(reader) { - Ok(stream_list) => Ok(stream_list), - Err(err) => { - create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to map to xtream streams {xtream_cluster}: {err}", ) + let reader = tokio_util::io::SyncIoBridge::new(streams); + let parsed: Result, serde_json::Error> = match xtream_cluster { + XtreamCluster::Live => serde_json::from_reader::<_, Vec>(reader).map(|list| list.into_iter().map(StreamProperties::Live).collect()), + XtreamCluster::Video => serde_json::from_reader::<_, Vec>(reader).map(|list| list.into_iter().map(Box::new).map(StreamProperties::Video).collect()), + XtreamCluster::Series => serde_json::from_reader::<_, Vec>(reader).map(|list| list.into_iter().map(Box::new).map(StreamProperties::Series).collect()), + }; + + match parsed { + Ok(mut stream_list) => { + for stream in &mut stream_list { + stream.prepare(); + } + Ok(stream_list) + } + Err(err) => { + create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to map to xtream streams {xtream_cluster}: {err}", ) + } } - } }).await.map_err(|e| TuliproxError::new(TuliproxErrorKind::Notify, format!("Mapping xtream streams failed: {e}")))? } -fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &XtreamSeriesInfoEpisode) -> Arc { +fn create_xtream_series_episode_url<'a>(url: &'a str, username: &'a str, password: &'a str, episode: &'a SeriesStreamDetailEpisodeProperties) -> Cow<'a, str> { 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) + Cow::Owned(stream_base_url) } else { - Arc::new(episode.direct_source.clone()) + Cow::Borrowed(&episode.direct_source) } } -pub fn parse_xtream_series_info(info: &Value, group_title: &str, series_name: &str, input: &ConfigInput) -> Result>, TuliproxError> { +pub fn parse_xtream_series_info(series_info: &SeriesStreamProperties, group_title: &str, series_name: &str, input: &ConfigInput) -> Option> { 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); + if let Some(episodes) = series_info.details.as_ref().and_then(|d| d.episodes.as_ref()) { + let result: Vec = episodes.iter().map(|episode| { + let episode_url = create_xtream_series_episode_url(url, username, password, episode); - - - match serde_json::from_value::(info.to_owned()) { - Ok(series_info) => { - if let Some(episodes) = &series_info.episodes { - let result: Vec<(XtreamSeriesInfoEpisode, PlaylistItem)> = episodes.iter().map(|episode| { - let episode_url = create_xtream_series_episode_url(url, username, password, episode); - let mut new_episode = episode.clone(); - - // We need to set the tmdb_id and tmdb from the series info if it is not set. - if let Some(episode_info) = &mut new_episode.info { - if episode_info.tmdb_id.is_none_or(|id| id == 0) && episode_info.tmdb.is_none_or(|id| id == 0) { - let series_tmdb_id = series_info.info.as_ref().and_then(|i| i.tmdb_id.or(i.tmdb)); - episode_info.tmdb_id = series_tmdb_id; - episode_info.tmdb = series_tmdb_id; - } - } - - (new_episode, - PlaylistItem { - header: PlaylistItemHeader { - id: episode.id.to_string(), - uuid: generate_playlist_uuid(&input.name, &episode.id.to_string(), PlaylistItemType::Series, &episode_url), - name: series_name.to_string(), - logo: episode.info.as_ref().map_or_else(String::new, |info| info.movie_image.clone()), - 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.clone(), - ..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}") - } + let episode_info = EpisodeStreamProperties::from_series(series_info, episode); + PlaylistItem { + header: PlaylistItemHeader { + id: episode.id.to_string(), + uuid: generate_playlist_uuid(&input.name, &episode.id.to_string(), PlaylistItemType::Series, &episode_url), + name: series_name.to_string(), + logo: episode.movie_image.clone(), + group: group_title.to_string(), + title: episode.title.clone(), + url: episode_url.to_string(), + item_type: PlaylistItemType::Series, + xtream_cluster: XtreamCluster::Series, + additional_properties: Some(StreamProperties::Episode(episode_info)), + category_id: 0, + input_name: input.name.clone(), + ..Default::default() + } + } + }).collect(); + return if result.is_empty() { None } else { Some(result) }; } + None } #[allow(clippy::too_many_arguments)] @@ -120,20 +112,20 @@ pub fn get_xtream_url(xtream_cluster: XtreamCluster, url: &str, } 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) + stream: &StreamProperties, live_stream_use_prefix: bool, live_stream_without_extension: bool) -> String { + if let Some(direct_source) = stream.get_direct_source() { + direct_source.clone() } else { - stream.direct_source.clone() + get_xtream_url(xtream_cluster, url, username, password, stream.get_stream_id(), + stream.get_container_extension().as_ref().map(std::string::ToString::to_string).as_ref(), + live_stream_use_prefix, live_stream_without_extension) } } pub async fn parse_xtream(input: &ConfigInput, - xtream_cluster: XtreamCluster, - categories: DynReader, - streams: DynReader) -> Result>, TuliproxError> { + xtream_cluster: XtreamCluster, + categories: DynReader, + streams: DynReader) -> Result>, TuliproxError> { match map_to_xtream_category(categories).await { Ok(xtream_categories) => { let input_name = input.name.clone(); @@ -142,13 +134,13 @@ pub async fn parse_xtream(input: &ConfigInput, let password = input.password.as_ref().map_or("", |v| v); match map_to_xtream_streams(xtream_cluster, streams).await { - Ok(mut xtream_streams) => { - let mut group_map: HashMap = + Ok(xtream_streams) => { + let mut group_map: HashMap = xtream_categories.into_iter().map(|category| - (category.category_id.clone(), category) + (category.category_id, category) ).collect(); let mut unknown_grp = XtreamCategory { - category_id: "0".to_string(), + category_id: 0u32, category_name: "Unknown".to_string(), channels: vec![], }; @@ -156,33 +148,24 @@ pub async fn parse_xtream(input: &ConfigInput, 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); + for stream in xtream_streams { + let group = group_map.get_mut(&stream.get_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 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.clone(), - logo: stream.stream_icon.clone(), + name: stream.get_name().to_string(), + logo: stream.get_stream_icon().to_string(), group: category_name.clone(), - title: stream.name.clone(), + title: stream.get_name().to_string(), url: stream_url.clone(), - epg_channel_id: stream.epg_channel_id.clone(), + epg_channel_id: stream.get_epg_channel_id(), item_type, xtream_cluster, - additional_properties: stream.get_additional_properties(), + additional_properties: Some(stream), category_id: 0, input_name: input_name.clone(), ..Default::default() @@ -190,15 +173,17 @@ pub async fn parse_xtream(input: &ConfigInput, }; group.add(item); } + + let has_channels = !unknown_grp.channels.is_empty(); if has_channels { - group_map.insert("0".to_string(), unknown_grp); + group_map.insert(0, unknown_grp); } Ok(Some(group_map.values().filter(|category| !category.channels.is_empty()) .map(|category| { PlaylistGroup { - id: category.category_id.parse::().unwrap_or(0), + id: category.category_id, xtream_cluster, title: category.category_name.clone(), channels: category.channels.clone(), @@ -214,25 +199,23 @@ pub async fn parse_xtream(input: &ConfigInput, #[cfg(test)] mod tests { - use std::fs; - use shared::model::XtreamCluster; - use crate::model::{XtreamSeriesInfo}; use crate::processing::parser::xtream::map_to_xtream_streams; use crate::utils::async_file_reader; + use shared::model::{XtreamCluster, XtreamSeriesInfo}; + use std::fs; #[test] fn test_read_json_file_into_struct() { let file_content = fs::read_to_string("/tmp/series-info.json").expect("Unable to read file"); - match serde_json::from_str::(&file_content) { + match serde_json::from_str::(&file_content) { Ok(series_info) => { println!("{:#?}", series_info); assert!(true); - }, + } Err(err) => { assert!(false, "Failed to parse json file: {err}"); } } - } #[tokio::test] @@ -244,7 +227,7 @@ mod tests { println!("{:?}", _streams.get(100)); println!("{:?}", _streams.get(200)); assert!(true); - }, + } Err(err) => { assert!(false, "Failed to parse json file: {err}"); } diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 746fbf0e7..3b45c74a5 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -1,9 +1,7 @@ use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; -use crate::model::{AppConfig, ConfigInput, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamSeriesInfoEpisodeInfo, XtreamSeriesInfoInfo}; -use crate::model::{XtreamMovieData, XtreamMovieInfo, XtreamMovieInfoDetails}; -use serde_json::value::RawValue; +use crate::model::{AppConfig, ConfigInput}; use shared::error::TuliproxError; -use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; +use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamProperties, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster}; use shared::utils::{generate_playlist_uuid, string_to_uuid_type}; use std::path::Path; use std::sync::Arc; @@ -56,10 +54,7 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s match metadata { MediaMetadata::Movie(_) => { - let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry).and_then(|info| { - let raw = serde_json::to_string(&info).ok()?; - RawValue::from_string(raw).ok() - }); + let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry); vec![PlaylistItem { header: PlaylistItemHeader { uuid: string_to_uuid_type(&entry.uuid), @@ -77,8 +72,49 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s } MediaMetadata::Series(_series) => { let mut items = vec![]; - if let Some(xtream_series_info) = metadata_cache_entry_to_xtream_series_info(entry) { - let additional_properties = serde_json::to_string(&xtream_series_info).ok().and_then(|r| RawValue::from_string(r).ok()); + if let Some(additional_properties) = metadata_cache_entry_to_xtream_series_info(entry) { + let mut episodes = vec![]; + if let StreamProperties::Series(series_properties) = &additional_properties { + if let Some(details_props) = series_properties.details.as_ref() { + if let Some(prop_episodes) = details_props.episodes.as_ref() { + for episode in prop_episodes { + let container_extension = Path::new(&episode.direct_source) + .extension() + .and_then(|s| s.to_str()) + .map(ToString::to_string).unwrap_or_default(); + episodes.push(PlaylistItem { + header: PlaylistItemHeader { + id: episode.id.to_string(), + // we use parent_code for local series to find the parent series info and straighten the virtual_ids + parent_code: entry.uuid.clone(), + uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source), + name: episode.title.clone(), + group: group_name.to_string(), + title: episode.title.clone(), + url: episode.direct_source.clone(), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::LocalSeries, + category_id: 0, + input_name: input_name.to_string(), + additional_properties: Some(StreamProperties::Episode(EpisodeStreamProperties { + episode_id: episode.id, + episode: episode.episode_num, + season: episode.season, + added: Some(episode.added.clone()), + release_date: Some(episode.release_date.clone()), + tmdb: episode.tmdb, + movie_image: episode.movie_image.clone(), + container_extension, + audio: None, + video: None, + })), + ..Default::default() + } + }); + } + } + } + } let series_info = PlaylistItem { header: PlaylistItemHeader { @@ -91,34 +127,13 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s xtream_cluster: XtreamCluster::Series, item_type: PlaylistItemType::LocalSeriesInfo, input_name: input_name.to_string(), - additional_properties, + additional_properties: Some(additional_properties), ..PlaylistItemHeader::default() } }; items.push(series_info); - if let Some(episodes) = xtream_series_info.episodes.as_ref() { - for episode in episodes { - items.push(PlaylistItem { - header: PlaylistItemHeader { - id: episode.id.to_string(), - // we use parent_code for local series to find the parent series info and straighten the virtual_ids - parent_code: entry.uuid.clone(), - uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source), - name: episode.title.clone(), - group: group_name.to_string(), - title: episode.title.clone(), - url: episode.direct_source.clone(), - xtream_cluster: XtreamCluster::Series, - item_type: PlaylistItemType::LocalSeries, - category_id: 0, - input_name: input_name.to_string(), - additional_properties: episode.get_additional_properties(&xtream_series_info), - ..Default::default() - } - }); - } - } + items.extend(episodes); } items @@ -128,7 +143,7 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s pub fn metadata_cache_entry_to_xtream_movie_info( entry: &MetadataCacheEntry, -) -> Option { +) -> Option { let movie = match &entry.metadata { MediaMetadata::Movie(m) => m, MediaMetadata::Series(_) => return None, @@ -137,96 +152,123 @@ pub fn metadata_cache_entry_to_xtream_movie_info( let container_extension = Path::new(&entry.file_path) .extension() .and_then(|s| s.to_str()) - .map(ToString::to_string); + .map(ToString::to_string).unwrap_or_default(); let actor_names = movie.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::>().join(", ")); - let info = XtreamMovieInfoDetails { - kinopoisk_url: movie.tmdb_id.map(|id| format!("https://www.themoviedb.org/movie/{id}")), - tmdb_id: movie.tmdb_id.map(|id| id.to_string()), - name: Some(movie.title.clone()), - o_name: movie.original_title.clone(), - cover_big: movie.poster.clone(), - movie_image: movie.poster.clone(), - releasedate: movie.year.map(|y| format!("{y}-01-01")), - episode_run_time: movie.runtime, - director: movie.directors.as_ref().map(|d| d.join(", ")), - youtube_trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())), - actors: actor_names.clone(), - cast: actor_names, - genre: movie.genres.as_ref().map(|g| g.join(", ")), - description: movie.plot.clone(), - plot: movie.plot.clone(), - age: None, - mpaa_rating: movie.mpaa.clone(), - rating_count_kinopoisk: 0, - country: None, - backdrop_path: movie - .fanart - .as_ref() - .filter(|s| !s.is_empty()) - .map(|f| vec![f.clone()]) - .or_else(|| { - movie.poster - .as_ref() - .filter(|s| !s.is_empty()) - .map(|p| vec![p.clone()]) - }) - .unwrap_or_default(), - duration_secs: movie.runtime.map(|r| (r * 60).to_string()), - duration: movie.runtime.map(|r| { - let h = r / 60; - let m = r % 60; - format!("{h:02}:{m:02}:00") - }), - - video: Vec::new(), - audio: Vec::new(), - bitrate: 0, - rating: movie.rating.map(|r| format!("{r:.2}")), - runtime: movie.runtime.map(|r| (r * 60).to_string()), - status: Some("Released".to_string()), - }; - - let movie_data = XtreamMovieData { - stream_id: 0, + let properties = VideoStreamProperties { name: movie.title.clone(), - added: Some(entry.file_modified.to_string()), category_id: 0, - category_ids: vec![0], - container_extension, - custom_sid: None, + stream_id: 0, + stream_icon: String::new(), direct_source: String::new(), + custom_sid: None, + added: entry.file_modified.to_string(), + container_extension, + rating: movie.rating, + rating_5based: None, + stream_type: "movie".to_string(), + trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())), + tmdb: movie.tmdb_id, + is_adult: 0, + details: Some(VideoStreamDetailProperties { + kinopoisk_url: movie.tmdb_id.map(|id| format!("https://www.themoviedb.org/movie/{id}")), + o_name: movie.original_title.clone(), + cover_big: movie.poster.clone(), + movie_image: movie.poster.clone(), + release_date: movie.year.map(|y| format!("{y}-01-01")), + episode_run_time: movie.runtime, + director: movie.directors.as_ref().map(|d| d.join(", ")), + youtube_trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())), + actors: actor_names.clone(), + cast: actor_names, + genre: movie.genres.as_ref().map(|g| g.join(", ")), + description: movie.plot.clone(), + plot: movie.plot.clone(), + age: None, + mpaa_rating: movie.mpaa.clone(), + rating_count_kinopoisk: 0, + country: None, + backdrop_path: movie + .fanart + .as_ref() + .filter(|s| !s.is_empty()) + .map(|f| vec![f.clone()]) + .or_else(|| { + movie.poster + .as_ref() + .filter(|s| !s.is_empty()) + .map(|p| vec![p.clone()]) + }), + duration_secs: movie.runtime.map(|r| (r * 60).to_string()), + duration: movie.runtime.map(|r| { + let h = r / 60; + let m = r % 60; + format!("{h:02}:{m:02}:00") + }), + + video: None, + audio: None, + bitrate: 0, + runtime: movie.runtime.map(|r| (r * 60).to_string()), + status: Some("Released".to_string()), + }), }; - Some(XtreamMovieInfo { info, movie_data }) + Some(StreamProperties::Video(Box::new(properties))) } pub fn metadata_cache_entry_to_xtream_series_info( entry: &MetadataCacheEntry, -) -> Option { +) -> Option { let series = match &entry.metadata { MediaMetadata::Movie(_) => return None, MediaMetadata::Series(m) => m, }; let actor_names = series.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::>().join(", ")).unwrap_or_default(); - let release_date = series.year.map(|y| format!("{y}-01-01")).unwrap_or_default(); + let release_date = series.year.map(|y| format!("{y}-01-01")); let youtube_trailer = series.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())).unwrap_or_default(); - let info = XtreamSeriesInfoInfo { + let episodes = series.episodes.as_ref().map(|episodes| { + episodes.iter().filter(|episode| !episode.file_path.is_empty()).map(|episode| { + let container_extension = Path::new(&episode.file_path) + .extension() + .and_then(|s| s.to_str()) + .map(ToString::to_string) + .unwrap_or_default(); + let episode_release_date = episode.aired.as_ref().map(ToString::to_string).unwrap_or_default(); + let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id); + + SeriesStreamDetailEpisodeProperties { + id: tmdb_id.unwrap_or_default(), + episode_num: episode.episode, + season: episode.season, + title: episode.title.clone(), + container_extension, + custom_sid: String::new(), + added: episode.file_modified.to_string(), + direct_source: episode.file_path.clone(), + tmdb: tmdb_id, + release_date: episode_release_date.clone(), + plot: episode.plot.clone(), + duration_secs: episode.runtime.map_or(0, |r| r * 60), + duration: episode.runtime + .map(|r| format!("{:02}:{:02}:00", r / 60, r % 60)) + .unwrap_or_default(), + movie_image: episode.thumb.clone().unwrap_or_default(), + audio: None, + video: None, + bitrate: 0, + rating: None, + } + }).collect::>() + }); + + let properties = SeriesStreamProperties { name: series.title.clone(), - cover: series.poster.clone().unwrap_or_default(), - plot: series.plot.clone().unwrap_or_default(), - cast: actor_names, - director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default(), - genre: series.genres.as_ref().map(|d| d.join(", ")).unwrap_or_default(), - release_date: release_date.clone(), - releaseDate: release_date.clone(), - releasedate: release_date.clone(), - last_modified: series.last_updated.to_string(), - rating: series.rating.unwrap_or(0f64), - rating_5based: 0.0, + category_id: 0, + series_id: 0, backdrop_path: series .fanart .as_ref() @@ -237,73 +279,24 @@ pub fn metadata_cache_entry_to_xtream_series_info( .filter(|s| !s.is_empty()) .map(|p| vec![p.clone()]) }), - trailer: youtube_trailer.clone(), + cast: actor_names, + cover: series.poster.clone().unwrap_or_default(), + director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default(), + episode_run_time: None, + genre: series.genres.as_ref().map(|d| d.join(", ")), + last_modified: Some(series.last_updated.to_string()), + plot: series.plot.clone(), + rating: series.rating.unwrap_or(0f64), + rating_5based: 0.0, + release_date, youtube_trailer, - episode_run_time: String::from("0"), - category_id: 0, - tmdb_id: series.tmdb_id, tmdb: series.tmdb_id, - year: series.year, + details: Some(SeriesStreamDetailProperties { + year: series.year, + // seasons are not delivered through xtream get_series_info. + episodes, + }), }; - // seasons are not delivered through xtream get_series_info. - let seasons = Some(vec![]); /*series.seasons.as_ref() - .map(|seasons| seasons.iter().map(|season| XtreamSeriesInfoSeason { - air_date: season.air_date.clone().unwrap_or_default(), - episode_count: season.episode_count, - id: season.id, - name: season.name.clone(), - overview: season.overview.clone().unwrap_or_default(), - season_number: season.season_number, - vote_average: season.vote_average, - cover: season.poster_path.as_ref().cloned().unwrap_or_default(), - cover_big: season.poster_path.as_ref().cloned().unwrap_or_default(), - }).collect()); - */ - - let episodes = series.episodes.as_ref().map(|episodes| { - episodes.iter().filter(|episode| !episode.file_path.is_empty()).map(|episode| { - let container_extension = Path::new(&episode.file_path) - .extension() - .and_then(|s| s.to_str()) - .map(ToString::to_string) - .unwrap_or_default(); - - let episode_release_date = episode.aired.as_ref().map(ToString::to_string).unwrap_or_default(); - - let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id); - - XtreamSeriesInfoEpisode { - id: episode.id, - episode_num: episode.episode, - season: episode.season, - title: episode.title.clone(), - container_extension, - info: Some(XtreamSeriesInfoEpisodeInfo { - id: tmdb_id, - tmdb_id, - tmdb: tmdb_id, - season: episode.season, - release_date: episode_release_date.clone(), - releaseDate: episode_release_date.clone(), - releasedate: episode_release_date, - plot: episode.plot.clone().unwrap_or_default(), - duration_secs: episode.runtime.map_or(0, |r| r * 60), - duration: episode.runtime - .map(|r| format!("{:02}:{:02}:00", r / 60, r % 60)) - .unwrap_or_default(), - movie_image: episode.thumb.clone().unwrap_or_default(), - video: None, - audio: None, - bitrate: 0, - rating: episode.rating.unwrap_or(0.0), - }), - custom_sid: String::new(), - added: episode.file_modified.to_string(), - direct_source: episode.file_path.clone(), - } - }).collect::>() - }); - - Some(XtreamSeriesInfo { seasons, info: Some(info), episodes }) + Some(StreamProperties::Series(Box::new(properties))) } \ No newline at end of file diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 6c2c72078..99f018429 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -19,6 +19,7 @@ use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats}; use crate::processing::parser::xmltv::flatten_tvguide; use crate::processing::playlist_watch::process_group_watch; use crate::processing::processor::epg::process_playlist_epg; +use crate::processing::processor::library; use crate::processing::processor::sort::sort_playlist; use crate::processing::processor::trakt::process_trakt_categories_for_target; use crate::processing::processor::xtream_series::playlist_resolve_series; @@ -34,7 +35,6 @@ use shared::foundation::filter::{get_field_value, set_field_value, Filter, Value use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistUpdateState, ProcessingOrder, UUIDType, XtreamCluster}; use shared::utils::{default_as_default, hash_bytes}; use std::time::Instant; -use crate::processing::processor::library; fn is_valid(pli: &PlaylistItem, filter: &Filter) -> bool { let provider = ValueProvider { pli }; @@ -111,17 +111,17 @@ fn filter_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Opt fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { let assigned_chnos: HashSet = new_playlist.iter().flat_map(|g| &g.channels) - .filter(|c| !c.header.chno.is_empty()) - .map(|c| c.header.chno.as_str()) - .flat_map(str::parse::).collect(); + .filter(|c| !c.header.chno == 0) + .map(|c| c.header.chno) + .collect(); let mut chno = 1; for group in new_playlist { for chan in &mut group.channels { - if chan.header.chno.is_empty() { + if chan.header.chno == 0 { while assigned_chnos.contains(&chno) { chno += 1; } - chan.header.chno = chno.to_string(); + chan.header.chno = chno; chno += 1; } } diff --git a/backend/src/processing/processor/trakt.rs b/backend/src/processing/processor/trakt.rs index 6ed1eff13..4904f4432 100644 --- a/backend/src/processing/processor/trakt.rs +++ b/backend/src/processing/processor/trakt.rs @@ -1,13 +1,12 @@ -use std::borrow::Cow; use crate::model::{ConfigTarget, TraktListItem, TraktMatchItem}; -use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster}; use crate::model::{TraktConfig, TraktListConfig, TraktMatchResult}; -use shared::error::TuliproxError; -use crate::utils::{TraktClient, extract_year_from_title, normalize_title_for_matching}; -use shared::utils::{get_u32_from_serde_value}; -use shared::utils::{CONSTANTS}; +use crate::utils::{extract_year_from_title, normalize_title_for_matching, TraktClient}; use crate::utils::{trace_if_enabled, with}; use log::{debug, info, trace, warn}; +use shared::error::TuliproxError; +use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster}; +use shared::utils::CONSTANTS; +use std::borrow::Cow; use strsim::normalized_levenshtein; fn extract_quality(value: &str) -> Option<&str> { @@ -39,22 +38,11 @@ fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContent /// Extract TMDB ID from playlist item fn extract_tmdb_id_from_playlist_item(item: &PlaylistItem) -> Option { - if let Some(additional_props) = &item.header.additional_properties { - let props_str = additional_props.get(); - if let Ok(props) = serde_json::from_str::>(props_str) { - return props - .get("tmdb_id") - .and_then(get_u32_from_serde_value) - .filter(|&id| id != 0) - .or_else(|| { - props.get("info") - .and_then(|info| info.get("tmdb_id")) - .and_then(get_u32_from_serde_value) - .filter(|&id| id != 0) - }); - } + if let Some(props) = &item.header.additional_properties { + props.get_tmdb_id() + } else { + None } - None } fn calculate_year_bonus(playlist_year: Option, trakt_year: Option) -> f64 { diff --git a/backend/src/processing/processor/xtream.rs b/backend/src/processing/processor/xtream.rs index fdaccaa0f..b6f37e8b0 100644 --- a/backend/src/processing/processor/xtream.rs +++ b/backend/src/processing/processor/xtream.rs @@ -3,7 +3,6 @@ use shared::error::{str_to_io_error, TuliproxError}; use crate::model::{AppConfig, Config, ConfigInput, InputSource}; use crate::model::{FetchedPlaylist}; use shared::model::{PlaylistEntry, PlaylistItem, PlaylistItemType, XtreamCluster}; -use crate::model::normalize_release_date; use crate::repository::storage::get_input_storage_path; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -13,7 +12,6 @@ use crate::repository::storage_const; use crate::repository::xtream_repository::xtream_get_record_file_path; use crate::utils; use crate::utils::xtream; -use serde_json::{from_str, to_string, Value}; pub(in crate::processing) async fn playlist_resolve_download_playlist_item(client: &reqwest::Client, pli: &PlaylistItem, input: &ConfigInput, errors: &mut Vec, resolve_delay: u16, cluster: XtreamCluster) -> Option { let mut result = None; @@ -34,18 +32,6 @@ pub(in crate::processing) async fn playlist_resolve_download_playlist_item(clien result } -pub(in crate::processing) fn normalize_json_content(content: String) -> String { - match from_str::(&content) { - Ok(mut json_value) => { - if let Some(info) = json_value.get_mut("info").and_then(Value::as_object_mut) { - normalize_release_date(info); - } - to_string(&json_value).unwrap_or(content) - }, - Err(_) => content, - } -} - pub(in crate::processing) async fn create_resolve_episode_wal_files(cfg: &Config, input: &ConfigInput) -> Option<(tokio::fs::File, PathBuf)> { match get_input_storage_path(&input.name, &cfg.working_dir) { Ok(storage_path) => { @@ -77,9 +63,9 @@ pub(in crate::processing) async fn create_resolve_info_wal_files(cfg: &Config, i } } -pub(in crate::processing) fn should_update_info(pli: &mut PlaylistItem, processed_provider_ids: &HashMap, field: &str) -> (bool, u32, u64) { +pub(in crate::processing) fn should_update_info(pli: &mut PlaylistItem, processed_provider_ids: &HashMap) -> (bool, u32, u64) { let Some(provider_id) = pli.header.get_provider_id() else { return (false, 0, 0) }; - let last_modified = pli.header.get_additional_property_as_u64(field); + let last_modified = pli.header.additional_properties.as_ref().and_then(shared::model::StreamProperties::get_last_modified); let old_timestamp = processed_provider_ids.get(&provider_id); (old_timestamp.is_none() || last_modified.is_none() diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index 09449ed8b..74a059afb 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -1,9 +1,7 @@ use crate::model::{AppConfig, ConfigTarget}; use crate::model::FetchedPlaylist; -use crate::model::{XtreamSeriesEpisode, XtreamSeriesInfoEpisode}; use crate::processing::parser::xtream::parse_xtream_series_info; use crate::processing::processor::playlist::ProcessingPipe; -use crate::processing::processor::xtream::normalize_json_content; use crate::processing::processor::xtream::{create_resolve_episode_wal_files, create_resolve_info_wal_files, playlist_resolve_download_playlist_item, read_processed_info_ids, should_update_info}; use crate::processing::processor::{create_resolve_options_function_for_xtream_target, handle_error, handle_error_and_return}; use crate::repository::storage::get_input_storage_path; @@ -16,8 +14,8 @@ use crate::utils::{bincode_serialize, IO_BUFFER_SIZE}; use log::{error, info, log_enabled, warn, Level}; use shared::error::{info_err, notify_err}; use shared::error::TuliproxError; -use shared::model::InputType; -use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster}; +use shared::model::{InputType, SeriesStreamProperties, StreamProperties, XtreamSeriesInfo}; +use shared::model::{PlaylistGroup, PlaylistItemType, XtreamCluster}; use std::collections::{HashMap, HashSet}; use std::time::Instant; use tokio::io::AsyncWriteExt; @@ -31,26 +29,23 @@ async fn read_processed_series_info_ids(cfg: &AppConfig, errors: &mut Vec, provider_id: u32, - episode: &XtreamSeriesInfoEpisode, + additional_properties: Option<&StreamProperties>, ) -> std::io::Result { - let series_episode = XtreamSeriesEpisode::from(episode); - if let Ok(content_bytes) = bincode_serialize(&series_episode) { - writer.write_all(&provider_id.to_le_bytes()).await?; - let content_len = content_bytes.len(); - if let Ok(len) = u32::try_from(content_len) { - writer.write_all(&len.to_le_bytes()).await?; - writer.write_all(&content_bytes).await?; - return Ok(content_len + 4usize); + if let Some(StreamProperties::Episode(series_episode)) = additional_properties { + if let Ok(content_bytes) = bincode_serialize(&series_episode) { + writer.write_all(&provider_id.to_le_bytes()).await?; + let content_len = content_bytes.len(); + if let Ok(len) = u32::try_from(content_len) { + writer.write_all(&len.to_le_bytes()).await?; + writer.write_all(&content_bytes).await?; + return Ok(content_len + 4usize); + } + error!("Cant write to WAL file, content length exceeds u32"); } - error!("Cant write to WAL file, content length exceeds u32"); } Ok(0) } -fn should_update_series_info(pli: &mut PlaylistItem, processed_provider_ids: &HashMap) -> (bool, u32, u64) { - should_update_info(pli, processed_provider_ids, crate::model::XC_TAG_SERIES_INFO_LAST_MODIFIED) -} - async fn playlist_resolve_series_info(cfg: &AppConfig, client: &reqwest::Client, errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>, resolve_delay: u16) -> bool { // TODO read existing WAL File and import it to avoid duplicate requests @@ -81,33 +76,36 @@ async fn playlist_resolve_series_info(cfg: &AppConfig, client: &reqwest::Client, // LocalSeriesInfo entries are not resolved! for plg in &mut fpl.playlistgroups { - if plg.xtream_cluster != XtreamCluster::Series { - continue; - } for pli in &mut plg.channels { if pli.header.item_type != PlaylistItemType::SeriesInfo { continue; } - let (should_update, provider_id, ts) = should_update_series_info(pli, &processed_info_ids); + let (should_update, provider_id, ts) = should_update_info(pli, &processed_info_ids); if should_update && provider_id != 0 && fetched_in_run.insert(provider_id) { if let Some(content) = playlist_resolve_download_playlist_item(client, pli, fpl.input, errors, resolve_delay, XtreamCluster::Series).await { - let normalized_content = normalize_json_content(content); - let normalized_str = normalized_content.as_str(); - handle_error_and_return!(write_series_info_to_wal_file(provider_id, ts, normalized_str, &mut content_writer, &mut record_writer).await, + if let Ok(info) = serde_json::from_str::(&content) { + let series_stream_props = SeriesStreamProperties::from_info(&info, pli); + handle_error_and_return!(write_series_info_to_wal_file(provider_id, ts, &serde_json::to_string(&series_stream_props).unwrap_or_else(|_|String::new()), + &mut content_writer, &mut record_writer).await, |err| errors.push(notify_err!(format!("Failed to resolve series, could not write to wal file {err}")))); - processed_info_ids.insert(provider_id, ts); - content_updated = true; - write_counter += normalized_str.len(); + processed_info_ids.insert(provider_id, ts); + content_updated = true; + write_counter += content.len(); - // periodic flush to bound BufWriter memory - if write_counter >= IO_BUFFER_SIZE { - write_counter = 0; - if let Err(err) = content_writer.flush().await { - errors.push(notify_err!(format!("Failed periodic flush of wal content writer {err}"))); - } - if let Err(err) = record_writer.flush().await { - errors.push(notify_err!(format!("Failed periodic flush of wal record writer {err}"))); + // periodic flush to bound BufWriter memory + if write_counter >= IO_BUFFER_SIZE { + write_counter = 0; + if let Err(err) = content_writer.flush().await { + errors.push(notify_err!(format!("Failed periodic flush of wal content writer {err}"))); + } + if let Err(err) = record_writer.flush().await { + errors.push(notify_err!(format!("Failed periodic flush of wal record writer {err}"))); + } } + + // Update in-memory playlist items with the newly fetched vod info. + // This makes the data available for subsequent processing steps like STRM export. + pli.header.additional_properties = Some(StreamProperties::Series(Box::new(series_stream_props))); } } } @@ -145,6 +143,7 @@ async fn playlist_resolve_series_info(cfg: &AppConfig, client: &reqwest::Client, // - series_record.db which contains the series_info provider_id and timestamp !processed_info_ids.is_empty() } + async fn process_series_info( app_config: &AppConfig, fpl: &mut FetchedPlaylist<'_>, @@ -192,36 +191,30 @@ async fn process_series_info( warn!("Series info content is empty, skipping series with provider id: {provider_id}"); continue; } - match serde_json::from_str::(&content) { + match serde_json::from_str::(&content) { Ok(series_content) => { let (group, series_name) = { let header = &pli.header; (header.group.clone(), if header.name.is_empty() { header.title.clone() } else { header.name.clone() }) }; - match parse_xtream_series_info(&series_content, &group, &series_name, input) { - Ok(Some(mut series)) => { - for (episode, pli_episode) in &mut series { - let Some(provider_id) = &pli_episode.header.get_provider_id() else { continue; }; - match write_series_episode_record_to_wal_file(&mut wal_writer, *provider_id, episode).await { - Ok(written_bytes) => { - write_counter += written_bytes; - // periodic flush to bound BufWriter memory - if write_counter >= IO_BUFFER_SIZE { - write_counter = 0; - if let Err(err) = wal_writer.flush().await { - errors.push(notify_err!(format!("Failed periodic flush of wal content writer {err}"))); - } + if let Some(mut series) = parse_xtream_series_info(&series_content, &group, &series_name, input) { + for pli_episode in &mut series { + let Some(provider_id) = &pli_episode.header.get_provider_id() else { continue; }; + match write_series_episode_record_to_wal_file(&mut wal_writer, *provider_id, pli.header.additional_properties.as_ref()).await { + Ok(written_bytes) => { + write_counter += written_bytes; + // periodic flush to bound BufWriter memory + if write_counter >= IO_BUFFER_SIZE { + write_counter = 0; + if let Err(err) = wal_writer.flush().await { + errors.push(notify_err!(format!("Failed periodic flush of wal content writer {err}"))); } } - Err(err) => { errors.push(info_err!(format!("Failed to write to series episode wal file: {err}"))) } } + Err(err) => { errors.push(info_err!(format!("Failed to write to series episode wal file: {err}"))) } } - group_series.extend(series.into_iter().map(|(_, pli)| pli)); - } - Ok(None) => {} - Err(err) => { - errors.push(err); } + group_series.extend(series.into_iter()); } } Err(err) => errors.push(info_err!(format!("Failed to parse JSON: {err}"))), diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index d848ec349..193e30644 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -1,19 +1,15 @@ -use crate::model::{AppConfig, ConfigTarget}; use crate::model::FetchedPlaylist; -use crate::processing::processor::xtream::normalize_json_content; +use crate::model::{AppConfig, ConfigTarget}; use crate::processing::processor::xtream::{create_resolve_info_wal_files, playlist_resolve_download_playlist_item, read_processed_info_ids, should_update_info}; use crate::processing::processor::{create_resolve_options_function_for_xtream_target, handle_error, handle_error_and_return}; use crate::repository::xtream_repository::{write_vod_info_to_wal_file, xtream_update_input_info_file, xtream_update_input_vod_record_from_wal_file, InputVodInfoRecord}; use crate::utils; use crate::utils::IO_BUFFER_SIZE; use log::{info, log_enabled, Level}; -use serde_json::value::RawValue; -use serde_json::{Map, Value}; use shared::error::notify_err; use shared::error::TuliproxError; -use shared::model::{InputType, PlaylistEntry}; -use shared::model::{PlaylistItem, PlaylistItemType, XtreamCluster}; -use shared::utils::{get_string_from_serde_value, get_u32_from_serde_value, get_u64_from_serde_value}; +use shared::model::{InputType, PlaylistEntry, StreamProperties, VideoStreamProperties, XtreamVideoInfo}; +use shared::model::{PlaylistItemType, XtreamCluster}; use std::collections::{HashMap, HashSet}; use std::time::Instant; use tokio::io::AsyncWriteExt; @@ -24,47 +20,6 @@ async fn read_processed_vod_info_ids(cfg: &AppConfig, errors: &mut Vec Option<(u32, InputVodInfoRecord)> { - let doc = serde_json::from_str::>(content).ok()?; - - let movie_data = doc.get(crate::model::XC_TAG_VOD_INFO_MOVIE_DATA)?.as_object()?; - let provider_id = get_u32_from_serde_value( - movie_data.get(crate::model::XC_TAG_VOD_INFO_STREAM_ID)?, - )?; - - let added = movie_data - .get(crate::model::XC_TAG_VOD_INFO_ADDED) - .and_then(get_u64_from_serde_value) - .unwrap_or(0); - - let info_section = doc.get(crate::model::XC_TAG_VOD_INFO_INFO)?.as_object()?; - - let tmdb_id = info_section - .get(crate::model::XC_TAG_VOD_INFO_TMDB_ID) - .and_then(get_u32_from_serde_value) - .filter(|&id| id != 0) - .or_else(|| { - info_section - .get(crate::model::XC_TAG_VOD_INFO_TMDB) - .and_then(get_u32_from_serde_value) - }) - .unwrap_or(0); - - let release_date = info_section - .get(crate::model::XC_TAG_VOD_INFO_RELEASEDATE) - .and_then(get_string_from_serde_value); - - Some((provider_id, InputVodInfoRecord { - tmdb_id, - ts: added, - release_date, - })) -} - -fn should_update_vod_info(pli: &mut PlaylistItem, processed_provider_ids: &HashMap) -> (bool, u32, u64) { - should_update_info(pli, processed_provider_ids, crate::model::XC_TAG_VOD_INFO_ADDED) -} - pub async fn playlist_resolve_vod(app_config: &AppConfig, client: &reqwest::Client, target: &ConfigTarget, errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>) { let (resolve_movies, resolve_delay) = get_resolve_vod_options(target, fpl); if !resolve_movies { return; } @@ -90,7 +45,7 @@ pub async fn playlist_resolve_vod(app_config: &AppConfig, client: &reqwest::Clie // TODO merge both filters to one let vod_info_count = fpl.playlistgroups.iter() .flat_map(|plg| &plg.channels) - .filter(|&pli| pli.header.xtream_cluster == XtreamCluster::Video && pli.header.item_type == PlaylistItemType::Video).count(); + .filter(|&pli| pli.header.xtream_cluster == XtreamCluster::Video && pli.header.item_type == PlaylistItemType::Video).count(); info!("Found {vod_info_count} vod info to resolve"); let mut last_log_time = Instant::now(); @@ -100,25 +55,32 @@ pub async fn playlist_resolve_vod(app_config: &AppConfig, client: &reqwest::Clie for plg in &mut fpl.playlistgroups { for pli in &mut plg.channels { // LocalVideo files are not resolved - if pli.header.xtream_cluster != XtreamCluster::Video || pli.header.item_type != PlaylistItemType::Video { + if pli.header.item_type != PlaylistItemType::Video { continue; } - let (should_update, provider_id, _ts) = should_update_vod_info(pli, &processed_info_ids); + let (should_update, provider_id, _ts) = should_update_info(pli, &processed_info_ids); if should_update && provider_id != 0 && fetched_in_run.insert(provider_id) { - if let Some(content) = if pli.get_item_type().is_local() { - pli.header.additional_properties.as_ref().map(ToString::to_string) - } else { - playlist_resolve_download_playlist_item(client, pli, fpl.input, errors, resolve_delay, XtreamCluster::Video).await - } { - let normalized_content = normalize_json_content(content); - let normalized_str: &str = &normalized_content; - if let Some((provider_id, info_record)) = extract_info_record_from_vod_info(normalized_str) { - let ts = info_record.ts; - handle_error_and_return!(write_vod_info_to_wal_file(provider_id, normalized_str, &info_record, &mut content_writer, &mut record_writer).await, + if pli.get_item_type().is_local() { + continue; + } + if let Some(content) = playlist_resolve_download_playlist_item(client, pli, fpl.input, errors, resolve_delay, XtreamCluster::Video).await { + if let Ok(info) = serde_json::from_str::(&content) { + let provider_id = info.movie_data.stream_id; + let added = info.movie_data.added.parse::().unwrap_or(0); + let tmdb_id = info.info.tmdb_id.parse::().unwrap_or(0); + let info_record = InputVodInfoRecord { + tmdb_id, + ts: added, + release_date: info.info.releasedate.clone(), + }; + + let video_stream_props = VideoStreamProperties::from_info(&info, pli); + handle_error_and_return!(write_vod_info_to_wal_file(provider_id, &serde_json::to_string(&video_stream_props).unwrap_or_else(|_|String::new()), + &info_record, &mut content_writer, &mut record_writer).await, |err| errors.push(notify_err!(format!("Failed to resolve vod, could not write to wal file {err}")))); - processed_info_ids.insert(provider_id, ts); + processed_info_ids.insert(provider_id, added); content_updated = true; - write_counter += normalized_str.len(); + write_counter += content.len(); // periodic flush to bound BufWriter memory if write_counter >= IO_BUFFER_SIZE { write_counter = 0; @@ -132,13 +94,7 @@ pub async fn playlist_resolve_vod(app_config: &AppConfig, client: &reqwest::Clie // Update in-memory playlist items with the newly fetched vod info. // This makes the data available for subsequent processing steps like STRM export. - if let Ok(value) = serde_json::from_str::(normalized_str) { - if let Some(info_content) = value.get("info") { - if let Ok(raw_info) = serde_json::to_string(info_content) { - pli.header.additional_properties = serde_json::from_str::>(&raw_info).ok(); - } - } - } + pli.header.additional_properties = Some(StreamProperties::Video(Box::new(video_stream_props))); } } } diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 78a5e08c8..a199ebd76 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -1,6 +1,6 @@ use crate::api::model::{AppState, PlaylistM3uStorage, PlaylistStorage, PlaylistStorageState, PlaylistXtreamStorage}; use crate::model::{AppConfig, ConfigTarget, TargetOutput}; -use crate::model::{Epg, XtreamSeriesInfo}; +use crate::model::{Epg}; use crate::processing::processor::playlist::apply_filter_to_playlist; use crate::repository::bplustree::BPlusTree; use crate::repository::epg_repository::epg_write; @@ -12,11 +12,10 @@ use crate::repository::target_id_mapping::{TargetIdMapping, VirtualIdRecord}; use crate::repository::xtream_repository::{xtream_get_file_paths, xtream_get_storage_path, xtream_write_playlist}; use crate::utils; use log::info; -use serde_json::value::RawValue; use shared::create_tuliprox_error; use shared::error::TuliproxError; use shared::error::{info_err, TuliproxErrorKind}; -use shared::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItemHeader, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItemHeader, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem}; use shared::utils::{is_dash_url, is_hls_url}; use std::collections::HashMap; use std::path::Path; @@ -150,21 +149,24 @@ fn rewrite_local_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup], let header = &mut channel.header; if header.item_type == PlaylistItemType::LocalSeriesInfo { if let Some(episode_keys) = local_library_series.get(&header.id) { - if let Some(props) = &header.additional_properties { - let props_str = props.get(); - if let Ok(mut series_info) = serde_json::from_str::(props_str) { - if let Some(episodes) = series_info.episodes.as_mut() { - for episode in episodes.iter_mut() { - for episode_key in episode_keys { - if episode.direct_source == episode_key.path { - episode.id = episode_key.virtual_id; - break; + if let Some(stream_props) = header.additional_properties.as_mut() { + match stream_props { + StreamProperties::Live(_) + | StreamProperties::Video(_) + | StreamProperties::Episode(_) => {} + StreamProperties::Series(series) => { + if let Some(episodes) = + series.details.as_mut().and_then(|d| d.episodes.as_mut()) + { + for episode in episodes.iter_mut() { + for episode_key in episode_keys { + if episode.direct_source == episode_key.path { + episode.id = episode_key.virtual_id; + break; + } } } } - if let Ok(s) = serde_json::to_string(&series_info) { - header.additional_properties = RawValue::from_string(s).ok(); - } } } } diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index 695d3cd52..0549fdf4f 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -1,6 +1,5 @@ // Import the new MediaQuality struct use crate::model::MediaQuality; -use crate::model::XtreamSeriesEpisode; use crate::model::{ApiProxyServerInfo, AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, StrmTargetOutput}; use crate::repository::bplustree::BPlusTree; @@ -16,7 +15,7 @@ use regex::Regex; use serde::Serialize; use shared::error::{create_tuliprox_error_result, info_err}; use shared::error::{TuliproxError, TuliproxErrorKind}; -use shared::model::{ClusterFlags, FieldGetAccessor, PlaylistGroup, PlaylistItem, PlaylistItemType, StrmExportStyle, UUIDType}; +use shared::model::{ClusterFlags, EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle, UUIDType}; use shared::utils::{extract_extension_from_url, hash_bytes, hash_string_as_hex, truncate_string, ExportStyleConfig, CONSTANTS}; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; @@ -24,15 +23,6 @@ use std::sync::Arc; use tokio::fs::{create_dir_all, remove_dir, remove_file, File}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; -use serde::Deserialize; -use serde_json::value::RawValue as JsonRawValue; - -#[derive(Deserialize)] -struct AdditionalProps<'a> { - #[serde(borrow)] - info: Option<&'a JsonRawValue>, -} - /// Sanitizes a string to be safe for use as a file or directory name by /// following a strict "allow-list" approach and discarding invalid characters. fn sanitize_for_filename(text: &str, underscore_whitespace: bool) -> String { @@ -92,39 +82,6 @@ fn extract_match(name: &str, pattern: &Regex) -> (String, Option) { ) } -/// Extracts a number from a pattern (e.g., "S01" -> 1), falling back to a default value. -fn extract_season_or_episode_match_with_default( - name: &str, - pattern: &Regex, - default_value: Option<&String>, -) -> (String, Option) { - let (new_name, value) = extract_match(name, pattern); - let new_value = match value { - // we skip the prefix S or E to parse the num - Some(num_value) => num_value[1..].parse::().ok(), - None => default_value.and_then(|val| val.parse::().ok()), - }; - (new_name, new_value) -} - -// Extracts and removes an episode number (e.g., "E01") based on style patterns. -fn style_rename_season( - name: &str, - style: &ExportStyleConfig, - series_season: Option<&String>, -) -> (String, Option) { - extract_season_or_episode_match_with_default(name, &style.season, series_season) -} - -/// Renames episode numbers in media titles according to style patterns -fn style_rename_episode( - name: &str, - style: &ExportStyleConfig, - series_episode: Option<&String>, -) -> (String, Option) { - extract_season_or_episode_match_with_default(name, &style.episode, series_episode) -} - /// Extracts and formats year information from media titles fn style_rename_year<'a>( name: &'a str, @@ -171,13 +128,13 @@ fn style_rename_year<'a>( #[derive(Clone)] enum InputTmdbIndexTree { Video(BPlusTree), - Series(BPlusTree), + Series(BPlusTree), } #[derive(Clone)] enum InputTmdbIndexValue { Video(InputVodInfoRecord), - Series(XtreamSeriesEpisode), + Series(EpisodeStreamProperties), } type InputTmdbIndexMap = HashMap>; @@ -216,7 +173,7 @@ async fn get_tmdb_value( PlaylistItemType::Series | PlaylistItemType::LocalSeries => { if let Ok(tree) = - BPlusTree::::load(&tmdb_path) + BPlusTree::::load(&tmdb_path) { let tmdb_id = tree.query(&pid).map(|episode| { InputTmdbIndexValue::Series(episode.clone()) @@ -269,8 +226,8 @@ struct StrmItemInfo { url: String, series_name: Option, release_date: Option, - season: Option, - episode: Option, + season: Option, + episode: Option, added: Option, tmdb_id: Option, } @@ -290,35 +247,34 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo { let virtual_id = header.virtual_id; let input_name = header.input_name.clone(); let url = header.url.clone(); - let (series_name, release_date, added, season, episode, tmdb_id) = match header.item_type { + let (series_name, release_date, added, tmdb_id, season, episode) = match header.item_type { PlaylistItemType::Series | PlaylistItemType::LocalSeries => { - let series_name = match header.get_field("name") { - Some(name) if !name.is_empty() => Some(name.to_string()), - _ => header.get_additional_property_as_str("series_name"), + let series_name = Some(header.name.clone()); + let (release_date, added, tmdb_id, season, episode) = match header.additional_properties.as_ref() { + None => (None, None, None, None, None), + Some(props) => ( + props.get_release_date(), + props.get_added(), + props.get_tmdb_id().filter(|&id| id != 0), + props.get_season(), + props.get_episode(), + ) }; - let release_date = header - .get_additional_property_as_str("series_release_date") - .or_else(|| header.get_additional_property_as_str("release_date")); - let season = header.get_additional_property_as_str("season"); - let episode = header.get_additional_property_as_str("episode"); - let added = header.get_additional_property_as_u64("added"); - let tmdb_id = header - .get_additional_property_as_u32("tmdb_id") - .filter(|&id| id != 0) - .or_else(|| header.get_additional_property_as_u32("tmdb")); - (series_name, release_date, added, season, episode, tmdb_id) + (series_name, release_date, added, tmdb_id, season, episode) } PlaylistItemType::Video | PlaylistItemType::LocalVideo => { - let name = header.get_field("name").map(|v| v.to_string()); - let release_date = header.get_additional_property_as_str("release_date"); - let added = header.get_additional_property_as_u64("added"); - let tmdb_id = header - .get_additional_property_as_u32("tmdb_id") - .filter(|&id| id != 0) - .or_else(|| header.get_additional_property_as_u32("tmdb")); - (name, release_date, added, None, None, tmdb_id) + let name = Some(header.name.clone()); + let (release_date, added, tmdb_id) = match header.additional_properties.as_ref() { + None => (None, None, None), + Some(props) => ( + props.get_release_date(), + props.get_added(), + props.get_tmdb_id().filter(|&id| id != 0), + ) + }; + (name, release_date, added, tmdb_id, None, None) } _ => (None, None, None, None, None, None), }; @@ -334,7 +290,7 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo { release_date, season, episode, - added, + added: added.as_ref().map_or_else(|| Some(0), |a| a.parse::().ok()), tmdb_id, } } @@ -478,10 +434,8 @@ fn format_for_kodi( let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref()); - let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref()); - let season_num = season.unwrap_or(1); - let episode_num = episode.unwrap_or(1); + let season_num=strm_item_info.season.unwrap_or(1u32); + let episode_num = strm_item_info.episode.unwrap_or(1u32); let final_filename = format!("{sanitized_series_name}{separator}S{season_num:02}E{episode_num:02}"); let season_folder = format!("Season{separator}{season_num:02}"); @@ -542,10 +496,8 @@ fn format_for_plex( let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref()); - let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref()); - let season_num = season.unwrap_or(1); - let episode_num = episode.unwrap_or(1); + let season_num = strm_item_info.season.unwrap_or(1); + let episode_num = strm_item_info.episode.unwrap_or(1); // Plex standard: lowercase 's' and hyphens as separators. let final_filename = format!("{sanitized_series_name} - s{season_num:02}e{episode_num:02}"); @@ -609,10 +561,8 @@ fn format_for_emby( let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref()); - let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref()); - let season_num = season.unwrap_or(1); - let episode_num = episode.unwrap_or(1); + let season_num = strm_item_info.season.unwrap_or(1); + let episode_num = strm_item_info.episode.unwrap_or(1); // Emby/Jellyfin standard: uppercase 'S' and hyphens. let final_filename = format!("{sanitized_series_name} - S{season_num:02}E{episode_num:02}"); @@ -674,10 +624,8 @@ fn format_for_jellyfin( let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref()); - let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref()); - let season_num = season.unwrap_or(1); - let episode_num = episode.unwrap_or(1); + let season_num = strm_item_info.season.unwrap_or(1); + let episode_num = strm_item_info.episode.unwrap_or(1); // Emby/Jellyfin standard: uppercase 'S' and hyphens. let final_filename = format!("{sanitized_series_name} - S{season_num:02}E{episode_num:02}"); @@ -719,7 +667,7 @@ async fn style_based_rename( let tmdb_id = match tmdb_id_val { Some(InputTmdbIndexValue::Video(r)) if r.tmdb_id != 0 => r.tmdb_id, - Some(InputTmdbIndexValue::Series(e)) if e.tmdb_id != 0 => e.tmdb_id, + Some(InputTmdbIndexValue::Series(e)) if e.tmdb.is_some_and(|e| e != 0) => e.tmdb.unwrap_or_default(), _ => strm_item_info.tmdb_id.unwrap_or(0), }; @@ -765,28 +713,7 @@ async fn prepare_strm_files( // Conditionally generate the quality string based on the new config flag let separator = if strm_target_output.underscore_whitespace { "_" } else { " " }; - let quality_string = if strm_target_output.add_quality_to_filename { - pli.header.additional_properties - .as_ref() - .and_then(|raw| { - let props: AdditionalProps<'_> = serde_json::from_str(raw.get()).ok()?; - let info = props.info?; - serde_json::from_str::(info.get()).ok() - }) - .as_ref() - .and_then(MediaQuality::from_ffprobe_info) - .map_or_else(String::new, |quality| { - let formatted = quality.format_for_filename(separator); - if formatted.is_empty() { - String::new() - } else { - // Hard-coded separator for filename clarity. - format!(" - [{formatted}]") - } - }) - } else { - String::new() - }; + let quality_string = get_quality(strm_target_output, pli, separator); let final_filename = format!("{strm_file_name}{quality_string}"); let filename = Arc::new(final_filename); @@ -839,6 +766,32 @@ async fn prepare_strm_files( result } +fn get_quality(strm_target_output: &StrmTargetOutput, pli: &PlaylistItem, separator: &str) -> String { + if strm_target_output.add_quality_to_filename { + let (audio, video) = match pli.header.additional_properties.as_ref() { + None => (None, None), + Some(props) => { + match props { + StreamProperties::Live(_) + | StreamProperties::Series(_) => (None, None), + StreamProperties::Video(video) => + video.details.as_ref().map_or_else(|| (None, None), |d| (d.audio.as_ref(), d.video.as_ref())), + StreamProperties::Episode(episode) => + (episode.audio.as_ref(), episode.video.as_ref()) + } + } + }; + if let Some(media_quality) = MediaQuality::from_ffprobe_info(audio, video) { + let formatted = media_quality.format_for_filename(separator); + if !formatted.is_empty() { + // Hard-coded separator for filename clarity. + return format!(" - [{formatted}]") + } + } + } + String::new() +} + pub async fn write_strm_playlist( app_config: &AppConfig, target: &ConfigTarget, diff --git a/backend/src/repository/xtream_playlist_iterator.rs b/backend/src/repository/xtream_playlist_iterator.rs index e2ca6f433..7957a3774 100644 --- a/backend/src/repository/xtream_playlist_iterator.rs +++ b/backend/src/repository/xtream_playlist_iterator.rs @@ -1,5 +1,4 @@ -use crate::model::XtreamMappingOptions; -use crate::model::{xtream_playlistitem_to_document, AppConfig, ProxyUserCredentials}; +use crate::model::{xtream_mapping_option_from_target_options, AppConfig, ProxyUserCredentials}; use crate::model::ConfigTarget; use crate::repository::indexed_document::IndexedDocumentIterator; use crate::repository::user_repository::user_get_bouquet_filter; @@ -9,7 +8,7 @@ use log::error; use serde_json::Value; use shared::error::info_err; use shared::error::TuliproxError; -use shared::model::{PlaylistItemType, TargetType, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{PlaylistItemType, TargetType, XtreamCluster, XtreamMappingOptions, XtreamPlaylistItem}; use std::collections::HashSet; pub struct XtreamPlaylistIterator { @@ -18,8 +17,6 @@ pub struct XtreamPlaylistIterator { cluster: XtreamCluster, // Use parsed numeric filter to avoid per-item String allocations (no to_string per check) filter_ids: Option>, - base_url: String, - user: ProxyUserCredentials, lookup_item: Option<(XtreamPlaylistItem, bool)>, // this is for filtered iteration _file_lock: FileReadGuard, } @@ -47,8 +44,8 @@ impl XtreamPlaylistIterator { let reader = IndexedDocumentIterator::::new(&xtream_path, &idx_path) .map_err(|err| info_err!(format!("Could not deserialize file {xtream_path:?} - {err}")))?; - let options = XtreamMappingOptions::from_target_options(target, xtream_output, app_config); let server_info = app_config.get_user_server_info(user); + let options = xtream_mapping_option_from_target_options(target, xtream_output, app_config, user, Some(server_info.get_base_url().as_str())); let filter = user_get_bouquet_filter(&config, &user.username, category_id, TargetType::Xtream, cluster).await; // Parse bouquet filter (strings) once into u32 set to minimize per-item allocations @@ -67,8 +64,6 @@ impl XtreamPlaylistIterator { cluster, filter_ids, _file_lock: file_lock, - base_url: server_info.get_base_url(), - user: user.clone(), lookup_item: None, }) } else { @@ -77,11 +72,10 @@ impl XtreamPlaylistIterator { } fn matches_filters(cluster: XtreamCluster, filter_ids: Option<&HashSet>, item: &XtreamPlaylistItem) -> bool { - if cluster == XtreamCluster::Series && !matches!(item.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) { - return false; - } + return false; + } // category_id-Filter if let Some(set) = filter_ids { @@ -154,14 +148,14 @@ impl XtreamPlaylistJsonIterator { } } -pub fn to_doc(pli: &XtreamPlaylistItem, url: &str, options: &XtreamMappingOptions, user: &ProxyUserCredentials) -> Value { - xtream_playlistitem_to_document(pli, url, options, user) +pub fn to_doc(pli: &XtreamPlaylistItem, options: &XtreamMappingOptions) -> Value { + pli.to_document(options) } impl Iterator for XtreamPlaylistJsonIterator { type Item = (String, bool); fn next(&mut self) -> Option { - self.inner.get_next().map(|(pli, has_next)| (to_doc(&pli, &self.inner.base_url, &self.inner.options, &self.inner.user).to_string(), has_next)) + self.inner.get_next().map(|(pli, has_next)| (to_doc(&pli, &self.inner.options).to_string(), has_next)) } } diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 575219944..f50ebc063 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -1,6 +1,6 @@ use crate::api::model::AppState; -use crate::model::normalize_release_date; -use crate::model::{rewrite_doc_urls, PlaylistXtreamCategory, XtreamMappingOptions, XtreamSeriesEpisode}; +use crate::model::{normalize_release_date, xtream_mapping_option_from_target_options}; +use crate::model::{rewrite_doc_urls, PlaylistXtreamCategory}; use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{Config, ConfigInput, ConfigTarget, XtreamTargetOutput}; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; @@ -19,7 +19,7 @@ use log::error; use serde::Serialize; use serde_json::{json, Map, Value}; use shared::error::{create_tuliprox_error, create_tuliprox_error_result, info_err, notify_err, str_to_io_error, to_io_error, TuliproxError, TuliproxErrorKind}; -use shared::model::{PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{EpisodeStreamProperties, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; use shared::utils::{generate_playlist_uuid, get_u32_from_serde_value, hex_encode}; use std::collections::HashMap; use std::fs; @@ -50,7 +50,6 @@ macro_rules! try_option_ok { }; } - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] pub struct InputVodInfoRecord { pub(crate) tmdb_id: u32, @@ -675,7 +674,7 @@ fn rewrite_xtream_vod_info

( movie_data.insert(crate::model::XC_TAG_STREAM_ID.to_string(), Value::Number(serde_json::value::Number::from(stream_id))); movie_data.insert(crate::model::XC_TAG_CATEGORY_ID.to_string(), Value::Number(serde_json::value::Number::from(category_id))); movie_data.insert(crate::model::XC_TAG_CATEGORY_IDS.to_string(), Value::Array(vec![Value::Number(serde_json::value::Number::from(category_id))])); - let options = XtreamMappingOptions::from_target_options(target, xtream_output, config); + let options = xtream_mapping_option_from_target_options(target, xtream_output, config, user, None); if options.skip_video_direct_source { movie_data.insert(crate::model::XC_TAG_DIRECT_SOURCE.to_string(), Value::String(String::new())); } else { @@ -767,7 +766,7 @@ async fn rewrite_xtream_series_info

( let virtual_id = pli.get_virtual_id(); { let (mut target_id_mapping, file_lock) = get_target_id_mapping(app_config, &target_path).await; - let options = XtreamMappingOptions::from_target_options(target, xtream_output, app_config); + let options = xtream_mapping_option_from_target_options(target, xtream_output, app_config, user, None); let use_memory_cache = target.use_memory_cache; let mut id_mapping_records = vec![]; @@ -1030,7 +1029,7 @@ pub async fn xtream_update_input_series_episodes_record_from_wal_file( let mut reader = async_file_reader(async_open_readonly_file(wal_path).await.map_err(|err| notify_err!(format!("Could not read series episode wal info {err}")))?); let mut provider_id_bytes = [0u8; 4]; let mut len_bytes = [0u8; 4]; - let mut tree_record_index: BPlusTree = BPlusTree::load(&record_path).unwrap_or_else(|_| BPlusTree::new()); + let mut tree_record_index: BPlusTree = BPlusTree::load(&record_path).unwrap_or_else(|_| BPlusTree::new()); let mut buffer = vec![0u8; 4096]; loop { if reader.read_exact(&mut provider_id_bytes).await.is_err() { diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 736cbbe16..5757d239b 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -320,9 +320,13 @@ pub fn create_vod_info_from_item(target: &ConfigTarget, user: &ProxyUserCredenti let category_id = pli.category_id; let stream_id = if user.proxy.is_redirect(pli.item_type) || target.is_force_redirect(pli.item_type) { pli.provider_id } else { pli.virtual_id }; let name = &pli.name; - let extension = pli.get_additional_property("container_extension") - .map_or_else(|| extract_extension_from_url(&pli.url).map_or_else(String::new, std::string::ToString::to_string), - |v| get_string_from_serde_value(&v).unwrap_or_default()); + let extension = pli + .get_container_extension() + .as_deref() + .filter(|ce| !ce.is_empty()) + .or_else(|| extract_extension_from_url(&pli.url)) + .map_or_else(String::new, std::string::ToString::to_string); + let added = last_updated / 1000; format!(r#"{{ "info": {{}}, diff --git a/shared/src/model/mapping.rs b/shared/src/model/mapping.rs index b7ec5270d..13ae8ecab 100644 --- a/shared/src/model/mapping.rs +++ b/shared/src/model/mapping.rs @@ -14,7 +14,6 @@ pub const MAPPER_FIELDS: &[&str] = &[ "time_shift", "rec", "url", "epg_channel_id", "epg_id" ]; - #[macro_export] macro_rules! valid_property { ($key:expr, $array:expr) => {{ diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index f3cd8711b..6f289b0f2 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -23,6 +23,8 @@ mod active_user_connection_change; mod user_command; pub mod system_info; mod library_request; +mod stream_properties; +mod xtream; pub use self::cluster_flags::*; pub use self::playlist::*; @@ -48,3 +50,5 @@ pub use self::active_user_connection_change::*; pub use self::user_command::*; pub use self::system_info::*; pub use self::library_request::*; +pub use self::stream_properties::*; +pub use self::xtream::*; \ No newline at end of file diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 58a9fe194..ecea2bc29 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -1,11 +1,12 @@ -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 crate::model::{xtream_const, ClusterFlags, CommonPlaylistItem, ConfigTargetOptions, EpisodeStreamProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties}; +use crate::utils::{extract_extension_from_url, generate_playlist_uuid, get_provider_id}; +use enum_iterator::Sequence; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; +use serde_json::Value; use std::borrow::Cow; +use std::fmt::Write; 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 @@ -68,7 +69,7 @@ impl TryFrom for XtreamCluster { } } -#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default)] +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default, Sequence)] #[repr(u8)] pub enum PlaylistItemType { #[default] @@ -95,7 +96,6 @@ impl From for PlaylistItemType { } } - impl FromStr for PlaylistItemType { type Err = String; @@ -127,13 +127,17 @@ impl PlaylistItemType { 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::Video | Self::LocalVideo => Self::VIDEO, Self::Series | Self::LocalSeries => Self::SERIES, Self::SeriesInfo | Self::LocalSeriesInfo => Self::SERIES_INFO, Self::Catchup => Self::CATCHUP, @@ -141,6 +145,42 @@ impl Display for PlaylistItemType { } } +#[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>; } @@ -164,7 +204,7 @@ pub struct PlaylistItemHeader { pub id: String, // provider id pub virtual_id: VirtualId, // virtual id pub name: String, - pub chno: String, + pub chno: u32, pub logo: String, pub logo_small: String, pub group: String, @@ -176,7 +216,7 @@ pub struct PlaylistItemHeader { pub url: String, pub epg_channel_id: Option, pub xtream_cluster: XtreamCluster, - pub additional_properties: Option>, + pub additional_properties: Option, #[serde(default)] pub item_type: PlaylistItemType, #[serde(default)] @@ -202,53 +242,16 @@ impl PlaylistItemHeader { } } - pub fn get_additional_property(&self, field: &str) -> Option { - let raw = self.additional_properties.as_ref()?; - let value: Value = serde_json::from_str(raw.get()).ok()?; - - let map = match value { - Value::Object(map) => map, - _ => return None, - }; - - map.get(field) - .cloned() - // fallback: info[field] - .or_else(|| { - map.get("info") - .and_then(|info| info.as_object()) - .and_then(|info_map| info_map.get(field)) - .cloned() - }) - } - - pub fn get_additional_property_as_u32(&self, field: &str) -> Option { - 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 { - 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 { - match self.get_additional_property(field) { - Some(value) => get_string_from_serde_value(&value), - None => None - } + pub fn get_container_extension(&self) -> Option { + 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() { - $line = format!("{} {}=\"{}\"", $line, $field, $header.$prop); + if !$header.$prop.is_empty() { + let _ = write!($line," {}=\"{}\"", $field, $header.$prop ); } )* }; @@ -258,7 +261,7 @@ 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)); + let _ = write!($line, " {}=\"{}/{}\"", $field, $url, stringify!($prop)); } )* }; @@ -273,6 +276,7 @@ macro_rules! generate_field_accessor_impl_for_playlist_item_header { $( 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) }), @@ -292,6 +296,14 @@ macro_rules! generate_field_accessor_impl_for_playlist_item_header { true } )* + "chno" => { + if let Ok(val) = value.parse::() { + self.chno = val; + true + } else { + false + } + }, "caption" => { self.title = val.clone(); self.name = val; @@ -308,14 +320,14 @@ macro_rules! generate_field_accessor_impl_for_playlist_item_header { } } -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;); +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: String, + pub chno: u32, pub logo: String, pub logo_small: String, pub group: String, @@ -339,9 +351,10 @@ impl M3uPlaylistItem { 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); + 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()) { @@ -351,15 +364,18 @@ impl M3uPlaylistItem { } } + if self.chno != 0 { + let _ = write!(line, " tvg-chno=\"{}\"", self.chno); + } 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, ) + let _ = write!(&mut line, ",{}\n{}", self.title, url); + line } pub fn to_common(&self) -> CommonPlaylistItem { @@ -367,7 +383,7 @@ impl M3uPlaylistItem { virtual_id: self.virtual_id, provider_id: self.provider_id.to_string(), name: self.name.clone(), - chno: self.chno.to_string(), + chno: self.chno, logo: self.logo.clone(), logo_small: self.logo_small.clone(), group: self.group.clone(), @@ -424,6 +440,7 @@ macro_rules! generate_field_accessor_impl_for_m3u_playlist_item { $( 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, @@ -433,7 +450,7 @@ macro_rules! generate_field_accessor_impl_for_m3u_playlist_item { } } -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;); +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 for CommonPlaylistItem { fn from(item: M3uPlaylistItem) -> Self { @@ -441,6 +458,25 @@ impl From for CommonPlaylistItem { } } +#[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, + pub reverse_item_types: PlaylistItemTypeSet, + pub username: String, + pub password: String, + pub base_url: Option, +} + +impl XtreamMappingOptions { + pub fn is_reverse(&self, item_type: PlaylistItemType) -> bool { + self.reverse_item_types.is_set(item_type) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XtreamPlaylistItem { pub virtual_id: VirtualId, @@ -455,21 +491,45 @@ pub struct XtreamPlaylistItem { pub url: String, pub epg_channel_id: Option, pub xtream_cluster: XtreamCluster, - pub additional_properties: Option, + pub additional_properties: Option, pub item_type: PlaylistItemType, pub category_id: u32, pub input_name: String, pub channel_no: u32, } +fn make_bdpath_resource_url(resource_url: Option<&String>, bd_path: &str, index: usize, field_prefix: &str) -> String { + if let Some(url) = resource_url { + if bd_path.starts_with("http") { + return format!("{url}/{field_prefix}{}_{index}", xtream_const::XC_PROP_BACKDROP_PATH); + } + } + bd_path.to_string() +} + +fn make_resource_url(resource_url: Option<&String>, value: &str, field: &str) -> String { + if let Some(url) = resource_url { + if value.starts_with("http") { + return format!("{url}/{field}"); + } + } + value.to_string() +} + + impl XtreamPlaylistItem { - pub fn get_additional_property(&self, field: &str) -> Option { - 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(); + pub fn get_container_extension(&self) -> Option { + match self.additional_properties { + None => None, + Some(ref props) => { + match props { + StreamProperties::Live(_) => Some("ts".to_string()), + StreamProperties::Video(video) => Some(video.container_extension.clone()), + StreamProperties::Series(_) => None, + StreamProperties::Episode(episode) => Some(episode.container_extension.clone()), + } } } - None } pub fn to_common(&self) -> CommonPlaylistItem { @@ -477,7 +537,7 @@ impl XtreamPlaylistItem { virtual_id: self.virtual_id, provider_id: self.provider_id.to_string(), name: self.name.clone(), - chno: self.channel_no.to_string(), + chno: self.channel_no, logo: self.logo.clone(), logo_small: self.logo_small.clone(), group: self.group.clone(), @@ -495,6 +555,232 @@ impl XtreamPlaylistItem { category_id: Some(self.category_id), } } + + pub fn to_document(&self, options: &XtreamMappingOptions) -> serde_json::Value { + let is_reverse = options.is_reverse(self.item_type); + let resource_url = if is_reverse && options.rewrite_resource_url && options.base_url.is_some() { + let resource_url = format!("{}/resource/{}/{}/{}/{}", options.base_url.as_ref().map_or_else(String::new, |b| b.clone()), + self.xtream_cluster.as_stream_type(), options.username, options.password, self.get_virtual_id()); + Some(resource_url) + } else { + None + }; + + if let Some(props) = self.additional_properties.as_ref() { + match props { + StreamProperties::Live(live) => { + let stream_icon = if !self.logo.is_empty() { + make_resource_url(resource_url.as_ref(), &self.logo, "logo") + } else if !self.logo_small.is_empty() { + make_resource_url(resource_url.as_ref(), &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(), Value::String(self.epg_channel_id.as_ref().map_or_else(String::new, Clone::clone))), + ("added".to_string(), Value::String(String::new())), + ("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 { Value::String(String::new()) } 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()))), + ])) + } + StreamProperties::Video(video) => { + let stream_icon = if !self.logo.is_empty() { + make_resource_url(resource_url.as_ref(), &self.logo, "logo") + } else if !self.logo_small.is_empty() { + make_resource_url(resource_url.as_ref(), &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(), Value::String(video.rating.map_or_else(String::new, |r| format!("{r:.2}")))), + ("rating_5based".to_string(), Value::String(video.rating_5based.map_or_else(String::new, |r| format!("{r:.2}")))), + ("tmdb".to_string(), Value::String(video.tmdb.as_ref().map_or_else(String::new, ToString::to_string))), + ("trailer".to_string(), Value::String(video.trailer.as_ref().map_or_else(String::new, Clone::clone))), + ("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 { Value::String(String::new()) } else { Value::String(video.direct_source.clone()) }), + ])) + } + StreamProperties::Series(series) => { + 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(make_resource_url(resource_url.as_ref(), &series.cover, xtream_const::XC_PROP_COVER))), + ("plot".to_string(), Value::String(series.plot.as_ref().map_or_else(String::new, Clone::clone))), + ("cast".to_string(), Value::String(series.cast.clone())), + ("director".to_string(), Value::String(series.director.clone())), + ("genre".to_string(), Value::String(series.genre.as_ref().map_or_else(String::new, Clone::clone))), + ("release_date".to_string(), Value::String(series.release_date.as_ref().map_or_else(String::new, Clone::clone))), + ("releaseDate".to_string(), Value::String(series.release_date.as_ref().map_or_else(String::new, Clone::clone))), + ("last_modified".to_string(), Value::String(series.last_modified.as_ref().map_or_else(String::new, Clone::clone))), + ("rating".to_string(), Value::String(format!("{:.2}", series.rating))), + ("rating_5based".to_string(), Value::String(format!("{:.2}", 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( + make_bdpath_resource_url(resource_url.as_ref(), 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(), Value::String(series.episode_run_time.as_ref().map_or_else(String::new, Clone::clone))), + ("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))]))), + ])) + } + StreamProperties::Episode(_episode) => { + Value::Object(serde_json::Map::from_iter([])) + + // + // impl XtreamSeriesInfoEpisode { + // pub fn get_additional_properties(&self, series_info: &XtreamSeriesInfo) -> Option { + // let mut result = serde_json::Map::new(); + // let info = series_info.info.as_ref(); + // let bdpath = info.and_then(|i| i.backdrop_path.as_ref()); + // let bdpath_is_set = bdpath.as_ref().is_some_and(|bdpath| !bdpath.is_empty()); + // if bdpath_is_set { + // result.insert(String::from("backdrop_path"), Value::Array(Vec::from([Value::String(String::from(bdpath?.first()?))]))); + // } + // add_str_property_if_exists!(result, info.map_or("", |i| i.name.as_str()), "series_name"); + // add_str_property_if_exists!(result, info.map_or("", |i| get_non_empty_str(i.release_date.as_str(), i.releaseDate.as_str(), i.releasedate.as_str())), "series_release_date"); + // add_str_property_if_exists!(result, self.added.as_str(), "added"); + // add_str_property_if_exists!(result, info.map_or("", |i| i.cast.as_str()), "cast"); + // add_str_property_if_exists!(result, self.container_extension.as_str(), "container_extension"); + // add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| info.movie_image.as_str()), "cover"); + // add_str_property_if_exists!(result, info.map_or("", |i| i.director.as_str()), "director"); + // add_str_property_if_exists!(result, info.map_or("", |i| i.episode_run_time.as_str()), "episode_run_time"); + // add_str_property_if_exists!(result, info.map_or("", |i| i.last_modified.as_str()), "last_modified"); + // add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| info.plot.as_str()), "plot"); + // add_f64_property_if_exists!(result, info.map_or(0_f64, |i| i.rating), "rating"); + // add_f64_property_if_exists!(result, info.map_or(0_f64, |i| i.rating_5based), "rating_5based"); + // add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| get_non_empty_str(info.release_date.as_str(), info.releaseDate.as_str(), info.releasedate.as_str())), "release_date"); + // add_str_property_if_exists!(result, self.title, "title"); + // add_i64_property_if_exists!(result, self.season, "season"); + // add_i64_property_if_exists!(result, self.episode_num, "episode"); + // let series_tmdb_id = info.and_then(|i| i.tmdb_id.or(i.tmdb)); + // add_opt_i64_property_if_exists!(result, self.info.as_ref().and_then(|info| info.tmdb_id.or(info.tmdb.or(series_tmdb_id))), "tmdb_id"); + // + // // Add the "info" section to the playlist item additional properties. + // if let Some(episode_info) = &self.info { + // if let Ok(info_value) = serde_json::to_value(episode_info) { + // result.insert("info".to_string(), info_value); + // } + // } + // + // if result.is_empty() { + // None + // } else { + // serde_json::to_string(&Value::Object(result)).ok() + // } + // } + } + } + } else { + match self.xtream_cluster { + XtreamCluster::Live => { + let stream_icon = if !self.logo.is_empty() { + make_resource_url(resource_url.as_ref(), &self.logo, "logo") + } else if !self.logo_small.is_empty() { + make_resource_url(resource_url.as_ref(), &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(), Value::String(self.epg_channel_id.as_ref().map_or_else(String::new, Clone::clone))), + ("added".to_string(), Value::String(String::new())), + ("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(), Value::String(String::new())), + ("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() { + make_resource_url(resource_url.as_ref(), &self.logo, "logo") + } else if !self.logo_small.is_empty() { + make_resource_url(resource_url.as_ref(), &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(), Value::String(String::new())), + ("rating_5based".to_string(), Value::String(String::new())), + ("tmdb".to_string(), Value::String(String::new())), + ("trailer".to_string(), Value::String(String::new())), + ("added".to_string(), Value::String(String::new())), + ("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(), Value::String(String::new())), + ("custom_sid".to_string(), Value::Null), + ("direct_source".to_string(), Value::String(String::new())), + ])) + } + XtreamCluster::Series => { + let stream_icon = if !self.logo.is_empty() { + make_resource_url(resource_url.as_ref(), &self.logo, "logo") + } else if !self.logo_small.is_empty() { + make_resource_url(resource_url.as_ref(), &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(), Value::String(String::new())), + ("cast".to_string(), Value::String(String::new())), + ("director".to_string(), Value::String(String::new())), + ("genre".to_string(), Value::String(String::new())), + ("release_date".to_string(), Value::String(String::new())), + ("releaseDate".to_string(), Value::String(String::new())), + ("last_modified".to_string(), Value::String(String::new())), + ("rating".to_string(), Value::String(String::new())), + ("rating_5based".to_string(), Value::String(String::new())), + ("backdrop_path".to_string(), Value::Array(Vec::new())), + ("youtube_trailer".to_string(), Value::String(String::new())), + ("tmdb".to_string(), Value::String(String::new())), + ("episode_runtime".to_string(), Value::String(String::new())), + ("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))]))), + ])) + } + } + } + } } impl PlaylistEntry for XtreamPlaylistItem { @@ -564,27 +850,46 @@ macro_rules! generate_field_accessor_impl_for_xtream_playlist_item { "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::>(add_props).ok()); - return match props { - Some(doc) => { - return if field == xtream_const::XC_PROP_COVER { - doc.get(&field) - .or_else(|| doc.get("info").and_then(|d| d.get(&field))) - .and_then(|value| value.as_str()) - .map(|s| Cow::::Owned(s.to_owned())) - } else { - doc.get(xtream_const::XC_PROP_BACKDROP_PATH) - .or_else(|| doc.get("info").and_then(|d| d.get(xtream_const::XC_PROP_BACKDROP_PATH))) - .and_then(|value| get_backdrop_path_value(&field, Some(value))) - .map(|s| Cow::::Owned(s.into_owned())) + 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::::Borrowed(s)) + }) + } else { + None + } } - } - _=> None, + StreamProperties::Series(series) => { + if field == xtream_const::XC_PROP_COVER { + series.backdrop_path.as_ref().and_then(|p| p.first()).map(|s| Cow::::Borrowed(s)) + } else { + None + } + } + StreamProperties::Episode(episode) => { + if field == xtream_const::XC_PROP_COVER { + Some(Cow::::Borrowed(&episode.movie_image)) + } else { + None + } + } + }, + None => None, } + } else { + None } - None - }, + } + } } } @@ -597,7 +902,6 @@ impl From for CommonPlaylistItem { } } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlaylistItem { #[serde(flatten)] @@ -613,7 +917,7 @@ impl PlaylistItem { 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(), + chno: header.chno, logo: header.logo.to_string(), logo_small: header.logo_small.to_string(), group: header.group.to_string(), @@ -634,35 +938,7 @@ impl PlaylistItem { pub fn to_xtream(&self) -> XtreamPlaylistItem { let header = &self.header; let provider_id = header.id.parse::().unwrap_or_default(); - let mut additional_properties: Option = None; - - if header.xtream_cluster != XtreamCluster::Live && header.item_type != PlaylistItemType::LocalVideo { - 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 = 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() - }); - } + let additional_properties = Self::get_additional_properties(header); XtreamPlaylistItem { virtual_id: header.virtual_id, @@ -681,43 +957,14 @@ impl PlaylistItem { item_type: header.item_type, category_id: header.category_id, input_name: header.input_name.to_string(), - channel_no: header.chno.parse::().unwrap_or(0), + channel_no: header.chno, } } pub fn to_common(&self) -> CommonPlaylistItem { let header = &self.header; - let mut additional_properties: Option = 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 = 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() - }); - } + let additional_properties = Self::get_additional_properties(header); CommonPlaylistItem { virtual_id: header.virtual_id, @@ -738,9 +985,85 @@ impl PlaylistItem { item_type: header.item_type, category_id: Some(header.category_id), input_name: header.input_name.clone(), - chno: header.chno.clone(), + chno: header.chno, } } + + + fn get_additional_properties(header: &PlaylistItemHeader) -> Option { + 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: "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 to_info_document(&self) -> serde_json::Value { + Value::Null + } } impl PlaylistEntry for PlaylistItem { diff --git a/shared/src/model/playlist_request.rs b/shared/src/model/playlist_request.rs index 132b0f48c..c76d113fa 100644 --- a/shared/src/model/playlist_request.rs +++ b/shared/src/model/playlist_request.rs @@ -1,4 +1,4 @@ -use crate::model::{PlaylistItemType, SearchRequest, XtreamCluster}; +use crate::model::{PlaylistItemType, SearchRequest, StreamProperties, XtreamCluster}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::rc::Rc; @@ -28,7 +28,7 @@ pub struct CommonPlaylistItem { pub virtual_id: u32, pub provider_id: String, pub name: String, - pub chno: String, + pub chno: u32, pub logo: String, pub logo_small: String, pub group: String, @@ -45,7 +45,7 @@ pub struct CommonPlaylistItem { #[serde(skip_serializing_if = "Option::is_none")] pub xtream_cluster: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub additional_properties: Option, + pub additional_properties: Option, #[serde(skip_serializing_if = "Option::is_none")] pub category_id: Option, } diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs new file mode 100644 index 000000000..c3750ca03 --- /dev/null +++ b/shared/src/model/stream_properties.rs @@ -0,0 +1,501 @@ +use crate::model::{PlaylistEntry, PlaylistItem, XtreamSeriesInfo, XtreamVideoInfo}; +use crate::utils::{deserialize_as_option_string, deserialize_as_string, deserialize_as_string_array, + deserialize_json_as_string, deserialize_number_from_string, + deserialize_number_from_string_or_zero, opt_string_or_number_u32, string_default_on_null, + string_or_number_u32}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct LiveStreamProperties { + #[serde(default)] + pub name: String, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub category_id: u32, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub stream_id: u32, + #[serde(default)] + pub stream_icon: String, + #[serde(default)] + pub direct_source: String, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub custom_sid: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub added: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub stream_type: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub epg_channel_id: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub tv_archive: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub tv_archive_duration: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub is_adult: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct VideoStreamDetailProperties { + pub kinopoisk_url: Option, + pub o_name: Option, + pub cover_big: Option, + pub movie_image: Option, + pub release_date: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub episode_run_time: Option, + pub youtube_trailer: Option, + pub director: Option, + pub actors: Option, + pub cast: Option, + pub description: Option, + pub plot: Option, + pub age: Option, + pub mpaa_rating: Option, + #[serde(default)] + pub rating_count_kinopoisk: u32, + pub country: Option, + pub genre: Option, + #[serde(default, deserialize_with = "deserialize_as_string_array")] + pub backdrop_path: Option>, + pub duration_secs: Option, + pub duration: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub video: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub audio: Option, + #[serde(default)] + pub bitrate: u32, + pub runtime: Option, + pub status: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct VideoStreamProperties { + #[serde(default, deserialize_with = "deserialize_as_string")] + pub name: String, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub category_id: u32, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub stream_id: u32, + #[serde(default)] + pub stream_icon: String, + #[serde(default)] + pub direct_source: String, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub custom_sid: Option, + #[serde(default)] + pub added: String, + #[serde(default)] + pub container_extension: String, + #[serde(default)] + pub rating: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub rating_5based: Option, + #[serde(default)] + pub stream_type: String, + #[serde(default)] + pub trailer: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub tmdb: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub is_adult: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct SeriesStreamDetailEpisodeProperties { + #[serde(default, deserialize_with = "string_or_number_u32")] + pub id: u32, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub episode_num: u32, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub season: u32, + #[serde(default, deserialize_with = "string_default_on_null")] + pub title: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub container_extension: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub custom_sid: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub added: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub direct_source: String, + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub tmdb: Option, + #[serde(default, deserialize_with = "string_default_on_null")] + pub release_date: String, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub plot: Option, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub duration_secs: u32, + #[serde(default, deserialize_with = "string_default_on_null")] + pub duration: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub movie_image: String, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub bitrate: u32, + #[serde(default)] + pub rating: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub video: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub audio: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct SeriesStreamDetailProperties { + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub year: Option, + pub episodes: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct SeriesStreamProperties { + #[serde(default, deserialize_with = "deserialize_as_string")] + pub name: String, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub category_id: u32, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub series_id: u32, + #[serde(default, deserialize_with = "deserialize_as_string_array")] + pub backdrop_path: Option>, + #[serde(default)] + pub cast: String, + #[serde(default)] + pub cover: String, + #[serde(default)] + pub director: String, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub episode_run_time: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub genre: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub last_modified: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub plot: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub rating: f64, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub rating_5based: f64, + pub release_date: Option, + #[serde(default)] + pub youtube_trailer: String, + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub tmdb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct EpisodeStreamProperties { + pub episode_id: u32, + pub episode: u32, + pub season: u32, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub added: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub release_date: Option, + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub tmdb: Option, + #[serde(default)] + pub movie_image: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub container_extension: String, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub video: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub audio: Option, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum StreamProperties { + Live(LiveStreamProperties), + Video(Box), + Series(Box), + Episode(EpisodeStreamProperties), +} + +impl StreamProperties { + pub fn prepare(&mut self) { + match self { + StreamProperties::Live(live) => { + live.epg_channel_id = live.epg_channel_id.as_ref() + .filter(|epg_id| !epg_id.trim().is_empty()) + .map(|epg_id| epg_id.to_lowercase()) + .or(None); + } + StreamProperties::Video(_) => {} + StreamProperties::Series(_) => {} + StreamProperties::Episode(_) => {} + } + } + + pub fn get_category_id(&self) -> u32 { + match self { + StreamProperties::Live(live) => live.category_id, + StreamProperties::Video(video) => video.category_id, + StreamProperties::Series(series) => series.category_id, + StreamProperties::Episode(_episode) => 0, + } + } + + pub fn get_stream_id(&self) -> u32 { + match self { + StreamProperties::Live(live) => live.stream_id, + StreamProperties::Video(video) => video.stream_id, + StreamProperties::Series(series) => series.series_id, + StreamProperties::Episode(episode) => episode.episode_id, + } + } + + pub fn get_stream_icon(&self) -> Cow<'_, str> { + match self { + StreamProperties::Live(live) => Cow::Borrowed(&live.stream_icon), + StreamProperties::Video(video) => Cow::Borrowed(&video.stream_icon), + StreamProperties::Series(series) => Cow::Borrowed(&series.cover), + StreamProperties::Episode(episode) => Cow::Borrowed(&episode.movie_image), + } + } + + pub fn get_name(&self) -> Cow<'_, str> { + match self { + StreamProperties::Live(live) => Cow::Borrowed(&live.name), + StreamProperties::Video(video) => Cow::Borrowed(&video.name), + StreamProperties::Series(series) => Cow::Borrowed(&series.name), + StreamProperties::Episode(_episode) => Cow::Borrowed(""), + } + } + + pub fn get_epg_channel_id(&self) -> Option { + match self { + StreamProperties::Live(live) => live.epg_channel_id.clone(), + StreamProperties::Video(_) => None, + StreamProperties::Series(_) => None, + StreamProperties::Episode(_) => None, + } + } + + pub fn get_tmdb_id(&self) -> Option { + match self { + StreamProperties::Live(_) => None, + StreamProperties::Video(video) => video.tmdb, + StreamProperties::Series(series) => series.tmdb, + StreamProperties::Episode(epsiode) => epsiode.tmdb, + } + } + + pub fn get_release_date(&self) -> Option { + match self { + StreamProperties::Live(_) => None, + StreamProperties::Video(video) => video.details.as_ref().and_then(|d| d.release_date.clone()), + StreamProperties::Series(series) => series.release_date.clone(), + StreamProperties::Episode(episode) => episode.release_date.clone(), + } + } + + pub fn get_added(&self) -> Option { + match self { + StreamProperties::Live(_) => None, + StreamProperties::Video(video) => Some(video.added.clone()), + StreamProperties::Series(series) => series.details.as_ref() + .and_then(|d| d.episodes.as_ref()) + .and_then(|e| e.first().map(|e| e.added.clone())), + StreamProperties::Episode(episode) => episode.added.clone(), + } + } + + pub fn get_container_extension(&self) -> Option { + match self { + StreamProperties::Live(_) => None, + StreamProperties::Video(video) => if video.container_extension.is_empty() { None } else { Some(video.container_extension.clone()) }, + StreamProperties::Series(series) => series.details.as_ref() + .and_then(|d| d.episodes.as_ref()) + .and_then(|e| e.first().and_then(|e| { + if e.container_extension.is_empty() { None } else { Some(e.container_extension.clone()) } + })), + StreamProperties::Episode(episode) => if episode.container_extension.is_empty() { None } else { Some(episode.container_extension.clone()) }, + } + } + + pub fn get_direct_source(&self) -> Option { + match self { + StreamProperties::Live(_) => None, + StreamProperties::Video(video) => if video.direct_source.is_empty() { None } else { Some(video.direct_source.clone()) }, + StreamProperties::Series(series) => series.details.as_ref() + .and_then(|d| d.episodes.as_ref()) + .and_then(|e| e.first().and_then(|e| { + if e.direct_source.is_empty() { None } else { Some(e.direct_source.clone()) } + })), + StreamProperties::Episode(_episode) => None, + } + } + + pub fn get_season(&self) -> Option { + match self { + StreamProperties::Live(_) + | StreamProperties::Video(_) + | StreamProperties::Series(_) => None, + StreamProperties::Episode(episode) => Some(episode.season), + } + } + + pub fn get_episode(&self) -> Option { + match self { + StreamProperties::Live(_) + | StreamProperties::Video(_) + | StreamProperties::Series(_) => None, + StreamProperties::Episode(episode) => Some(episode.episode), + } + } + + pub fn get_last_modified(&self) -> Option { + match self { + StreamProperties::Live(_) => None, + StreamProperties::Video(video) => video.added.parse::().ok(), + StreamProperties::Series(series) => series.last_modified.as_ref().and_then(|v| v.parse::().ok()), + StreamProperties::Episode(episode) => episode.added.as_ref().and_then(|v| v.parse::().ok()), + } + } +} + +impl VideoStreamProperties { + pub fn from_info(info: &XtreamVideoInfo, pli: &PlaylistItem) -> VideoStreamProperties { + let mut props = VideoStreamProperties { + name: info.info.name.clone(), + category_id: info.movie_data.category_id, + stream_id: info.movie_data.stream_id, + stream_icon: info.info.movie_image.as_ref().map_or_else(String::new, Clone::clone), + direct_source: info.movie_data.direct_source.clone(), + custom_sid: info.movie_data.custom_sid.clone(), + added: info.movie_data.added.clone(), + container_extension: info.movie_data.container_extension.clone(), + rating: None, // from PlaylistItem + rating_5based: None, // from PlaylistItem + stream_type: "movie".to_string(), + trailer: info.info.youtube_trailer.clone(), + tmdb: info.info.tmdb_id.parse::().ok(), + is_adult: 0, // from PlaylistItem + details: Some(VideoStreamDetailProperties { + kinopoisk_url: info.info.kinopoisk_url.clone(), + o_name: info.info.o_name.clone(), + cover_big: info.info.cover_big.clone(), + movie_image: info.info.movie_image.clone(), + release_date: info.info.releasedate.clone(), + episode_run_time: info.info.episode_run_time, + youtube_trailer: info.info.youtube_trailer.clone(), + director: info.info.director.clone(), + actors: info.info.actors.clone(), + cast: info.info.cast.clone(), + description: info.info.description.clone(), + plot: info.info.plot.clone(), + age: info.info.age.clone(), + mpaa_rating: info.info.mpaa_rating.clone(), + rating_count_kinopoisk: info.info.rating_count_kinopoisk, + country: info.info.country.clone(), + genre: info.info.genre.clone(), + backdrop_path: info.info.backdrop_path.clone(), + duration_secs: info.info.duration_secs.clone(), + duration: info.info.duration.clone(), + video: info.info.video.clone(), + audio: info.info.audio.clone(), + bitrate: info.info.bitrate, + runtime: info.info.runtime.clone(), + status: info.info.status.clone(), + }), + }; + + if let Some(StreamProperties::Video(video)) = pli.header.additional_properties.as_ref() { + props.rating = video.rating; + props.rating_5based = video.rating_5based; + props.stream_type = video.stream_type.clone(); + if props.tmdb.is_none() { + props.tmdb = video.tmdb; + } + if props.trailer.is_none() { + props.trailer = video.trailer.clone(); + } + props.is_adult = video.is_adult; + } + + props + } +} + + +impl SeriesStreamProperties { + pub fn from_info(info: &XtreamSeriesInfo, pli: &PlaylistItem) -> SeriesStreamProperties { + SeriesStreamProperties { + name: info.info.name.clone(), + category_id: info.info.category_id, + series_id: pli.get_virtual_id(), + backdrop_path: info.info.backdrop_path.clone(), + cast: info.info.cast.clone(), + cover: info.info.cover.clone(), + director: info.info.director.clone(), + episode_run_time: Some(info.info.episode_run_time.clone()), + genre: Some(info.info.genre.clone()), + last_modified: Some(info.info.last_modified.clone()), + plot: Some(info.info.plot.clone()), + rating: info.info.rating, + rating_5based: info.info.rating_5based, + release_date: Some(info.info.release_date.clone()), + youtube_trailer: info.info.youtube_trailer.clone(), + tmdb: info.info.tmdb, + details: Some(SeriesStreamDetailProperties { + year: extract_year_from_release_date(&info.info.release_date), + episodes: info.episodes.as_ref().map(|list| + list.iter().map(|e| { + SeriesStreamDetailEpisodeProperties { + id: e.id, + episode_num: e.episode_num, + season: e.season, + title: e.title.clone(), + container_extension: e.container_extension.clone(), + custom_sid: e.custom_sid.clone(), + added: e.added.clone(), + direct_source: e.direct_source.clone(), + tmdb: info.info.tmdb, + release_date: e.info.as_ref().map(|i| i.air_date.clone()).unwrap_or_default(), + plot: None, + duration_secs: e.info.as_ref().map(|i| i.duration_secs).unwrap_or_default(), + duration: e.info.as_ref().map(|i| i.duration.clone()).unwrap_or_default(), + movie_image: e.info.as_ref().map(|i| i.movie_image.clone()).unwrap_or_default(), + bitrate: e.info.as_ref().map(|i| i.bitrate).unwrap_or_default(), + rating: e.info.as_ref().map(|i| i.rating), + video: e.info.as_ref().map(|i| i.video.clone()).unwrap_or_default(), + audio: e.info.as_ref().map(|i| i.audio.clone()).unwrap_or_default(), + } + }).collect()) + }), + } + } +} + +impl EpisodeStreamProperties { + + pub fn from_series(series: &SeriesStreamProperties, episode: &SeriesStreamDetailEpisodeProperties) -> EpisodeStreamProperties { + EpisodeStreamProperties { + episode_id: episode.id, + episode: episode.episode_num, + season: episode.season, + added: Some(episode.added.clone()), + release_date: Some(episode.release_date.clone()), + tmdb: episode.tmdb.or(series.tmdb), + movie_image: episode.movie_image.clone(), + container_extension: episode.container_extension.clone(), + video: episode.video.clone(), + audio: episode.audio.clone(), + } + } +} + +fn extract_year_from_release_date(release_date: &str) -> Option { + if release_date.len() >= 4 { + release_date[..4].parse::().ok() + } else { + None + } +} + diff --git a/shared/src/model/xtream.rs b/shared/src/model/xtream.rs new file mode 100644 index 000000000..01954d294 --- /dev/null +++ b/shared/src/model/xtream.rs @@ -0,0 +1,271 @@ +use std::collections::BTreeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::ser::SerializeMap; +use serde_json::Value; +use crate::utils::{deserialize_as_option_string, deserialize_as_string_array, + deserialize_number_from_string, deserialize_number_from_string_or_zero, + opt_string_or_number_u32, string_default_on_null, string_or_number_u32, + deserialize_json_as_string, string_or_number_f64, deserialize_release_date}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct XtreamVideoInfoMovieData { + pub name: String, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub category_id: u32, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub stream_id: u32, + #[serde(default)] + pub direct_source: String, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub custom_sid: Option, + #[serde(default)] + pub added: String, + #[serde(default)] + pub container_extension: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct XtreamVideoInfoInfo { + pub kinopoisk_url: Option, + #[serde(default)] + pub tmdb_id: String, // is in get_vod_streams + #[serde(default)] + pub name: String, // is in get_vod_streams + pub o_name: Option, + pub cover_big: Option, + pub movie_image: Option, + pub releasedate: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub episode_run_time: Option, + pub youtube_trailer: Option, + pub director: Option, + pub actors: Option, + pub cast: Option, + pub description: Option, + pub plot: Option, + pub age: Option, + pub mpaa_rating: Option, + #[serde(default)] + pub rating_count_kinopoisk: u32, + pub country: Option, + pub genre: Option, + #[serde(default, deserialize_with = "deserialize_as_string_array")] + pub backdrop_path: Option>, + pub duration_secs: Option, + pub duration: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub video: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub audio: Option, + #[serde(default)] + pub bitrate: u32, + pub runtime: Option, + pub status: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct XtreamVideoInfo { + pub info: XtreamVideoInfoInfo, + pub movie_data: XtreamVideoInfoMovieData, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamSeriesInfoSeason { + #[serde(default, deserialize_with = "string_default_on_null")] + pub air_date: String, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub episode_count: u32, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub id: u32, + #[serde(default, deserialize_with = "string_default_on_null")] + pub name: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub overview: String, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub season_number: u32, + #[serde(default, deserialize_with = "string_or_number_f64")] + pub vote_average: f64, + #[serde(default, deserialize_with = "string_default_on_null")] + pub cover: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub cover_big: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[allow(non_snake_case)] +pub struct XtreamSeriesInfoInfo { + #[serde(default, deserialize_with = "string_default_on_null")] + pub(crate) name: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub cover: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub plot: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub cast: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub director: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub genre: String, + #[serde(default, deserialize_with = "deserialize_release_date")] + pub release_date: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub last_modified: String, + #[serde(default, deserialize_with = "string_or_number_f64")] + pub rating: f64, + #[serde(default, deserialize_with = "string_or_number_f64")] + pub rating_5based: f64, + #[serde(default, deserialize_with = "deserialize_as_string_array")] + pub backdrop_path: Option>, + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub tmdb: Option, + #[serde(default, deserialize_with = "string_default_on_null")] + pub youtube_trailer: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub episode_run_time: String, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub category_id: u32, +} + +#[allow(non_snake_case)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct XtreamSeriesInfoEpisodeInfo { + #[serde(default, deserialize_with = "string_default_on_null")] + pub air_date: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub crew: String, + #[serde(default, deserialize_with = "string_or_number_f64")] + pub rating: f64, + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub id: Option, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub duration_secs: u32, + #[serde(default, deserialize_with = "string_default_on_null")] + pub duration: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub movie_image: String, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub video: Option, + #[serde(default, deserialize_with = "deserialize_json_as_string")] + pub audio: Option, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub bitrate: u32, +} + +// Used for serde_json deserialization, cannot be used with bincode +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamSeriesInfoEpisode { + #[serde(default, deserialize_with = "string_or_number_u32")] + pub id: u32, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub episode_num: u32, + #[serde(default, deserialize_with = "string_default_on_null")] + pub title: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub container_extension: String, + #[serde(default)] + pub info: Option, + #[serde(default, deserialize_with = "string_default_on_null")] + pub custom_sid: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub added: String, + #[serde(default, deserialize_with = "string_or_number_u32")] + pub season: u32, + #[serde(default, deserialize_with = "string_default_on_null")] + pub direct_source: String, +} + +impl XtreamSeriesInfoEpisode { + pub fn get_id(&self) -> u32 { + self.id + } +} + +// sometimes episodes are a map with season as key, sometimes an array +fn deserialize_episodes<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + // read as generic value + let value = Value::deserialize(deserializer)?; + match value { + Value::Null => Ok(None), + Value::Array(array) => { + if array.is_empty() { + Ok(None) + } else { + let mut result = Vec::new(); + for inner in array { + if let Some(inner_array) = inner.as_array() { + for item in inner_array { + let ep: XtreamSeriesInfoEpisode = serde_json::from_value(item.clone()) + .map_err(serde::de::Error::custom)?; + result.push(ep); + } + } + } + Ok(Some(result)) + } + } + Value::Object(object) => { + if object.is_empty() { + Ok(None) + } else { + let mut result = Vec::new(); + for (_key, val) in object { + if let Some(inner_array) = val.as_array() { + for item in inner_array { + let ep: XtreamSeriesInfoEpisode = serde_json::from_value(item.clone()) + .map_err(serde::de::Error::custom)?; + result.push(ep); + } + } + } + Ok(Some(result)) + } + } + _ => Err(serde::de::Error::custom("Invalid format for episodes")), + } +} + +#[allow(clippy::ref_option)] +fn serialize_episodes( + episodes: &Option>, + serializer: S, +) -> Result +where + S: Serializer, +{ + match episodes { + None => { + let map = serializer.serialize_map(Some(0))?; + map.end() + } + Some(list) => { + if list.is_empty() { + let map = serializer.serialize_map(Some(0))?; + return map.end(); + } + let mut seasons: BTreeMap> = BTreeMap::new(); + for ep in list { + seasons.entry(ep.season.to_string()).or_default().push(ep); + } + + seasons.serialize(serializer) + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamSeriesInfo { + #[serde(default)] + pub seasons: Option>, + #[serde(default)] + pub info: XtreamSeriesInfoInfo, + #[serde( + default, + serialize_with = "serialize_episodes", + deserialize_with = "deserialize_episodes" + )] + pub episodes: Option>, +} \ No newline at end of file diff --git a/shared/src/utils/hash_utils.rs b/shared/src/utils/hash_utils.rs index ab727a035..f7153049f 100644 --- a/shared/src/utils/hash_utils.rs +++ b/shared/src/utils/hash_utils.rs @@ -1,6 +1,7 @@ use base64::Engine; use base64::engine::general_purpose; use std::fmt::Write; +use url::Url; use crate::model::{PlaylistItemType, UUIDType}; #[inline] @@ -67,13 +68,31 @@ pub fn get_provider_id(provider_id: &str, url: &str) -> Option { }) } +fn url_path_and_more(url: &str) -> Option { + let u = Url::parse(url).ok()?; + + let mut out = u.path().to_string(); + + if let Some(q) = u.query() { + out.push('?'); + out.push_str(q); + } + + if let Some(f) = u.fragment() { + out.push('#'); + out.push_str(f); + } + + Some(out) +} + pub fn generate_playlist_uuid(key: &str, provider_id: &str, item_type: PlaylistItemType, url: &str) -> UUIDType { - if let Some(id) = get_provider_id(provider_id, url) { - if id > 0 { - return hash_string(&format!("{key}{id}{item_type}")); + if provider_id.is_empty() || provider_id == "0" { + if let Some(url_path) = url_path_and_more(url) { + return hash_string(&url_path); } } - hash_string(url) + hash_string(&format!("{key}{provider_id}{item_type}")) } pub fn u32_to_base64(value: u32) -> String { diff --git a/shared/src/utils/serde_utils.rs b/shared/src/utils/serde_utils.rs index 840a0d2e4..0862191c7 100644 --- a/shared/src/utils/serde_utils.rs +++ b/shared/src/utils/serde_utils.rs @@ -38,6 +38,7 @@ where match &value { Value::String(s) => Ok(s.to_string()), + Value::Number(s) => Ok(s.to_string()), Value::Null => Ok(String::new()), _ => Ok(value.to_string()), } @@ -159,6 +160,18 @@ where } } +pub fn deserialize_number_from_string_or_zero<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: std::str::FromStr + Default, +{ + match deserialize_number_from_string(deserializer) { + Ok(Some(v)) => Ok(v), + Ok(None) => Ok(T::default()), + Err(e) => Err(serde::de::Error::custom(e)), + } +} + #[inline] pub fn bin_serialize(value: &T) -> io::Result> where @@ -247,4 +260,38 @@ pub fn parse_timestamp(value: &str) -> Result, ParseError> { let dt = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")?; let timestamp = Utc.from_utc_datetime(&dt).timestamp(); Ok(Some(timestamp)) +} + +pub fn deserialize_json_as_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let val: Value = Deserialize::deserialize(deserializer)?; + Ok(Some(val.to_string())) +} + + +const RELEASE_DATES: [&str; 3] = [ + "release_date", + "releaseDate", + "releasedata", +]; + +pub fn deserialize_release_date<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + + for key in RELEASE_DATES { + if let Some(v) = value.get(key) { + if let Some(s) = v.as_str() { + if !s.trim().is_empty() { + return Ok(s.to_string()); + } + } + } + } + + Ok(String::new()) } \ No newline at end of file