diff --git a/Cargo.toml b/Cargo.toml index 90ba6d2b4..92c488b97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,6 @@ strip = true incremental = false [profile.dev] -opt-level = 1 lto = false incremental = true codegen-units = 256 diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 1434ddf4a..d428afbc0 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -773,21 +773,6 @@ where }; if redirect_request { - // handle redirect for series but why? - if params.cluster == XtreamCluster::Series { - let ext = params.stream_ext.unwrap_or_default(); - let url = params.input.url.as_str(); - let (username, password) = - (params.input.username.as_deref().unwrap_or(""), params.input.password.as_deref().unwrap_or("")); - // TODO do i need action_path like for timeshift ? - let stream_url = format!("{url}/series/{username}/{password}/{provider_id}{ext}"); - debug_if_enabled!( - "Redirecting stream request to {}", - sanitize_sensitive_info(resolve_request_url_for_logging(params.input, &stream_url).as_ref()) - ); - return Some(redirect(&stream_url).into_response()); - } - let target_name = params.target.name.as_str(); let virtual_id = params.item.get_virtual_id(); let stream_url = match get_xtream_player_api_stream_url( diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 2cd9b8956..6dc7cb1ad 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -265,6 +265,9 @@ async fn xtream_player_api_stream( return axum::http::StatusCode::BAD_REQUEST.into_response(); } + let stream_ext = stream_ext.filter(|s| !s.is_empty()) + .or_else(|| pli.get_container_extension().map(|e| concat_string!(".", e.as_ref()))); + let input = try_option_bad_request!( app_state.app_config.get_input_by_name(&pli.input_name), true, @@ -514,6 +517,9 @@ async fn xtream_player_api_stream_with_token( .into_response(); } + let stream_ext = stream_ext.filter(|s| !s.is_empty()) + .or_else(|| pli.get_container_extension().map(|e| concat_string!(".", e.as_ref()))); + let session_key = create_session_fingerprint(fingerprint, "webui", virtual_id); let is_hls_request = pli.item_type == PlaylistItemType::LiveHls || stream_ext.as_deref() == Some(HLS_EXT); diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index f2198c814..904456e49 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -5,12 +5,12 @@ use crate::model::{Config, ConfigTarget}; use crate::model::{ConfigInput, PlaylistXtreamCategory}; use crate::processing::parser::xtream; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate, FlushPolicy}; +use crate::repository::open_playlist_reader; use crate::repository::playlist_scratch::PlaylistScratch; use crate::repository::storage::{ensure_input_storage_path, get_file_path_for_db_index, get_input_storage_path, get_target_id_mapping_file, get_target_storage_path}; use crate::repository::storage_const; use crate::repository::target_id_mapping::VirtualIdRecord; use crate::repository::xtream_playlist_iterator::XtreamPlaylistJsonIterator; -use crate::repository::open_playlist_reader; use crate::utils::json_write_documents_to_file; use crate::utils::request::DynReader; use crate::utils::FileReadGuard; @@ -19,7 +19,7 @@ use bytes::Bytes; use fs2::FileExt; use futures::{stream, Stream, StreamExt}; use indexmap::IndexMap; -use log::{error}; +use log::error; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use shared::error::{info_err_res, notify_err, string_to_io_error, TuliproxError}; @@ -45,13 +45,6 @@ macro_rules! cant_write_result { }; } -// fn rebuild_source_ordinal_index_if_present(xtream_path: &Path) -> Result<(), std::io::Error> { -// let index_path = get_file_path_for_db_index(xtream_path); -// if !index_path.exists() { -// return Ok(()); -// } -// BPlusTree::::store_index(xtream_path, |pli| pli.source_ordinal) -// } #[inline] pub fn get_collection_path(path: &Path, collection: &str) -> PathBuf { @@ -164,7 +157,6 @@ pub async fn write_playlist_item_update( let _guard = file_lock; let mut tree = BPlusTreeUpdate::::try_new_with_backoff(&xtream_path_clone)?; tree.upsert_batch_encoded(prepared_items)?; - // rebuild_source_ordinal_index_if_present(&xtream_path_clone)?; Ok(()) }) .await @@ -210,7 +202,6 @@ pub async fn write_playlist_batch_item_upsert( let _guard = file_lock; let mut tree = BPlusTreeUpdate::::try_new_with_backoff(&xtream_path_clone)?; tree.upsert_batch_encoded(prepared_items)?; - //rebuild_source_ordinal_index_if_present(&xtream_path_clone)?; Ok(()) }) .await @@ -465,8 +456,8 @@ async fn xtream_read_item_for_stream_id( Err(err) => Err(Error::other(format!("Query failed for {stream_id} in {cluster}: {err}"))), } }) - .await - .map_err(|err| Error::other(format!("Query task failed for {stream_id} in {cluster}: {err}")))? + .await + .map_err(|err| Error::other(format!("Query task failed for {stream_id} in {cluster}: {err}")))? } async fn xtream_read_series_item_for_stream_id( @@ -486,8 +477,8 @@ async fn xtream_read_series_item_for_stream_id( Err(err) => Err(Error::other(format!("Query failed for {stream_id} in series: {err}"))), } }) - .await - .map_err(|err| Error::other(format!("Query task failed for {stream_id} in series: {err}")))? + .await + .map_err(|err| Error::other(format!("Query task failed for {stream_id} in series: {err}")))? } @@ -519,16 +510,17 @@ async fn xtream_get_item_for_stream_id_from_memory( PlaylistItemType::Series | PlaylistItemType::LocalSeries => { log::debug!("In-memory series item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}", virtual_id, mapping.parent_virtual_id, mapping.provider_id); - if let Some(item) = xtream_storage.series.query(&mapping.parent_virtual_id) { + + if let Some(item) = xtream_storage.series.query(&virtual_id) { + Ok(item.clone()) + } else if let Some(item) = xtream_storage.series.query(&mapping.parent_virtual_id) { let mut xc_item = item.clone(); xc_item.provider_id = mapping.provider_id; xc_item.item_type = PlaylistItemType::Series; xc_item.virtual_id = mapping.virtual_id; Ok(xc_item) } else { - Ok(xtream_storage.series.query(&virtual_id) - .ok_or_else(|| string_to_io_error(format!("Failed to read xtream item for id {virtual_id}")))? - .clone()) + Err(string_to_io_error(format!("Failed to read xtream item for id {virtual_id}"))) } } PlaylistItemType::Catchup => { @@ -606,8 +598,8 @@ pub async fn xtream_get_item_for_stream_id( Err(err) => Err(string_to_io_error(format!("Query failed for id {virtual_id}: {err}"))), } }) - .await - .map_err(|err| string_to_io_error(format!("Mapping query task failed for id {virtual_id}: {err}")))??; + .await + .map_err(|err| string_to_io_error(format!("Mapping query task failed for id {virtual_id}: {err}")))??; match mapping.item_type { PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => { @@ -616,14 +608,19 @@ pub async fn xtream_get_item_for_stream_id( PlaylistItemType::Series | PlaylistItemType::LocalSeries => { log::debug!("Disk series item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}", virtual_id, mapping.parent_virtual_id, mapping.provider_id); + + if let Ok(episode) = xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series).await { + return Ok(episode); + } + if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path).await { item.provider_id = mapping.provider_id; item.item_type = PlaylistItemType::Series; item.virtual_id = mapping.virtual_id; - Ok(item) - } else { - xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series).await + return Ok(item); } + + return Err(Error::other(format!("Failed to find episode item with virtual-id {virtual_id}"))); } PlaylistItemType::Catchup => { log::debug!("Disk catchup item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}", virtual_id, mapping.parent_virtual_id, mapping.provider_id); @@ -655,14 +652,14 @@ pub async fn xtream_load_rewrite_playlist( XtreamPlaylistJsonIterator::new(cluster, app_state, target, category_id, user).await } -pub async fn iter_raw_xtream_target_playlist(app_config: &AppConfig, target: &ConfigTarget, cluster: XtreamCluster) -> Option + Send + Unpin>> { +pub async fn iter_raw_xtream_target_playlist(app_config: &AppConfig, target: &ConfigTarget, cluster: XtreamCluster) -> Option + Send + Unpin>> { let config = app_config.config.load(); let storage_path = xtream_get_storage_path(&config, target.name.as_str())?; let xtream_path = xtream_get_file_path(&storage_path, cluster); iter_raw_xtream_playlist(app_config, &xtream_path).await } -pub async fn iter_raw_xtream_input_playlist(app_config: &AppConfig, input: &ConfigInput, cluster: XtreamCluster) -> Option + Send + Unpin>> { +pub async fn iter_raw_xtream_input_playlist(app_config: &AppConfig, input: &ConfigInput, cluster: XtreamCluster) -> Option + Send + Unpin>> { let config = app_config.config.load(); let storage_dir = &config.storage_dir; let storage_path = get_input_storage_path(&input.name, storage_dir).await.ok()?; @@ -671,7 +668,7 @@ pub async fn iter_raw_xtream_input_playlist(app_config: &AppConfig, input: &Conf iter_raw_xtream_playlist(app_config, &xtream_path).await } -async fn iter_raw_xtream_playlist(app_config: &AppConfig, xtream_path: &Path) -> Option + Send + Unpin>> { +async fn iter_raw_xtream_playlist(app_config: &AppConfig, xtream_path: &Path) -> Option + Send + Unpin>> { if !file_exists_async(xtream_path).await { return None; } @@ -725,7 +722,7 @@ async fn iter_raw_xtream_playlist(app_config: &AppConfig, xtream_path: &Path) -> } }); - let stream: Box + Send + Unpin> = + let stream: Box + Send + Unpin> = Box::new(ReceiverStream::new(rx)); Some(stream) } @@ -775,10 +772,9 @@ fn preserve_details_input_xtream_playlist_cluster_to_disk( old_path: &Path, tmp_path: &Path, ) -> Result<(), TuliproxError> { - - let Ok(mut old_tree) = BPlusTreeQuery::::try_new(old_path) else { - return Ok(()) - }; + let Ok(mut old_tree) = BPlusTreeQuery::::try_new(old_path) else { + return Ok(()) + }; let Ok(mut new_tree) = BPlusTreeUpdate::::try_new_with_backoff(tmp_path) else { return Ok(()) @@ -1082,7 +1078,7 @@ pub async fn persist_input_xtream_playlist(app_config: &Arc, storage_ } entries }) - .await + .await { Ok(entries) => Some(entries), Err(err) => { @@ -1203,7 +1199,6 @@ pub(crate) fn needs_update_info_details( new_stream_props: &StreamProperties, old_stream_props: &StreamProperties, ) -> bool { - let new_modified = new_stream_props.get_last_modified(); let old_modified = old_stream_props.get_last_modified(); @@ -1486,8 +1481,8 @@ pub async fn load_input_xtream_playlist(app_config: &Arc, storage_pat } Ok(items) }) - .await - .map_err(|err| notify_err!("failed to read xtream playlist: {} - {err}", xtream_display))??; + .await + .map_err(|err| notify_err!("failed to read xtream playlist: {} - {err}", xtream_display))??; for item in items { let cat_id = item.category_id; diff --git a/shared/src/utils/request.rs b/shared/src/utils/request.rs index 3160cfc78..ac2076c94 100644 --- a/shared/src/utils/request.rs +++ b/shared/src/utils/request.rs @@ -1,5 +1,4 @@ use crate::{ - concat_string, error::TuliproxError, info_err, info_err_res, utils::{CONSTANTS, DASH_EXT, DASH_EXT_FRAGMENT, DASH_EXT_QUERY, HLS_EXT, HLS_EXT_FRAGMENT, HLS_EXT_QUERY}, @@ -40,22 +39,37 @@ pub fn sanitize_sensitive_info(query: &str) -> Cow<'_, str> { Cow::Owned(result) } -/// Extracts the file extension from a URL path (query/fragment stripped). +/// Extracts the file extension from a URL path (fragment stripped). /// Returns the extension **prefixed with a dot** (e.g., ".m3u8"). pub fn extract_extension_from_url(input: &str) -> Option { - // 1. Strip query + fragment - let input = input.split('?').next().unwrap_or(input).split('#').next().unwrap_or(input); + // 1. Remove fragment (#) + let base = input.split('#').next()?; - // 2. Remove scheme (http://, file://, etc.) - let path = input.split("://").last().unwrap_or(input); + // 2. Get last path segment (after last '/') + let last_segment = base.rsplit('/').next().filter(|s| !s.is_empty())?; - // 3. Take last path segment - let filename = path.rsplit('/').next().filter(|s| !s.is_empty())?; + // 3. Define the search area (last 6 characters to include the dot + 5 extension chars) + // We use char_indices to handle UTF-8 safely + let len = last_segment.len(); + let search_start = len.saturating_sub(6); + let search_area = &last_segment[search_start..]; - // 4. Extract extension - let ext = filename.rsplit('.').next().filter(|e| *e != filename)?; // ensures dot exists + // 4. Find the dot in the restricted search area + if let Some(dot_index) = search_area.rfind('.') { + // Slice INCLUDING the dot + let extension_with_dot = &search_area[dot_index..]; - Some(concat_string!(".", ext)) + // Validation (Note: length is now +1 because of the dot) + if extension_with_dot.len() > 1 + && extension_with_dot.len() <= 5 + && !extension_with_dot.contains('?') + && !extension_with_dot.eq_ignore_ascii_case(".php") + { + return Some(extension_with_dot.to_string()); + } + } + + None } pub fn is_hls_url(url: &str) -> bool {