From 354911aa2755fdcd8a5da8a15ec3dc7c18ca069e Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 26 Dec 2025 16:03:03 +0100 Subject: [PATCH] stream properties refactored --- Cargo.lock | 17 +--- backend/Cargo.toml | 1 - backend/src/api/api_utils.rs | 2 +- backend/src/library/metadata_resolver.rs | 7 +- backend/src/library/tmdb_client.rs | 6 +- backend/src/model/xtream.rs | 2 +- backend/src/processing/parser/xtream.rs | 7 +- backend/src/processing/processor/playlist.rs | 2 +- .../src/processing/processor/xtream_series.rs | 96 ------------------- .../src/processing/processor/xtream_vod.rs | 14 +-- backend/src/ptt/parser.rs | 1 + backend/src/ptt/transformers.rs | 14 --- backend/src/repository/bplustree.rs | 12 +-- backend/src/repository/playlist_scratch.rs | 3 +- .../repository/xtream_playlist_iterator.rs | 1 + backend/src/repository/xtream_repository.rs | 7 -- backend/src/tools/lru_cache.rs | 12 ++- backend/src/utils/network/xtream.rs | 11 ++- shared/src/model/info_doc_utils.rs | 6 +- shared/src/model/playlist.rs | 9 +- shared/src/model/stream_properties.rs | 35 +++---- shared/src/model/xtream.rs | 10 +- shared/src/utils/serde_utils.rs | 2 +- shared/src/utils/string_utils.rs | 2 +- 24 files changed, 81 insertions(+), 198 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e633f44cd..90f55fac6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,12 +350,6 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.0" @@ -3433,22 +3427,19 @@ dependencies = [ [[package]] name = "rmp" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" dependencies = [ - "byteorder", "num-traits", - "paste", ] [[package]] name = "rmp-serde" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" dependencies = [ - "byteorder", "rmp", "serde", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7f8bf4582..1bd25e2bb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -35,7 +35,6 @@ deunicode = "1.6.2" mime = "0.3" log = "0.4" env_logger = "0.11" -#bincode = { version = "2.0", features = ["std", "serde"] } rmp-serde = "1" rand = "0.9" fastrand = "2" diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 83f9984a3..014b72b42 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1234,7 +1234,7 @@ fn get_mime_type(headers: &axum::http::HeaderMap, resource_url: &str) -> Option< .and_then(|v| v.to_str().ok()) // Option<&str> .map(ToString::to_string) // Option .or_else(|| { - // fallback auf guess + // fallback to guess mime_guess::from_path(resource_url) .first_raw() .map(ToString::to_string) diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index d6651f525..ddad1c00b 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -78,8 +78,7 @@ impl MetadataResolver { // Attempts to resolve metadata from TMDB async fn resolve_from_tmdb(&self, movie: bool, file: &ScannedMediaFile, metadata: &PttMetadata, tmdb: &TmdbClient) -> Result, String> { if movie { - let tmdb_id = metadata.tmdb.as_ref(); - tmdb.search_movie(tmdb_id, metadata.title.as_str(), metadata.year.as_ref()).await + tmdb.search_movie(metadata.tmdb, metadata.title.as_str(), metadata.year).await } else { let (series_year, tmdb_id) = if metadata.year.is_some() { (metadata.year, metadata.tmdb) } else { // Try to extract year from parent directory if available @@ -189,7 +188,7 @@ mod tests { MediaClassification::Movie { metadata, .. } => metadata, MediaClassification::Series { metadata, .. } => metadata, }; - let group = MediaGroup::Movie { file, metadata }; + let group = MediaGroup::Movie { file, metadata: Box::new(metadata) }; let metadata = resolver.resolve(&group).await; assert!(metadata.is_some()); @@ -218,7 +217,7 @@ mod tests { MediaClassification::Movie { metadata, .. } => metadata, MediaClassification::Series { metadata, .. } => metadata, }; - let group = MediaGroup::Movie { file, metadata }; + let group = MediaGroup::Movie { file, metadata: Box::new(metadata) }; let metadata = resolver.resolve(&group).await; assert!(metadata.is_none()); diff --git a/backend/src/library/tmdb_client.rs b/backend/src/library/tmdb_client.rs index 5cc264dd5..b4e2bfaab 100644 --- a/backend/src/library/tmdb_client.rs +++ b/backend/src/library/tmdb_client.rs @@ -38,11 +38,11 @@ impl TmdbClient { } // Searches for a movie by title and optional year - pub async fn search_movie(&self, tmdb_id: Option<&u32>, title: &str, year: Option<&u32>) -> Result, String> { + pub async fn search_movie(&self, tmdb_id: Option, title: &str, year: Option) -> Result, String> { debug!("TMDB search movie: {title}"); if let Some(movie_id) = tmdb_id { - return self.fetch_movie_details(*movie_id).await; + return self.fetch_movie_details(movie_id).await; } sleep(Duration::from_millis(self.rate_limit_ms)).await; @@ -62,7 +62,7 @@ impl TmdbClient { } } - fn build_movie_search_url(&self, title: &str, year: Option<&u32>) -> Result { + fn build_movie_search_url(&self, title: &str, year: Option) -> Result { let mut url = Url::parse(&format!("{TMDB_API_BASE_URL}/search/movie")).map_err(|e| format!("Failed to parse URL for TMDB movie search: {e}"))?; { let mut q = url.query_pairs_mut(); diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index 58b38522b..ef45b4bdd 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -52,7 +52,7 @@ pub fn xtream_mapping_option_from_target_options(target: &ConfigTarget, target_o reverse_item_types, username: user.username.clone(), password: user.password.clone(), - base_url: resource_url.map(ToString::to_string).clone(), + base_url: resource_url.map(ToString::to_string), } } diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 8ce42f3f1..f72ad8783 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -102,8 +102,11 @@ pub fn get_xtream_url(xtream_cluster: XtreamCluster, url: &str, format!("{url}/{ctx_path}{username}/{password}/{stream_id}{suffix}") } XtreamCluster::Video => { - let ext = container_extension.as_ref().map_or("mp4", |e| e.as_str()); - format!("{url}/movie/{username}/{password}/{stream_id}.{ext}") + if let Some(extension) = container_extension { + format!("{url}/movie/{username}/{password}/{stream_id}.{extension}") + } else { + format!("{url}/movie/{username}/{password}/{stream_id}") + } } XtreamCluster::Series => format!("{}&action={}&series_id={stream_id}", get_xtream_stream_url_base(url.as_ref(), username, password), crate::model::XC_ACTION_GET_SERIES_INFO) diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index c6a19641d..12c9690fc 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -335,8 +335,8 @@ async fn process_source(client: &reqwest::Client, app_config: Arc, so let (playlistgroups, mut error_list) = { let (downloaded_playlist, mut download_err) = playlist_download_from_input(client, &app_config, input).await; let (playlist, error) = persist_input_playlist(&app_config, input, downloaded_playlist).await; - error!("Failed to persist input playlist {}", input.name); if let Some(err) = error { + error!("Failed to persist input playlist {}", input.name); download_err.push(err); } (playlist, download_err) diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index edf04768a..39933bfd8 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -89,102 +89,6 @@ async fn playlist_resolve_series_info(app_config: &Arc, client: &reqw info!("resolved {processed_series_info_count}/{series_info_count} series info"); result } -// -// async fn process_series_info( -// app_config: &AppConfig, -// fpl: &mut FetchedPlaylist<'_>, -// errors: &mut Vec, -// ) -> Vec { -// let mut result: Vec = vec![]; -// let input = fpl.input; -// let config = app_config.config.load(); -// let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &config.working_dir) -// .map(|storage_path| xtream_get_info_file_paths(&storage_path, XtreamCluster::Series)) -// else { -// errors.push(notify_err!("Failed to open input info file for series".to_string())); -// return result; -// }; -// -// let mut write_counter = 0usize; -// -// let _file_lock = app_config.file_locks.read_lock(&info_path).await; -// -// // Contains the Series Info with episode listing -// let Ok(mut info_reader) = IndexedDocumentReader::::new(&info_path, &idx_path) else { return result; }; -// -// let Some((mut wal_file, wal_path)) = create_resolve_episode_wal_files(&config, input).await else { -// errors.push(notify_err!("Could not create wal file for series episodes record".to_string())); -// return result; -// }; -// let mut wal_writer = utils::async_file_writer(&mut wal_file); -// -// for plg in fpl -// .playlistgroups -// .iter_mut() -// .filter(|plg| plg.xtream_cluster == XtreamCluster::Series) -// { -// let mut group_series = vec![]; -// -// // Resolve does not handle LocalSeriesInfo -// for pli in plg -// .channels -// .iter_mut() -// .filter(|pli| pli.header.item_type == PlaylistItemType::SeriesInfo) -// { -// let Some(provider_id) = pli.header.get_provider_id() else { continue; }; -// let Ok(content) = info_reader.get(&provider_id) else { continue; }; -// if content.is_empty() { -// warn!("Series info content is empty, skipping series with provider id: {provider_id}"); -// continue; -// } -// 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() }) -// }; -// 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}"))) } -// } -// } -// group_series.extend(series.into_iter()); -// } -// } -// Err(err) => errors.push(info_err!(format!("Failed to parse JSON: {err}"))), -// } -// } -// if !group_series.is_empty() { -// result.push(PlaylistGroup { -// id: plg.id, -// title: plg.title.clone(), -// channels: group_series, -// xtream_cluster: XtreamCluster::Series, -// }); -// } -// } -// -// handle_error!(wal_writer.flush().await, |err| errors.push(notify_err!(format!("Failed to resolve series episodes, could not write to wal file {err}")))); -// handle_error!(wal_writer.get_ref().sync_all().await, |err| errors.push(notify_err!(format!("Failed to sync series info to wal file {err}")))); -// -// drop(wal_writer); -// drop(wal_file); -// handle_error!(xtream_update_input_series_episodes_record_from_wal_file(app_config, input, &wal_path).await, -// |err| errors.push(err)); -// result -// } pub async fn playlist_resolve_series(cfg: &Arc, client: &reqwest::Client, diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index 4d4c3fa64..7b987a0ef 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -1,15 +1,15 @@ -use std::sync::Arc; use crate::model::FetchedPlaylist; use crate::model::{AppConfig, ConfigTarget}; -use crate::processing::processor::xtream::{playlist_resolve_download_playlist_item}; -use crate::processing::processor::{create_resolve_options_function_for_xtream_target}; -use crate::repository::xtream_repository::{persists_input_vod_info}; +use crate::processing::processor::xtream::playlist_resolve_download_playlist_item; +use crate::processing::processor::create_resolve_options_function_for_xtream_target; +use crate::repository::storage::get_input_storage_path; +use crate::repository::xtream_repository::persists_input_vod_info; use log::{error, info, log_enabled, Level}; use shared::error::TuliproxError; use shared::model::{InputType, PlaylistEntry, StreamProperties, VideoStreamProperties, XtreamVideoInfo}; use shared::model::{PlaylistItemType, XtreamCluster}; +use std::sync::Arc; use std::time::Instant; -use crate::repository::storage::get_input_storage_path; create_resolve_options_function_for_xtream_target!(vod); @@ -52,7 +52,9 @@ pub async fn playlist_resolve_vod(app_config: &Arc, client: &reqwest: 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 video_stream_props = VideoStreamProperties::from_info(&info, pli); - let _ = persists_input_vod_info(app_config, &storage_path, pli.header.xtream_cluster, &input.name, provider_id, &video_stream_props).await; + if let Err(err) = persists_input_vod_info(app_config, &storage_path, pli.header.xtream_cluster, &input.name, provider_id, &video_stream_props).await { + error!("Failed to persist VOD info for provider_id {provider_id}: {err}"); + } // This makes the data available for subsequent processing steps like STRM export. pli.header.additional_properties = Some(StreamProperties::Video(Box::new(video_stream_props))); } diff --git a/backend/src/ptt/parser.rs b/backend/src/ptt/parser.rs index b425a9e13..23077176d 100644 --- a/backend/src/ptt/parser.rs +++ b/backend/src/ptt/parser.rs @@ -161,6 +161,7 @@ impl PttParser { result.seasons.dedup(); result.episodes.sort_unstable(); result.episodes.dedup(); + result.languages.sort_unstable(); result.languages.dedup(); let final_title = if end_of_title <= context.title.len() { diff --git a/backend/src/ptt/transformers.rs b/backend/src/ptt/transformers.rs index 99a84a46e..ddb6cee27 100644 --- a/backend/src/ptt/transformers.rs +++ b/backend/src/ptt/transformers.rs @@ -79,20 +79,6 @@ macro_rules! range_func { range_func!(range_i32, i32); range_func!(range_u32, u32); - -// -// pub fn range_x_of_y_func(input: &str) -> Option> { -// let numbers: Vec = PTT_CONSTANTS.integer -// .find_iter(input) -// .filter_map(|mat| mat.as_str().parse().ok()) -// .collect(); -// -// if numbers.len() != 1 { -// return None; -// } -// Some((1..=numbers[0]).collect()) -// } - pub fn transform_resolution(input: &str) -> String { let lower = input.to_lowercase(); if lower.contains("2160") || lower.contains("4k") { diff --git a/backend/src/repository/bplustree.rs b/backend/src/repository/bplustree.rs index 0756a90cd..738aecf06 100644 --- a/backend/src/repository/bplustree.rs +++ b/backend/src/repository/bplustree.rs @@ -13,7 +13,7 @@ use tempfile::NamedTempFile; use crate::utils::{binary_deserialize, binary_serialize}; const BLOCK_SIZE: usize = 4096; -const BINCODE_OVERHEAD: usize = 8; +const ENCODE_OVERHEAD: usize = 10*8; // for bincode we used 8 const LEN_SIZE: usize = 4; const FLAG_SIZE: usize = 1; @@ -464,14 +464,6 @@ fn decode_content(content_bytes: &[u8]) -> Option> { return Some(result); } } - - // TODO remove at next deployment, this is only fallback for older compressed files - let mut decoder = flate2::write::ZlibDecoder::new(Vec::with_capacity(content_bytes.len())); - if let Ok(()) = decoder.write_all(content_bytes) { - if let Ok(decoded) = decoder.finish() { - return Some(decoded); - } - } None } @@ -484,7 +476,7 @@ pub struct BPlusTree { } const fn calc_order() -> (usize, usize) { - let overhead_size = BINCODE_OVERHEAD + LEN_SIZE + FLAG_SIZE; + let overhead_size = ENCODE_OVERHEAD + LEN_SIZE + FLAG_SIZE; let key_size = size_of::() + overhead_size; let value_size = key_size + size_of::() + overhead_size; let inner_order = BLOCK_SIZE / key_size; diff --git a/backend/src/repository/playlist_scratch.rs b/backend/src/repository/playlist_scratch.rs index 3e6386cd6..c401a905c 100644 --- a/backend/src/repository/playlist_scratch.rs +++ b/backend/src/repository/playlist_scratch.rs @@ -48,7 +48,9 @@ impl PlaylistScratch { series: C::with_capacity(capacity), } } +} +impl PlaylistScratch { pub fn get_mut(&mut self, cluster: XtreamCluster) -> &mut C { match cluster { XtreamCluster::Live => &mut self.live, @@ -85,7 +87,6 @@ impl PlaylistScratch { } impl PlaylistScratch { - pub fn is_empty(&self, cluster: XtreamCluster) -> bool { match cluster { XtreamCluster::Live => self.live.is_empty(), diff --git a/backend/src/repository/xtream_playlist_iterator.rs b/backend/src/repository/xtream_playlist_iterator.rs index ea806fb51..6a96b4c01 100644 --- a/backend/src/repository/xtream_playlist_iterator.rs +++ b/backend/src/repository/xtream_playlist_iterator.rs @@ -71,6 +71,7 @@ impl XtreamPlaylistIterator { } fn matches_filters(cluster: XtreamCluster, filter_ids: Option<&HashSet>, item: &XtreamPlaylistItem) -> bool { + // we cant serve episodes with series! if cluster == XtreamCluster::Series && !matches!(item.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) { return false; diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 9b7c122ee..92a1dd4b5 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -50,13 +50,6 @@ macro_rules! try_option_ok { } }; } -// -// #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -// pub struct InputVodInfoRecord { -// pub(crate) tmdb_id: u32, -// pub(crate) ts: u64, -// pub(crate) release_date: Option, -// } #[inline] fn get_collection_path(path: &Path, collection: &str) -> PathBuf { diff --git a/backend/src/tools/lru_cache.rs b/backend/src/tools/lru_cache.rs index 14f2dfaca..2afbdf42e 100644 --- a/backend/src/tools/lru_cache.rs +++ b/backend/src/tools/lru_cache.rs @@ -38,11 +38,13 @@ impl LRUResourceCache { /// - `cache_dir`: The directory path where cached files are stored. /// pub fn new(capacity: usize, cache_dir: &str) -> Self { + // Estimate: assume average file size of 256KB + let estimated_entries = (capacity / (256 * 1024)).clamp(64, 16384); Self { capacity, cache_dir: PathBuf::from(cache_dir), current_size: 0, - cache: HashMap::, usize)>::with_capacity(4096), + cache: HashMap::, usize)>::with_capacity(estimated_entries), usage_order: VecDeque::new(), } } @@ -147,7 +149,9 @@ impl LRUResourceCache { if path.exists() { trace_if_enabled!("Responding resource from cache with key: {key} for url: {}", sanitize_sensitive_info(url)); // Move to the end of the queue - self.usage_order.retain(|k| k != &key); // remove from queue + if let Some(pos) = self.usage_order.iter().position(|k| k == &key) { + self.usage_order.remove(pos); // remove from queue + } self.usage_order.push_back(key); // add to the to end return Some((path.clone(), mime_type.clone())); } @@ -156,7 +160,9 @@ impl LRUResourceCache { // this should not happen, someone deleted the file manually and the cache is not in sync self.current_size -= size; self.cache.remove(&key); - self.usage_order.retain(|k| k != &key); + if let Some(pos) = self.usage_order.iter().position(|k| k == &key) { + self.usage_order.remove(pos); + } } } } diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 2649bb31f..d95145ee2 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -13,7 +13,7 @@ use crate::utils::request; use chrono::{DateTime, Utc}; use log::{error, info, warn}; use shared::error::{str_to_io_error, to_io_error, TuliproxError}; -use shared::model::{MsgKind, PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo}; +use shared::model::{MsgKind, PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo}; use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info}; use std::io::Error; use std::str::FromStr; @@ -57,7 +57,7 @@ pub async fn get_xtream_stream_info_content(client: &reqwest::Client, input: &In } } -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub async fn get_xtream_stream_info(client: &reqwest::Client, app_state: &Arc, user: &ProxyUserCredentials, @@ -90,12 +90,14 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, error!("Failed to persist video stream for input {}: {err}", &input.name); } - if let Err(err) = write_playlist_item_to_file(app_config, &target.name, pli).await { + let mut vod_pli = pli.clone(); + vod_pli.additional_properties = Some(StreamProperties::Video(Box::new(video_stream_props))); + if let Err(err) = write_playlist_item_to_file(app_config, &target.name, &vod_pli).await { error!("Failed to persist video stream: {err}"); } if target.use_memory_cache { - app_state.playlists.update_playlist_items(target, vec![pli]).await; + app_state.playlists.update_playlist_items(target, vec![&vod_pli]).await; } } } @@ -148,6 +150,7 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, if !provider_series.is_empty() { let mut series_pli = pli.clone(); + series_pli.additional_properties = Some(StreamProperties::Series(Box::new(series_stream_props))); rewrite_provider_series_info_episode_virtual_id(&mut series_pli, &provider_series); if let Err(err) = write_playlist_item_to_file(app_config, &target.name, &series_pli).await { error!("Failed to persist series stream: {err}"); diff --git a/shared/src/model/info_doc_utils.rs b/shared/src/model/info_doc_utils.rs index b66dd3f36..629bf69cc 100644 --- a/shared/src/model/info_doc_utils.rs +++ b/shared/src/model/info_doc_utils.rs @@ -23,9 +23,9 @@ impl InfoDocUtils { pub fn limited(n: f64) -> String { if n > 0.01 { - let s = n.to_string(); - if s.len() > 4 { - s[..4].to_string() + let s = format!("{:.2}", n); + if s.ends_with(".00") { + s[..s.len()-3].to_string() } else { s } diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index b870496df..53e3300dc 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -532,9 +532,9 @@ impl PlaylistEntry for M3uPlaylistItem { #[inline] fn get_name(&self) -> Cow<'_, str> { if self.title.is_empty() { - Cow::Borrowed(self.title.as_str()) - } else { Cow::Borrowed(self.name.as_str()) + } else { + Cow::Borrowed(self.title.as_str()) } } @@ -672,6 +672,7 @@ impl XtreamPlaylistItem { } } } + #[inline] pub fn has_details(&self) -> bool { self.additional_properties.as_ref().is_some_and(|p| p.has_details()) @@ -987,7 +988,7 @@ impl PlaylistEntry for XtreamPlaylistItem { } #[inline] fn get_category_id(&self) -> Option { - None + Some(self.category_id) } #[inline] fn get_provider_url(&self) -> String { @@ -1313,7 +1314,7 @@ impl PlaylistEntry for PlaylistItem { #[inline] fn get_category_id(&self) -> Option { - None + Some(self.header.category_id) } #[inline] diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 6eda479fe..34fe5489d 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -1,7 +1,7 @@ use crate::model::{ PlaylistEntry, PlaylistItemType, VirtualId, XtreamCluster, XtreamMappingOptions, XtreamSeriesInfo, XtreamVideoInfo}; use crate::utils::{deserialize_as_option_string, deserialize_number_from_string, deserialize_number_from_string_or_zero, string_default_on_null, - deserialize_as_string, deserialize_as_string_array, deserialize_json_as_string, + deserialize_as_string, deserialize_as_string_array, deserialize_json_as_opt_string, string_or_number_u32}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; @@ -62,9 +62,9 @@ pub struct VideoStreamDetailProperties { pub backdrop_path: Option>, pub duration_secs: Option, pub duration: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub video: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub audio: Option, #[serde(default)] pub bitrate: u32, @@ -140,9 +140,9 @@ pub struct SeriesStreamDetailEpisodeProperties { pub bitrate: u32, #[serde(default, deserialize_with = "deserialize_number_from_string")] pub rating: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub video: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub audio: Option, } @@ -205,13 +205,12 @@ pub struct EpisodeStreamProperties { pub movie_image: String, #[serde(default, deserialize_with = "string_default_on_null")] pub container_extension: String, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub video: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub audio: Option, } - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub enum StreamProperties { Live(LiveStreamProperties), @@ -321,25 +320,21 @@ impl StreamProperties { 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::Video(video) => non_empty_string(&video.container_extension), 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()) }, + .and_then(|e| e.first().and_then(|e| non_empty_string(&e.container_extension))), + StreamProperties::Episode(episode) => non_empty_string(&episode.container_extension), } } 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::Video(video) => non_empty_string(&video.direct_source), 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()) } - })), + .and_then(|e| e.first().and_then(|e| non_empty_string(&e.direct_source))), StreamProperties::Episode(_episode) => None, } } @@ -465,6 +460,7 @@ impl StreamProperties { virtual_id: VirtualId, category_id: u32) -> Value { match self { StreamProperties::Live(_live) => { + // live has no info Value::Object(serde_json::Map::new()) } StreamProperties::Video(video) => { @@ -474,6 +470,7 @@ impl StreamProperties { self.series_to_info_document(options, series, item_type, virtual_id, category_id) } StreamProperties::Episode(_episode) => { + // episode has no info Value::Object(serde_json::Map::new()) } } @@ -808,3 +805,7 @@ impl EpisodeStreamProperties { } } } + +fn non_empty_string(s: &str) -> Option { + if s.is_empty() { None } else { Some(s.to_string()) } +} \ No newline at end of file diff --git a/shared/src/model/xtream.rs b/shared/src/model/xtream.rs index 16d270bfb..226022b71 100644 --- a/shared/src/model/xtream.rs +++ b/shared/src/model/xtream.rs @@ -1,5 +1,5 @@ use crate::utils::{deserialize_as_option_string, deserialize_as_string_array, - deserialize_json_as_string, deserialize_number_from_string, + deserialize_json_as_opt_string, deserialize_number_from_string, deserialize_number_from_string_or_zero, deserialize_release_date, string_default_on_null, string_or_number_u32}; use serde::ser::SerializeMap; @@ -53,9 +53,9 @@ pub struct XtreamVideoInfoInfo { pub backdrop_path: Option>, pub duration_secs: Option, pub duration: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub video: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub audio: Option, #[serde(default)] pub bitrate: u32, @@ -144,9 +144,9 @@ pub struct XtreamSeriesInfoEpisodeInfo { pub duration: String, #[serde(default, deserialize_with = "string_default_on_null")] pub movie_image: String, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub video: Option, - #[serde(default, deserialize_with = "deserialize_json_as_string")] + #[serde(default, deserialize_with = "deserialize_json_as_opt_string")] pub audio: Option, #[serde(default, deserialize_with = "string_or_number_u32")] pub bitrate: u32, diff --git a/shared/src/utils/serde_utils.rs b/shared/src/utils/serde_utils.rs index dca47afdc..0ac1a7a43 100644 --- a/shared/src/utils/serde_utils.rs +++ b/shared/src/utils/serde_utils.rs @@ -261,7 +261,7 @@ pub fn parse_timestamp(value: &str) -> Result, ParseError> { Ok(Some(timestamp)) } -pub fn deserialize_json_as_string<'de, D>(deserializer: D) -> Result, D::Error> +pub fn deserialize_json_as_opt_string<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { diff --git a/shared/src/utils/string_utils.rs b/shared/src/utils/string_utils.rs index 74807bbee..bb0e74db0 100644 --- a/shared/src/utils/string_utils.rs +++ b/shared/src/utils/string_utils.rs @@ -59,7 +59,7 @@ pub fn get_non_empty_str<'a>(first: &'a str, second: &'a str, third: &'a str) -> } pub fn is_blank_optional_string(s: Option<&String>) -> bool { - s.is_none() || s.as_ref().is_some_and(|s| s.trim().is_empty()) + s.is_none() || s.is_some_and(|s| s.trim().is_empty()) } pub fn trim_slash(s: &str) -> Cow<'_, str> {