stream properties refactored

This commit is contained in:
euzu
2025-12-26 16:03:03 +01:00
parent d5207e886f
commit 354911aa27
24 changed files with 81 additions and 198 deletions
Generated
+4 -13
View File
@@ -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",
]
-1
View File
@@ -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"
+1 -1
View File
@@ -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<String>
.or_else(|| {
// fallback auf guess
// fallback to guess
mime_guess::from_path(resource_url)
.first_raw()
.map(ToString::to_string)
+3 -4
View File
@@ -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<Option<MediaMetadata>, 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());
+3 -3
View File
@@ -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<Option<MediaMetadata>, String> {
pub async fn search_movie(&self, tmdb_id: Option<u32>, title: &str, year: Option<u32>) -> Result<Option<MediaMetadata>, 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<Url, String> {
fn build_movie_search_url(&self, title: &str, year: Option<u32>) -> Result<Url, String> {
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();
+1 -1
View File
@@ -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),
}
}
+5 -2
View File
@@ -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)
+1 -1
View File
@@ -335,8 +335,8 @@ async fn process_source(client: &reqwest::Client, app_config: Arc<AppConfig>, 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)
@@ -89,102 +89,6 @@ async fn playlist_resolve_series_info(app_config: &Arc<AppConfig>, 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<TuliproxError>,
// ) -> Vec<PlaylistGroup> {
// let mut result: Vec<PlaylistGroup> = 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::<u32, String>::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::<SeriesStreamProperties>(&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<AppConfig>,
client: &reqwest::Client,
@@ -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<AppConfig>, 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::<XtreamVideoInfo>(&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)));
}
+1
View File
@@ -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() {
-14
View File
@@ -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<Vec<i32>> {
// let numbers: Vec<i32> = 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") {
+2 -10
View File
@@ -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<Vec<u8>> {
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<K, V> {
}
const fn calc_order<K, V>() -> (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::<K>() + overhead_size;
let value_size = key_size + size_of::<V>() + overhead_size;
let inner_order = BLOCK_SIZE / key_size;
+2 -1
View File
@@ -48,7 +48,9 @@ impl<C: WithCapacity> PlaylistScratch<C> {
series: C::with_capacity(capacity),
}
}
}
impl<C> PlaylistScratch<C> {
pub fn get_mut(&mut self, cluster: XtreamCluster) -> &mut C {
match cluster {
XtreamCluster::Live => &mut self.live,
@@ -85,7 +87,6 @@ impl<C: Default> PlaylistScratch<C> {
}
impl<C: IsEmpty> PlaylistScratch<C> {
pub fn is_empty(&self, cluster: XtreamCluster) -> bool {
match cluster {
XtreamCluster::Live => self.live.is_empty(),
@@ -71,6 +71,7 @@ impl XtreamPlaylistIterator {
}
fn matches_filters(cluster: XtreamCluster, filter_ids: Option<&HashSet<u32>>, item: &XtreamPlaylistItem) -> bool {
// we cant serve episodes with series!
if cluster == XtreamCluster::Series
&& !matches!(item.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) {
return false;
@@ -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<String>,
// }
#[inline]
fn get_collection_path(path: &Path, collection: &str) -> PathBuf {
+9 -3
View File
@@ -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::<String, (PathBuf, Option<String>, usize)>::with_capacity(4096),
cache: HashMap::<String, (PathBuf, Option<String>, 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);
}
}
}
}
+7 -4
View File
@@ -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<AppState>,
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}");
+3 -3
View File
@@ -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
}
+5 -4
View File
@@ -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<u32> {
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<u32> {
None
Some(self.header.category_id)
}
#[inline]
+18 -17
View File
@@ -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<Vec<String>>,
pub duration_secs: Option<String>,
pub duration: Option<String>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub video: Option<String>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub audio: Option<String>,
#[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<f64>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub video: Option<String>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub audio: Option<String>,
}
@@ -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<String>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub audio: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum StreamProperties {
Live(LiveStreamProperties),
@@ -321,25 +320,21 @@ impl StreamProperties {
pub fn get_container_extension(&self) -> Option<String> {
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<String> {
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<String> {
if s.is_empty() { None } else { Some(s.to_string()) }
}
+5 -5
View File
@@ -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<Vec<String>>,
pub duration_secs: Option<String>,
pub duration: Option<String>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub video: Option<String>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub audio: Option<String>,
#[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<String>,
#[serde(default, deserialize_with = "deserialize_json_as_string")]
#[serde(default, deserialize_with = "deserialize_json_as_opt_string")]
pub audio: Option<String>,
#[serde(default, deserialize_with = "string_or_number_u32")]
pub bitrate: u32,
+1 -1
View File
@@ -261,7 +261,7 @@ pub fn parse_timestamp(value: &str) -> Result<Option<i64>, ParseError> {
Ok(Some(timestamp))
}
pub fn deserialize_json_as_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
pub fn deserialize_json_as_opt_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
+1 -1
View File
@@ -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> {