From 16fcd7f882106cd96804302e3fe2921de9564b47 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:09:58 +0100 Subject: [PATCH] Optimizations * New Features Added series information display with episodes, cast, and metadata in playlist explorer Introduced multi-cluster playlist endpoints (live, VOD, series) for flexible content retrieval Added series info lookup and filtering capabilities Implemented sidebar navigation for source editor with dedicated pages (Main, Options, Staged, Advanced, Alias) Added filter state controls (All, Selected, Deselected) for playlist category management Enabled Trakt configuration toggle to control integration Performance Improvements Optimized B+Tree indexing for faster queries Reduced startup latency with disk-based operations and optional memory caching Improved string memory handling through efficient reference-counted strings UI Enhancements Enhanced playlist explorer with series details and episode grouping by season Added new UI icons (Options, Advanced, Staging, Alias, Trakt) Improved form layouts with toolbar controls Enhanced radio button group styling for better visual hierarchy * New Features Added series information display with episodes, cast, and metadata in playlist explorer Introduced multi-cluster playlist endpoints (live, VOD, series) for flexible content retrieval Added series info lookup and filtering capabilities Implemented sidebar navigation for source editor with dedicated pages (Main, Options, Staged, Advanced, Alias) Added filter state controls (All, Selected, Deselected) for playlist category management Enabled Trakt configuration toggle to control integration Performance Improvements Optimized B+Tree indexing for faster queries Reduced startup latency with disk-based operations and optional memory caching Improved string memory handling through efficient reference-counted strings UI Enhancements Enhanced playlist explorer with series details and episode grouping by season Added new UI icons (Options, Advanced, Staging, Alias, Trakt) Improved form layouts with toolbar controls Enhanced radio button group styling for better visual hierarchy * New Features Added series information display with episodes, cast, and metadata in playlist explorer Introduced multi-cluster playlist endpoints (live, VOD, series) for flexible content retrieval Added series info lookup and filtering capabilities Implemented sidebar navigation for source editor with dedicated pages (Main, Options, Staged, Advanced, Alias) Added filter state controls (All, Selected, Deselected) for playlist category management Enabled Trakt configuration toggle to control integration Performance Improvements Optimized B+Tree indexing for faster queries Reduced startup latency with disk-based operations and optional memory caching Improved string memory handling through efficient reference-counted strings UI Enhancements Enhanced playlist explorer with series details and episode grouping by season Added new UI icons (Options, Advanced, Staging, Alias, Trakt) Improved form layouts with toolbar controls Enhanced radio button group styling for better visual hierarchy * New Features Added series information display with episodes, cast, and metadata in playlist explorer Introduced multi-cluster playlist endpoints (live, VOD, series) for flexible content retrieval Added series info lookup and filtering capabilities Implemented sidebar navigation for source editor with dedicated pages (Main, Options, Staged, Advanced, Alias) Added filter state controls (All, Selected, Deselected) for playlist category management Enabled Trakt configuration toggle to control integration Performance Improvements Optimized B+Tree indexing for faster queries Reduced startup latency with disk-based operations and optional memory caching Improved string memory handling through efficient reference-counted strings UI Enhancements Enhanced playlist explorer with series details and episode grouping by season Added new UI icons (Options, Advanced, Staging, Alias, Trakt) Improved form layouts with toolbar controls Enhanced radio button group styling for better visual hierarchy --- CHANGELOG.md | 4 + Cargo.lock | 17 +- backend/Cargo.toml | 3 +- backend/src/api/api_utils.rs | 237 ++++-- .../src/api/endpoints/api_playlist_utils.rs | 177 +---- backend/src/api/endpoints/hdhomerun_api.rs | 47 +- backend/src/api/endpoints/hls_api.rs | 44 +- backend/src/api/endpoints/m3u_api.rs | 9 +- backend/src/api/endpoints/user_api.rs | 4 +- backend/src/api/endpoints/v1_api.rs | 12 +- backend/src/api/endpoints/v1_api_config.rs | 10 +- backend/src/api/endpoints/v1_api_playlist.rs | 129 ++- backend/src/api/endpoints/xmltv_api.rs | 17 +- backend/src/api/endpoints/xtream_api.rs | 141 ++-- backend/src/api/hdhomerun_proprietary.rs | 3 +- backend/src/api/main_api.rs | 9 +- .../src/api/model/active_provider_manager.rs | 18 +- backend/src/api/model/active_user_manager.rs | 23 +- backend/src/api/model/event_manager.rs | 7 +- backend/src/api/model/provider_config.rs | 4 +- .../src/api/model/provider_lineup_manager.rs | 130 ++-- backend/src/api/model/stream.rs | 6 +- .../api/model/streams/active_client_stream.rs | 3 +- .../model/streams/shared_stream_manager.rs | 8 +- backend/src/api/panel_api.rs | 30 +- backend/src/api/scheduler.rs | 21 +- backend/src/library/metadata_resolver.rs | 6 +- backend/src/library/scanner.rs | 6 +- backend/src/model/config/app.rs | 8 +- backend/src/model/config/input.rs | 11 +- backend/src/model/config/library.rs | 6 +- backend/src/model/config/source.rs | 8 +- backend/src/model/config/trakt.rs | 3 + backend/src/model/input_source.rs | 3 +- backend/src/model/playlist.rs | 3 +- backend/src/model/xmltv.rs | 4 +- backend/src/model/xtream.rs | 9 +- backend/src/processing/parser/hls.rs | 150 +++- backend/src/processing/parser/m3u.rs | 104 +-- backend/src/processing/parser/xmltv.rs | 14 +- backend/src/processing/parser/xtream.rs | 251 +++--- backend/src/processing/playlist_watch.rs | 11 +- backend/src/processing/processor/epg.rs | 14 +- backend/src/processing/processor/library.rs | 181 ++--- backend/src/processing/processor/playlist.rs | 69 +- backend/src/processing/processor/sort.rs | 9 +- backend/src/processing/processor/trakt.rs | 23 +- .../src/processing/processor/xtream_series.rs | 28 +- backend/src/repository/bplustree.rs | 734 +++++++++++++----- backend/src/repository/epg_repository.rs | 2 +- backend/src/repository/library_repository.rs | 5 +- .../src/repository/m3u_playlist_iterator.rs | 4 +- backend/src/repository/m3u_repository.rs | 76 +- backend/src/repository/playlist_repository.rs | 56 +- backend/src/repository/playlist_source.rs | 3 +- backend/src/repository/sorted_index.rs | 203 +++-- backend/src/repository/strm_repository.rs | 37 +- backend/src/repository/target_id_mapping.rs | 344 ++++++-- backend/src/repository/user_repository.rs | 13 +- backend/src/repository/xtream_repository.rs | 102 +-- backend/src/utils/file/csv_input_reader.rs | 11 +- backend/src/utils/file/file_lock_manager.rs | 17 + backend/src/utils/mod.rs | 1 + backend/src/utils/network/epg.rs | 5 +- backend/src/utils/network/request.rs | 6 + backend/src/utils/network/xtream.rs | 44 +- frontend/Cargo.toml | 5 +- frontend/public/assets/i18n/en.json | 9 +- frontend/public/assets/icons.json | 20 + frontend/scss/app/_component.scss | 1 - frontend/scss/app/components/_form.scss | 1 + frontend/scss/app/components/_input.scss | 2 +- frontend/scss/app/components/_menu_item.scss | 2 +- .../app/components/_radio_button_group.scss | 12 +- frontend/scss/app/components/_sidebar.scss | 2 +- .../components/api_user/_api_user_view.scss | 28 + .../app/components/config/_config_view.scss | 24 +- .../playlist/_playlist_explorer.scss | 150 +++- .../components/source_editor/_input_form.scss | 0 .../source_editor/_source_editor.scss | 17 +- .../source_editor/_source_editor_form.scss | 38 +- .../components/api_user/target_playlist.rs | 151 +++- frontend/src/app/components/breadcrumbs.rs | 1 + frontend/src/app/components/collapse_panel.rs | 9 +- frontend/src/app/components/config/macros.rs | 6 +- .../app/components/dashboard/stats_view.rs | 2 +- .../app/components/dashboard/streams_table.rs | 10 +- .../app/components/drop_down_icon_button.rs | 2 + .../src/app/components/filter/filter_input.rs | 1 + frontend/src/app/components/hide_content.rs | 1 + frontend/src/app/components/home.rs | 3 +- frontend/src/app/components/icon_button.rs | 5 +- .../src/app/components/key_value_editor.rs | 1 + frontend/src/app/components/login.rs | 1 + frontend/src/app/components/menu_item.rs | 1 + frontend/src/app/components/number_input.rs | 1 + .../playlist/epg_source_selector.rs | 2 +- .../app/components/playlist/input_table.rs | 6 +- .../components/playlist/playlist_explorer.rs | 249 +++++- .../playlist/playlist_source_selector.rs | 2 +- frontend/src/app/components/reveal_content.rs | 1 + .../source_editor/alias_item_form.rs | 6 +- .../components/source_editor/block_view.rs | 10 +- .../components/source_editor/editor_view.rs | 5 +- .../components/source_editor/input_form.rs | 241 ++++-- .../source_editor/output_strm_form.rs | 86 +- .../source_editor/output_xtream_form.rs | 134 +++- .../components/source_editor/target_form.rs | 82 +- frontend/src/app/components/tag_list.rs | 1 + frontend/src/app/components/text_button.rs | 6 +- frontend/src/app/components/toggle_switch.rs | 1 + .../src/app/components/userlist/user_table.rs | 1 + frontend/src/model/event_message.rs | 3 +- frontend/src/services/playlist_service.rs | 65 +- shared/Cargo.toml | 2 +- shared/src/foundation/filter.rs | 56 +- shared/src/foundation/mapper.rs | 16 +- shared/src/model/config/base.rs | 2 +- shared/src/model/config/input.rs | 122 +-- shared/src/model/config/panel_api.rs | 15 +- shared/src/model/config/source.rs | 13 +- shared/src/model/config/trakt.rs | 18 +- shared/src/model/mod.rs | 4 +- shared/src/model/playlist.rs | 474 ++++++----- shared/src/model/playlist_document.rs | 312 ++++---- shared/src/model/playlist_info_document.rs | 461 ++++++----- shared/src/model/playlist_request.rs | 77 +- shared/src/model/status_check.rs | 3 +- shared/src/model/stream_info.rs | 18 +- shared/src/model/stream_properties.rs | 495 +++++++----- shared/src/model/uuidtype.rs | 141 ++++ shared/src/model/web_socket.rs | 3 +- shared/src/model/xtream.rs | 219 +++--- shared/src/utils/constants.rs | 2 +- shared/src/utils/default_utils.rs | 5 + shared/src/utils/hash_utils.rs | 3 +- shared/src/utils/serde_utils.rs | 37 +- shared/src/utils/string_interner.rs | 286 ++++++- shared/src/utils/string_utils.rs | 3 +- 139 files changed, 5094 insertions(+), 2797 deletions(-) delete mode 100644 frontend/scss/app/components/source_editor/_input_form.scss create mode 100644 shared/src/model/uuidtype.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b5e1899..2fa17fa58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - **Global Input Definitions**: To align input definitions with the SourceEditor, inputs are now defined globally in the `inputs` section of the config file. Each source can reference one or more inputs by their name in the `inputs` attribute. - **Data Format Migration**: Due to heavy refactoring, the old data format is invalid. You need to clean your `data` folder and update the playlists. - **B+Tree Storage Format**: Storage format has changed to a more efficient Slotted Page architecture. + - **Index optimization**: Added index to B+Tree to accelerate queries without tree traversal. + - **TargetIdMapping Optimization**: Refactored to use disk-based B+Tree operations, eliminating startup latency. + - **B+Tree Header Metadata**: Implemented efficient `BPlusTreeMetadata` Enum to persist `VirtualId` counter directly in the database header. + - **Fast Initialization**: `TargetIdMapping` now conditionally loads the tree, achieving near-instant startup for established databases. - **Configuration Renames**: - `threads` attribute in `config.yml` renamed to `process_parallel` (boolean). - Added mandatory `rewrite_secret` to `reverse_proxy` config for stable resource URLs. diff --git a/Cargo.lock b/Cargo.lock index 1d2de60bb..8655dcbfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.42" +version = "3.2.43" dependencies = [ "anyhow", "base64", @@ -1171,6 +1171,7 @@ dependencies = [ "gloo-timers 0.2.6", "gloo-utils 0.2.0", "implicit-clone", + "indexmap", "js-sys", "log", "prost", @@ -2526,6 +2527,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -3983,7 +3993,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.42" +version = "3.2.43" dependencies = [ "base64", "bitflags 2.10.0", @@ -4558,7 +4568,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.42" +version = "3.2.43" dependencies = [ "arc-swap", "async-compression", @@ -4590,6 +4600,7 @@ dependencies = [ "log", "lol_html", "lz4_flex", + "memmap2", "mime", "mime_guess", "notify", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d512b4b36..52a8588d4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.42" +version = "3.2.43" edition = "2021" rust-version = "1.89.0" @@ -59,6 +59,7 @@ base64 = "0.22" paste = "1.0" indexmap = "2" lz4_flex = "0.12" +memmap2 = "0.9" fs2 = "0.4" notify = "8.2" arc-swap = "1.8" diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 34b0f0d8a..d30553c86 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1,4 +1,3 @@ -use crate::BUILD_TIMESTAMP; use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}; use crate::api::model::{create_channel_unavailable_stream, create_custom_video_stream_response, create_provider_connections_exhausted_stream, create_provider_stream, @@ -8,40 +7,44 @@ use crate::api::model::{create_channel_unavailable_stream, create_custom_video_s use crate::api::model::{tee_stream, UserSession}; use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy}; use crate::api::panel_api::try_provision_account_on_exhausted; +use crate::auth::Fingerprint; use crate::model::{ConfigInput, ResourceRetryConfig}; use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::tools::lru_cache::LRUResourceCache; -use crate::utils::{request}; +use crate::utils::request::{content_type_from_ext, parse_range}; use crate::utils::{async_file_reader, async_file_writer, create_new_file_for_write, get_file_extension}; use crate::utils::{debug_if_enabled, trace_if_enabled}; -use crate::auth::Fingerprint; -use crate::utils::request::{content_type_from_ext, parse_range}; +use crate::utils::request; +use crate::BUILD_TIMESTAMP; use arc_swap::ArcSwapOption; +use axum::body::Body; use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode}; use axum::response::IntoResponse; +use bytes::Bytes; use chrono::{DateTime, Utc}; -use futures::{StreamExt, TryStreamExt}; +use futures::{stream, StreamExt, TryStreamExt}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use log::{debug, error, info, log_enabled, trace, warn}; use reqwest::header::RETRY_AFTER; use serde::Serialize; -use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster}; -use shared::utils::{bin_serialize, default_grace_period_millis, human_readable_kbps, trim_slash}; +use shared::concat_string; +use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, ProxyType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster}; +use shared::utils::{bin_serialize, default_grace_period_millis, human_readable_kbps, trim_slash, Internable}; use shared::utils::{ extract_extension_from_url, replace_url_extension, sanitize_sensitive_info, DASH_EXT, HLS_EXT, }; use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap}; +use std::convert::Infallible; use std::io::SeekFrom; use std::path::{Path, PathBuf}; -use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio::sync::Mutex; use tokio_util::io::ReaderStream; use url::Url; -use shared::concat_string; const CONTENT_TYPE_BIN: &str = "application/cbor"; @@ -68,6 +71,13 @@ macro_rules! try_option_bad_request { }; } +#[macro_export] +macro_rules! internal_server_error { + () => { + axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + }; +} + #[macro_export] macro_rules! try_unwrap_body { ($body:expr) => { @@ -126,6 +136,7 @@ pub use try_option_bad_request; pub use try_result_bad_request; pub use try_result_not_found; pub use try_unwrap_body; +pub use internal_server_error; pub fn get_server_time() -> String { chrono::offset::Local::now() @@ -171,7 +182,7 @@ pub async fn serve_file(file_path: &Path, mime_type: String) -> impl IntoRespons ) .body(body)) } - Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), + Err(_) => internal_server_error!(), } } @@ -279,10 +290,10 @@ pub fn get_stream_alternative_url( alias_input: &Arc, ) -> String { let Some(input_user_info) = input.get_user_info() else { - return stream_url.to_owned(); + return stream_url.to_string(); }; let Some(alt_input_user_info) = alias_input.get_user_info() else { - return stream_url.to_owned(); + return stream_url.to_string(); }; let modified = stream_url.replacen(&input_user_info.base_url, &alt_input_user_info.base_url, 1); @@ -290,11 +301,11 @@ pub fn get_stream_alternative_url( modified.replacen(&input_user_info.password, &alt_input_user_info.password, 1) } -async fn get_redirect_alternative_url<'a>( +async fn get_redirect_alternative_url( app_state: &Arc, - redirect_url: &'a str, + redirect_url: &Arc, input: &ConfigInput, -) -> Cow<'a, str> { +) -> Arc { if let Some((base_url, username, password)) = input.get_matched_config_by_url(redirect_url) { if let Some(provider_cfg) = app_state .active_provider @@ -309,15 +320,15 @@ async fn get_redirect_alternative_url<'a>( ) { new_url = new_url.replacen(old_username, new_username, 1); new_url = new_url.replacen(old_password, new_password, 1); - return Cow::Owned(new_url); + return new_url.into(); } // one has credentials the other not, something not right - return Cow::Borrowed(redirect_url); + return redirect_url.clone(); } - return Cow::Owned(new_url); + return new_url.into(); } } - Cow::Borrowed(redirect_url) + redirect_url.clone() } @@ -342,7 +353,7 @@ async fn resolve_streaming_strategy( stream_url: &str, fingerprint: &Fingerprint, input: &ConfigInput, - force_provider: Option<&str>, + force_provider: Option<&Arc>, ) -> StreamingStrategy { // allocate a provider connection let mut provider_connection_handle = match force_provider { @@ -385,9 +396,9 @@ async fn resolve_streaming_strategy( ProviderAllocation::Exhausted => { let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); ProviderStreamState::Custom(stream) - }, - ProviderAllocation::Available(_) => ProviderStreamState::Available(Some(selected_provider_name), url), - ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(selected_provider_name), url), + } + ProviderAllocation::Available(_) => ProviderStreamState::Available(Some(selected_provider_name.intern()), url.intern()), + ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(selected_provider_name.intern()), url.intern()), } } } @@ -468,7 +479,7 @@ async fn create_stream_response_details( item_type: PlaylistItemType, share_stream: bool, connection_permission: UserConnectionPermission, - force_provider: Option<&str>, + force_provider: Option<&Arc>, ) -> StreamDetails { let mut streaming_strategy = resolve_streaming_strategy(app_state, stream_url, fingerprint, input, force_provider).await; let config_grace_period_millis = app_state.app_config.config @@ -591,9 +602,9 @@ where let extension = self.stream_ext.map_or_else( || { extract_extension_from_url(url) - .map_or_else(String::new, std::string::ToString::to_string) + .map_or_else(String::new, ToString::to_string) }, - std::string::ToString::to_string, + ToString::to_string, ); // if there is an action_path (like for timeshift duration/start), it will be added in front of the stream_id @@ -624,13 +635,13 @@ where if params.target_type == TargetType::M3u { if redirect_request || is_dash_request { - let redirect_url = if is_hls_request { - Cow::Owned(replace_url_extension(&provider_url, HLS_EXT)) + let redirect_url: Arc = if is_hls_request { + replace_url_extension(&provider_url, HLS_EXT).into() } else { - provider_url + provider_url.clone() }; let redirect_url = if is_dash_request { - Cow::Owned(replace_url_extension(&redirect_url, DASH_EXT)) + replace_url_extension(&redirect_url, DASH_EXT).into() } else { redirect_url }; @@ -644,7 +655,7 @@ where } } else if params.target_type == TargetType::Xtream { let Some(provider_id) = params.provider_id else { - return Some(axum::http::StatusCode::BAD_REQUEST.into_response()); + return Some(StatusCode::BAD_REQUEST.into_response()); }; if redirect_request { @@ -673,7 +684,7 @@ where ) { None => { error!("Can't find stream url for target {target_name}, context {}, stream_id {virtual_id}", params.req_context); - return Some(axum::http::StatusCode::BAD_REQUEST.into_response()); + return Some(StatusCode::BAD_REQUEST.into_response()); } Some(url) => { match app_state @@ -684,7 +695,7 @@ where Some(provider_cfg) => { get_stream_alternative_url(&url, params.input, &provider_cfg) } - None => url, + None => url.to_string(), } } }; @@ -812,15 +823,15 @@ pub async fn force_provider_stream_response( if let (Some(stream), _stream_info) = create_channel_unavailable_stream( &app_state.app_config, &[], - axum::http::StatusCode::SERVICE_UNAVAILABLE, + StatusCode::SERVICE_UNAVAILABLE, ) { app_state.connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ChannelUnavailable).await; debug!("Streaming custom stream"); try_unwrap_body!(axum::response::Response::builder() - .status(axum::http::StatusCode::OK) + .status(StatusCode::OK) .body(axum::body::Body::from_stream(stream))) } else { - axum::http::StatusCode::BAD_REQUEST.into_response() + StatusCode::BAD_REQUEST.into_response() } } @@ -933,7 +944,7 @@ pub async fn stream_response( } try_unwrap_body!(response.body(axum::body::Body::from_stream(broadcast_stream))) } else { - axum::http::StatusCode::BAD_REQUEST.into_response() + StatusCode::BAD_REQUEST.into_response() } } else { // Previously, we would always check if the provider redirected the request. @@ -1010,7 +1021,7 @@ pub async fn stream_response( return stream_resp.into_response(); } app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; - axum::http::StatusCode::BAD_REQUEST.into_response() + StatusCode::BAD_REQUEST.into_response() } fn get_stream_throttle(app_state: &Arc) -> u64 { @@ -1048,7 +1059,7 @@ async fn try_shared_stream_response_if_any( { let (status_code, header_map) = get_stream_response_with_headers(Some(( headers.clone(), - axum::http::StatusCode::OK, + StatusCode::OK, ))); let mut stream_details = StreamDetails::from_stream(stream); @@ -1078,6 +1089,7 @@ pub async fn local_stream_response( _target: &ConfigTarget, _user: &ProxyUserCredentials, connection_permission: UserConnectionPermission, + check_path: bool, ) -> impl IntoResponse + Send { if log_enabled!(log::Level::Trace) { trace!("Try to open stream {}", sanitize_sensitive_info(&pli.url)); @@ -1110,19 +1122,19 @@ pub async fn local_stream_response( let path = match path.canonicalize() { Ok(canonical) => canonical, Err(err) => { - error!("Local library file path is corrupt {}: {err}", path.display()); + error!("Local file path is corrupt {}: {err}", path.display()); return StatusCode::NOT_FOUND.into_response(); } }; // Verify path is within allowed media directories // (requires configuration of allowed base paths) - if !is_path_within_allowed_directories(&path, &library_paths) { + if check_path && !is_path_within_allowed_directories(&path, &library_paths) { return StatusCode::FORBIDDEN.into_response(); } let Ok(mut file) = tokio::fs::File::open(&path).await else { return StatusCode::NOT_FOUND.into_response() }; - let Ok(metadata) = file.metadata().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; + let Ok(metadata) = file.metadata().await else { return internal_server_error!() }; let file_size = metadata.len(); let range = req_headers @@ -1163,7 +1175,7 @@ pub async fn local_stream_response( if start > 0 { if let Err(_err) = file.seek(SeekFrom::Start(start)).await { - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + return internal_server_error!(); } } @@ -1252,7 +1264,7 @@ fn get_add_cache_content( add_cache_content } -fn get_mime_type(headers: &axum::http::HeaderMap, resource_url: &str) -> Option { +fn get_mime_type(headers: &HeaderMap, resource_url: &str) -> Option { headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) // Option<&str> @@ -1275,7 +1287,7 @@ async fn build_resource_stream_response( let mut response_builder = axum::response::Response::builder().status(status); let mime_type = get_mime_type(response.headers(), resource_url); - let has_content_range = response.headers().contains_key(axum::http::header::CONTENT_RANGE); + let has_content_range = response.headers().contains_key(header::CONTENT_RANGE); for (key, value) in response.headers() { let name = key.as_str(); let is_hop_by_hop = matches!( @@ -1297,7 +1309,7 @@ async fn build_resource_stream_response( .bytes_stream() .map_err(|err| StreamError::reqwest(&err)); // Cache only complete responses (200 OK without Content-Range) - let can_cache = status == axum::http::StatusCode::OK && !has_content_range; + let can_cache = status == StatusCode::OK && !has_content_range; if can_cache { debug!( "Caching eligible resource stream {sanitized_resource_url}"); let cache_resource_path = if let Some(cache) = app_state.cache.load().as_ref() { @@ -1437,7 +1449,7 @@ pub async fn resource_response( input: Option<&ConfigInput>, ) -> impl IntoResponse + Send { if resource_url.is_empty() { - return axum::http::StatusCode::NO_CONTENT.into_response(); + return StatusCode::NO_CONTENT.into_response(); } let filter: HeaderFilter = Some(Box::new(|key| { key != "if-none-match" && key != "if-modified-since" @@ -1462,10 +1474,10 @@ pub async fn resource_response( return resp; } // Upstream failure after retries - return axum::http::StatusCode::BAD_GATEWAY.into_response(); + return StatusCode::BAD_GATEWAY.into_response(); } error!("Url is malformed {}", sanitize_sensitive_info(resource_url)); - axum::http::StatusCode::BAD_REQUEST.into_response() + StatusCode::BAD_REQUEST.into_response() } pub fn separate_number_and_remainder(input: &str) -> (String, Option) { @@ -1482,8 +1494,8 @@ pub fn separate_number_and_remainder(input: &str) -> (String, Option) { /// # Panics pub fn empty_json_list_response() -> axum::response::Response { try_unwrap_body!(axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) .body("[]".to_owned())) } @@ -1511,8 +1523,8 @@ pub fn get_username_from_auth_header(token: &str, app_state: &Arc) -> /// # Panics pub fn redirect(url: &str) -> impl IntoResponse { try_unwrap_body!(axum::response::Response::builder() - .status(axum::http::StatusCode::FOUND) - .header(axum::http::header::LOCATION, url) + .status(StatusCode::FOUND) + .header(header::LOCATION, url) .body(axum::body::Body::empty())) } @@ -1539,15 +1551,15 @@ pub async fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> pub fn bin_response(data: &T) -> impl IntoResponse + Send { match bin_serialize(data) { Ok(body) => ( - [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_BIN)], + [(header::CONTENT_TYPE, CONTENT_TYPE_BIN)], body, ).into_response(), - Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), + Err(_) => internal_server_error!(), } } pub fn json_response(data: &T) -> impl IntoResponse + Send { - (axum::http::StatusCode::OK, axum::Json(data)).into_response() + (StatusCode::OK, axum::Json(data)).into_response() } pub fn json_or_bin_response(accept: Option<&str>, data: &T) -> impl IntoResponse + Send { @@ -1557,8 +1569,117 @@ pub fn json_or_bin_response(accept: Option<&str>, data: &T) -> imp json_response(data).into_response() } -pub fn create_session_fingerprint(fingerprint: &str, username: &str, virtual_id: u32) -> String { - format!("{fingerprint}|{username}|{virtual_id}") +pub fn stream_json_or_bin_response

(accept: Option<&str>, data: Box + Send>) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, +{ + if accept.is_some_and(|a| a.contains(CONTENT_TYPE_BIN)) { + return stream_bin_array(data); + } + stream_json_array(data) +} + +pub fn create_session_fingerprint(fingerprint: &Fingerprint, username: &str, virtual_id: u32) -> String { + concat_string!(&fingerprint.key, "|", username, "|", &virtual_id.to_string()) +} + +pub fn stream_json_array

(iter: Box + Send>) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, +{ + let stream = stream::unfold( + (iter, true), + |(mut iter, first)| async move { + match iter.next() { + Some(item) => { + let mut json = String::new(); + if !first { + json.push(','); + } + let element = serde_json::to_string(&item).ok()?; + json.push_str(&element); + Some((Ok::(Bytes::from(json)), (iter, false))) + } + None => None, + } + }, + ); + + let body = Body::from_stream( + stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"[")) }) + .chain(stream) + .chain(stream::once(async { + Ok::<_, Infallible>(Bytes::from_static(b"]")) + })), + ); + + try_unwrap_body!(Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .body(body)) +} + +pub fn stream_bin_array

(iter: Box + Send>) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, +{ + let stream = stream::unfold( + iter, + |mut iter| async move { + match iter.next() { + Some(item) => { + match bin_serialize(&item) { + Ok(buf) => Some((Ok::(Bytes::from(buf)), iter)), + Err(err) => { + warn!("CBOR serialization error in stream: {err}"); + Some((Ok::(Bytes::new()), iter)) // skip errors, continue + } + } + } + None => None, + } + }, + ); + + let body = Body::from_stream( + stream::once(async { + // CBOR: start indefinite-length array + Ok::<_, Infallible>(Bytes::from_static(&[0x9f])) + }) + .chain(stream) + .chain(stream::once(async { + // CBOR: end indefinite-length array + Ok::<_, Infallible>(Bytes::from_static(&[0xff])) + })), + ); + + try_unwrap_body!(Response::builder() + .header(header::CONTENT_TYPE, CONTENT_TYPE_BIN) + .body(body)) +} + +pub fn create_api_proxy_user(app_state: &Arc) -> ProxyUserCredentials { + let config = app_state.app_config.config.load(); + + let server = config + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.player_server.as_ref()) + .map_or("default", |server_name| server_name.as_str()); + + ProxyUserCredentials { + username: "api_user".to_string(), + password: "api_user".to_string(), + token: None, + proxy: ProxyType::Reverse(None), + server: Some(server.to_string()), + epg_timeshift: None, + created_at: None, + exp_date: None, + max_connections: 0, + status: None, + ui_enabled: false, + comment: None, + } } #[cfg(test)] diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index cb1d1031e..bfefbb1df 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -1,150 +1,53 @@ use crate::model::{AppConfig, ConfigInput, ConfigTarget}; use crate::repository::{m3u_repository, xtream_repository}; use crate::utils::{m3u, xtream}; -use crate::utils; use axum::response::IntoResponse; -use indexmap::IndexMap; -use serde::Serialize; use serde_json::{json}; -use shared::model::{CommonPlaylistItem, InputType, M3uPlaylistItem, PlaylistCategoriesResponse, - PlaylistGroup, PlaylistItemType, PlaylistResponseGroup, TargetType, XtreamCluster}; +use shared::model::{CommonPlaylistItem, InputType, TargetType, XtreamCluster}; use std::sync::Arc; -use crate::api::api_utils::{json_or_bin_response}; +use crate::api::api_utils::{empty_json_list_response, json_or_bin_response, stream_json_or_bin_response}; +use shared::utils::interner_gc; -fn group_playlist_items( - cluster: XtreamCluster, - iter: impl Iterator, - get_group: fn(&T) -> Arc, -) -> Vec -where - T: Serialize + Into, -{ - let mut groups: IndexMap, Vec> = IndexMap::new(); - - for item in iter { - let group_key = get_group(&item); - groups.entry(group_key) - .or_default() - .push(item); - } - - groups - .into_iter() - .enumerate() - .map(|(index, (key, value))| PlaylistResponseGroup { - #[allow(clippy::cast_possible_truncation)] - id: index as u32, - title: key.clone(), - channels: value.into_iter().map(Into::into).collect(), - xtream_cluster: cluster, - }) - .collect() -} - -fn group_playlist_items_by_cluster(params: Option<(utils::FileReadGuard, - impl Iterator)>) -> - (Vec, Vec, Vec) { - match params { - None => (vec![], vec![], vec![]), - Some((guard, iter)) => { - let mut live = Vec::new(); - let mut video = Vec::new(); - let mut series = Vec::new(); - for (item, _) in iter { - match item.item_type { - PlaylistItemType::Live - | PlaylistItemType::LiveUnknown - | PlaylistItemType::LiveHls - | PlaylistItemType::LiveDash => { - live.push(item); - } - PlaylistItemType::Catchup - | PlaylistItemType::Video - | PlaylistItemType::LocalVideo => { - video.push(item); - } - PlaylistItemType::Series - | PlaylistItemType::SeriesInfo - | PlaylistItemType::LocalSeries - | PlaylistItemType::LocalSeriesInfo => { - series.push(item); - } - } - } - - drop(guard); - - (live, video, series) - } - } -} - -fn group_playlist_groups_by_cluster(playlist: Vec) -> (Vec, Vec, Vec) { - let mut live = Vec::new(); - let mut video = Vec::new(); - let mut series = Vec::new(); - for group in playlist { - let channels = group.channels.iter() - .map(CommonPlaylistItem::from) - .collect(); - let grp = PlaylistResponseGroup { - id: group.id, - title: group.title, - channels, - xtream_cluster: group.xtream_cluster, - }; - match group.xtream_cluster { - XtreamCluster::Live => live.push(grp), - XtreamCluster::Video => video.push(grp), - XtreamCluster::Series => series.push(grp), - } - } - (live, video, series) -} - -async fn grouped_channels( - cfg: &AppConfig, - target: &ConfigTarget, - cluster: XtreamCluster, -) -> Option> { - xtream_repository::iter_raw_xtream_playlist(cfg, target, cluster).await - .map(|(_guard, iter)| group_playlist_items::( - cluster, - iter.filter(|(item, _)| item.item_type != PlaylistItemType::LocalSeries).map(|(v, _)| v.to_common()), - |item| item.group.clone(), - )) -} - -pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &AppConfig, accept: Option<&str>) -> impl IntoResponse + Send { +pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &AppConfig, cluster: XtreamCluster, accept: Option<&str>) -> impl IntoResponse + Send { if let Some(target) = cfg_target { if target.has_output(TargetType::Xtream) { - let live_channels = grouped_channels(cfg, target, XtreamCluster::Live).await; - let vod_channels = grouped_channels(cfg, target, XtreamCluster::Video).await; - let series_channels = grouped_channels(cfg, target, XtreamCluster::Series).await; - - let response = PlaylistCategoriesResponse { - live: live_channels, - vod: vod_channels, - series: series_channels, + let Some((_guard, channel_iterator)) = xtream_repository::iter_raw_xtream_target_playlist(cfg, target, cluster).await else { + return empty_json_list_response(); }; - - return json_or_bin_response(accept, &response).into_response(); + let converted_iterator: Box + Send> = Box::new(channel_iterator.map(CommonPlaylistItem::from)); + return stream_json_or_bin_response(accept, converted_iterator).into_response(); } else if target.has_output(TargetType::M3u) { - let all_channels = m3u_repository::iter_raw_m3u_playlist(cfg, target).await; - let (live_channels, vod_channels, series_channels) = group_playlist_items_by_cluster(all_channels); - let response = PlaylistCategoriesResponse { - live: Some(group_playlist_items::(XtreamCluster::Live, live_channels.into_iter(), |item| item.group.clone())), - vod: Some(group_playlist_items::(XtreamCluster::Video, vod_channels.into_iter(), |item| item.group.clone())), - series: Some(group_playlist_items::(XtreamCluster::Series, series_channels.into_iter(), |item| item.group.clone())), + let Some((_guard, channel_iterator)) = m3u_repository::iter_raw_m3u_target_playlist(cfg, target, Some(cluster)).await else { + return empty_json_list_response(); }; - - return json_or_bin_response(accept, &response).into_response(); + let converted_iterator: Box + Send> = Box::new(channel_iterator.map(CommonPlaylistItem::from)); + return stream_json_or_bin_response(accept, converted_iterator).into_response(); } } (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid Arguments"}))).into_response() } -pub(in crate::api::endpoints) async fn get_playlist(client: &reqwest::Client, cfg_input: Option<&Arc>, app_config: &Arc, accept: Option<&str>) -> impl IntoResponse + Send { + +pub(in crate::api::endpoints) async fn get_playlist_for_input(cfg_input: Option<&Arc>, cfg: &AppConfig, cluster: XtreamCluster, accept: Option<&str>) -> impl IntoResponse + Send { + if let Some(input) = cfg_input { + if matches!(input.input_type, InputType::Xtream | InputType::XtreamBatch) { + let Some((_guard, channel_iterator)) = xtream_repository::iter_raw_xtream_input_playlist(cfg, input, cluster).await else { + return empty_json_list_response(); + }; + let converted_iterator: Box + Send> = Box::new(channel_iterator.map(CommonPlaylistItem::from)); + return stream_json_or_bin_response(accept, converted_iterator).into_response(); + } else if matches!(input.input_type, InputType::M3u | InputType::M3uBatch) { + let Some((_guard, channels)) = m3u_repository::iter_raw_m3u_input_playlist(cfg, input, Some(cluster)).await else { + return empty_json_list_response(); + }; + let converted_iterator: Box + Send> = Box::new(channels.map(CommonPlaylistItem::from)); + return stream_json_or_bin_response(accept, converted_iterator).into_response(); + } + } + (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid Arguments"}))).into_response() +} + +pub(in crate::api::endpoints) async fn get_playlist_for_custom_provider(client: &reqwest::Client, cfg_input: Option<&Arc>, app_config: &Arc, cluster: XtreamCluster, accept: Option<&str>) -> impl IntoResponse + Send { let cfg = app_config.config.load(); match cfg_input { Some(input) => { @@ -152,7 +55,7 @@ pub(in crate::api::endpoints) async fn get_playlist(client: &reqwest::Client, cf match input.input_type { InputType::M3u | InputType::M3uBatch => m3u::download_m3u_playlist(client, &cfg, input).await, InputType::Xtream | InputType::XtreamBatch => { - let (pl, err, _) = xtream::download_xtream_playlist(app_config, client, input, None).await; + let (pl, err, _) = xtream::download_xtream_playlist(app_config, client, input, Some(&[cluster])).await; (pl, err) } InputType::Library => { @@ -160,16 +63,12 @@ pub(in crate::api::endpoints) async fn get_playlist(client: &reqwest::Client, cf } }; if result.is_empty() { - let error_strings: Vec = errors.iter().map(std::string::ToString::to_string).collect(); + let error_strings: Vec = errors.iter().map(ToString::to_string).collect(); (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": error_strings.join(", ")}))).into_response() } else { - let (live, vod, series) = group_playlist_groups_by_cluster(result); - let response = PlaylistCategoriesResponse { - live: Some(live), - vod: Some(vod), - series: Some(series), - }; - json_or_bin_response(accept, &response).into_response() + let channels: Vec = result.iter().flat_map(|g| g.channels.iter()).map(CommonPlaylistItem::from).collect(); + interner_gc(); + json_or_bin_response(accept, &channels).into_response() } } None => (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid Arguments"}))).into_response(), diff --git a/backend/src/api/endpoints/hdhomerun_api.rs b/backend/src/api/endpoints/hdhomerun_api.rs index 0b3513dbd..c1fe49433 100644 --- a/backend/src/api/endpoints/hdhomerun_api.rs +++ b/backend/src/api/endpoints/hdhomerun_api.rs @@ -1,7 +1,8 @@ -use crate::api::api_utils::try_unwrap_body; +use crate::api::api_utils::{try_unwrap_body, internal_server_error}; use crate::api::model::HdHomerunAppState; use crate::auth::AuthBasic; use crate::model::{AppConfig, ConfigTarget, ProxyUserCredentials}; +use crate::utils::arc_str_serde; use crate::processing::parser::xtream::get_xtream_url; use crate::repository::m3u_playlist_iterator::M3uPlaylistIterator; use crate::repository::m3u_repository; @@ -20,12 +21,12 @@ use std::sync::Arc; #[derive(Serialize, Deserialize, Clone)] struct Lineup { - #[serde(rename = "GuideNumber")] - guide_number: String, - #[serde(rename = "GuideName")] - guide_name: String, - #[serde(rename = "URL")] - url: String, + #[serde(with = "arc_str_serde", rename = "GuideNumber")] + guide_number: Arc, + #[serde(with = "arc_str_serde", rename = "GuideName")] + guide_name: Arc, + #[serde(with = "arc_str_serde", rename = "URL")] + url: Arc, } #[derive(Serialize, Deserialize, Clone)] @@ -124,13 +125,13 @@ where container_extension.as_deref(), live_stream_use_prefix, live_stream_without_extension, - ), + ).into(), }; let lineup = Lineup { - guide_number: item.epg_channel_id.unwrap_or(item.name).clone(), + guide_number: item.epg_channel_id.unwrap_or(item.name.clone()), guide_name: item.title.clone(), - url: stream_url, + url: stream_url.clone(), }; match serde_json::to_string(&lineup) { Ok(mut content) => { @@ -156,14 +157,13 @@ where Some(chans) => { let mapped = chans.map(move |(item, has_next)| { let lineup = Lineup { - guide_number: item.epg_channel_id.unwrap_or(item.name).clone(), + guide_number: item.epg_channel_id.clone().unwrap_or(item.name.clone()), guide_name: item.title.clone(), - url: (if item.t_stream_url.is_empty() { - &item.url + url: if item.t_stream_url.is_empty() { + item.url.clone() } else { - &item.t_stream_url - }) - .clone(), + item.t_stream_url.clone() + } }; match serde_json::to_string(&lineup) { Ok(mut content) => { @@ -230,7 +230,7 @@ async fn device_xml( .header(axum::http::header::CONTENT_TYPE, "application/xml") .body(axum::body::Body::from(device.as_xml()))) } else { - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() } } @@ -240,7 +240,7 @@ async fn device_json( if let Some(device) = create_device(&app_state) { axum::Json(device).into_response() } else { - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() } } @@ -250,7 +250,7 @@ async fn discover_json( if let Some(device) = create_device(&app_state) { axum::Json(device).into_response() } else { - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() } } @@ -277,8 +277,7 @@ async fn lineup_status( cfg.get_target_for_username(&app_state.device.t_username) { if target.has_output(TargetType::M3u) { - if let Some((_guard, iter)) = - m3u_repository::iter_raw_m3u_playlist(&cfg, &target).await + if let Some((_guard, iter)) = m3u_repository::iter_raw_m3u_target_playlist(&cfg, &target, None).await { iter.count() } else { @@ -286,10 +285,8 @@ async fn lineup_status( } } else if target.has_output(TargetType::Xtream) { let credentials = Arc::new(user); - let live = - XtreamPlaylistIterator::new(XtreamCluster::Live, &cfg, &target, None, &credentials).await.map_or(0, std::iter::Iterator::count); - let vod = - XtreamPlaylistIterator::new(XtreamCluster::Video, &cfg, &target, None, &credentials).await.map_or(0, std::iter::Iterator::count); + let live = XtreamPlaylistIterator::new(XtreamCluster::Live, &cfg, &target, None, &credentials).await.map_or(0, std::iter::Iterator::count); + let vod = XtreamPlaylistIterator::new(XtreamCluster::Video, &cfg, &target, None, &credentials).await.map_or(0, std::iter::Iterator::count); live + vod } else { 0 diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index 192074150..d39c66d9c 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -1,6 +1,6 @@ use crate::api::api_utils::{create_session_fingerprint, try_unwrap_body}; use crate::api::api_utils::{ - force_provider_stream_response, get_stream_alternative_url, is_seek_request, + force_provider_stream_response, get_stream_alternative_url, is_seek_request, local_stream_response, }; use crate::api::api_utils::{get_headers_from_request, try_option_bad_request, HeaderFilter}; use crate::api::model::AppState; @@ -21,8 +21,9 @@ use axum::response::IntoResponse; use log::{debug, error}; use serde::Deserialize; use shared::model::{PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster}; -use shared::utils::{intern, is_hls_url, replace_url_extension, sanitize_sensitive_info, CUSTOM_VIDEO_PREFIX, HLS_EXT}; +use shared::utils::{is_hls_url, replace_url_extension, sanitize_sensitive_info, Internable, CUSTOM_VIDEO_PREFIX, HLS_EXT}; use std::sync::Arc; +use crate::utils::request::is_file_url; const PLAYLIST_TEMPLATE: &str = r"#EXTM3U #EXT-X-VERSION:3 @@ -102,7 +103,7 @@ pub(in crate::api) async fn handle_hls_stream_request( provider_cfg.name, sanitize_sensitive_info(&stream_url) ); - let user_session_token = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id); + let user_session_token = create_session_fingerprint(fingerprint, &user.username, virtual_id); let session_token = app_state.active_users.create_user_session( user, &user_session_token, @@ -187,8 +188,9 @@ async fn resolve_stream_channel( app_state: &Arc, target: &Arc, virtual_id: u32, - hls_url: &str, + hls_url: &Arc, ) -> StreamChannel { + let unknown = "Unknown".intern(); let mut channel = match get_stream_channel(app_state, target, virtual_id).await { Some(channel) => channel, None => StreamChannel { @@ -197,9 +199,9 @@ async fn resolve_stream_channel( provider_id: 0, item_type: PlaylistItemType::LiveHls, cluster: XtreamCluster::Live, - group: intern("Unknown"), - title: "Unknown".to_string(), - url: hls_url.to_string(), + group: unknown.clone(), + title: unknown, + url: hls_url.clone(), shared: false, }, }; @@ -240,7 +242,7 @@ async fn hls_api_stream( ); debug_if_enabled!("ID chain for hls endpoint: request_stream_id={} -> virtual_id={virtual_id}", params.stream_id); - let user_session_token = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id); + let user_session_token = create_session_fingerprint(&fingerprint, &user.username, virtual_id); let mut user_session = app_state .active_users .get_and_update_user_session(&user.username, &user_session_token).await; @@ -272,8 +274,8 @@ async fn hls_api_stream( Some((Some(session_token), hls_url)) if session.token.eq(&session_token) => hls_url, _ => return axum::http::StatusCode::BAD_REQUEST.into_response(), }; - - session.stream_url.clone_from(&hls_url); + let hls_url = hls_url.intern(); + session.stream_url = hls_url.clone(); if session.virtual_id == virtual_id { let stream_channel = resolve_stream_channel(&app_state, &target, virtual_id, &hls_url).await; if is_seek_request(stream_channel.cluster, &req_headers).await { @@ -314,8 +316,26 @@ async fn hls_api_stream( &req_headers, connection_permission, ) - .await - .into_response(); + .await + .into_response(); + } + + if is_file_url(&session.stream_url) { + let stream_channel = + resolve_stream_channel(&app_state, &target, virtual_id, &hls_url).await; + return local_stream_response( + &fingerprint, + &app_state, + stream_channel, + &req_headers, + &input, + &target, + &user, + connection_permission, + false, + ) + .await + .into_response(); } let stream_channel = resolve_stream_channel(&app_state, &target, virtual_id, &hls_url).await; diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 48c643a26..6045d5b69 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -142,6 +142,7 @@ async fn m3u_api_stream( &target, &user, connection_permission, + true, ).await.into_response(); } @@ -150,7 +151,7 @@ async fn m3u_api_stream( debug_if_enabled!( "ID chain for m3u endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}", stream_req.stream_id); - let session_key = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id); + let session_key = create_session_fingerprint(fingerprint, &user.username, virtual_id); let user_session = app_state .active_users .get_and_update_user_session(&user.username, &session_key).await; @@ -189,9 +190,9 @@ async fn m3u_api_stream( .await .into_response(); } - session.stream_url.as_str() + session.stream_url.clone() } else { - pli.url.as_str() + pli.url.clone() }; let connection_permission = user.connection_permission(app_state).await; @@ -252,7 +253,7 @@ async fn m3u_api_stream( app_state, &session_key, pli.to_stream_channel(target.id), - session_url, + &session_url, req_headers, &input, &target, diff --git a/backend/src/api/endpoints/user_api.rs b/backend/src/api/endpoints/user_api.rs index 48fab2dc3..c2263c469 100644 --- a/backend/src/api/endpoints/user_api.rs +++ b/backend/src/api/endpoints/user_api.rs @@ -29,9 +29,9 @@ fn get_categories_from_xtream(categories: Option>) - async fn get_categories_from_m3u_playlist(target: &ConfigTarget, config: &AppConfig) -> Vec> { let mut groups = Vec::new(); - if let Some((_guard, iter)) = m3u_repository::iter_raw_m3u_playlist(config, target).await { + if let Some((_guard, iter)) = m3u_repository::iter_raw_m3u_target_playlist(config, target, None).await { let mut unique_groups = HashSet::new(); - for (item, _has_next) in iter { + for item in iter { if !unique_groups.contains(&item.group) { unique_groups.insert(item.group.clone()); groups.push(item.group.clone()); diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index e06f103aa..269a07ba9 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -1,4 +1,4 @@ -use crate::api::api_utils::{json_or_bin_response, try_unwrap_body}; +use crate::api::api_utils::{json_or_bin_response, try_unwrap_body, internal_server_error}; use crate::api::endpoints::download_api; use crate::api::endpoints::user_api::user_api_register; use crate::api::endpoints::v1_api_playlist::v1_api_playlist_register; @@ -9,7 +9,7 @@ use crate::utils::ip_checker::get_ips; use crate::{VERSION}; use axum::response::IntoResponse; use shared::model::{default_geoip_url, InputFetchMethod, IpCheckDto, StatusCheck}; -use shared::utils::{concat_path_leading_slash}; +use shared::utils::{concat_path_leading_slash, Internable}; use std::collections::{BTreeMap, HashMap}; use std::io::{Cursor}; use std::sync::Arc; @@ -83,7 +83,7 @@ async fn geoip_update(axum::extract::State(app_state): axum::extract::State { error!("Failed to process geoip db: {err}"); - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() }, - _ => { - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() - } + _ => internal_server_error!() } } Err(err) => { diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index 9622d0e2c..2ea7e3faa 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -7,7 +7,7 @@ use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, Sources use std::sync::Arc; use log::{error}; use shared::error::TuliproxError; -use crate::api::api_utils::try_unwrap_body; +use crate::api::api_utils::{try_unwrap_body, internal_server_error}; use crate::{utils}; use crate::utils::{prepare_sources_batch, prepare_users}; use crate::utils::request::download_text_content; @@ -124,17 +124,17 @@ async fn config( Ok(mut app_config) => { if let Err(err) = prepare_sources_batch(&mut app_config.sources, false).await { error!("Failed to prepare sources batch: {err}"); - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() } else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config).await { error!("Failed to prepare users: {err}"); - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() } else { axum::response::Json(app_config).into_response() } } Err(err) => { error!("Failed to read config files: {err}"); - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() } } } @@ -158,7 +158,7 @@ async fn config_batch_content( } Err(err) => { error!("Failed to read batch file: {err}"); - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + internal_server_error!() } }; } diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index 1b3b4d528..66e1e6100 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -1,23 +1,24 @@ -use std::sync::Arc; -use axum::response::IntoResponse; -use axum::Router; -use log::error; -use serde_json::json; -use shared::model::{InputType, PlaylistEpgRequest, PlaylistRequest, WebplayerUrlRequest}; -use shared::utils::sanitize_sensitive_info; -use crate::api::endpoints::api_playlist_utils::{get_playlist, get_playlist_for_target}; +use crate::api::api_utils::{create_api_proxy_user, json_or_bin_response}; +use crate::api::endpoints::api_playlist_utils::{get_playlist_for_custom_provider, get_playlist_for_input, get_playlist_for_target}; +use crate::api::endpoints::extract_accept_header::ExtractAcceptHeader; use crate::api::model::AppState; use crate::auth::create_access_token; use crate::model::{parse_xmltv_for_web_ui_from_file, parse_xmltv_for_web_ui_from_url, ConfigInput, ConfigInputOptions}; use crate::processing::processor::playlist; +use axum::response::IntoResponse; +use axum::{Router}; +use log::{error, info}; +use serde_json::json; +use shared::model::{InputType, PlaylistEpgRequest, PlaylistRequest, ProxyType, TargetType, WebplayerUrlRequest, XtreamCluster}; +use shared::utils::{sanitize_sensitive_info, Internable}; +use std::sync::Arc; use url::Url; -use crate::api::api_utils::{json_or_bin_response}; -use crate::api::endpoints::extract_accept_header::ExtractAcceptHeader; +use crate::api::endpoints::xtream_api::xtream_get_stream_info_response; fn create_config_input_for_m3u(url: &str) -> ConfigInput { ConfigInput { id: 0, - name: String::from("m3u_req"), + name: "m3u_req".intern(), input_type: InputType::M3u, url: String::from(url), enabled: true, @@ -35,7 +36,7 @@ fn create_config_input_for_m3u(url: &str) -> ConfigInput { fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> ConfigInput { ConfigInput { id: 0, - name: String::from("xc_req"), + name: "xc_req".intern(), input_type: InputType::Xtream, url: String::from(host), username: Some(String::from(username)), @@ -66,10 +67,10 @@ async fn playlist_update( let playlist_state = Arc::clone(&app_state.playlists); let valid_targets = Arc::new(valid_targets); tokio::spawn({ - async move { - playlist::exec_processing(&http_client, app_config, valid_targets, Some(event_manager), - Some(playlist_state), Some(app_state.update_guard.clone())).await; - } + async move { + playlist::exec_processing(&http_client, app_config, valid_targets, Some(event_manager), + Some(playlist_state), Some(app_state.update_guard.clone())).await; + } }); axum::http::StatusCode::ACCEPTED.into_response() } @@ -81,24 +82,25 @@ async fn playlist_update( } async fn playlist_content( - ExtractAcceptHeader(accept): ExtractAcceptHeader, - axum::extract::State(app_state): axum::extract::State>, - axum::extract::Json(playlist_req): axum::extract::Json, + accept: Option, + app_state: &Arc, + playlist_req: &PlaylistRequest, + cluster: XtreamCluster, ) -> impl IntoResponse + Send { let _config = app_state.app_config.config.load(); let client = app_state.http_client.load(); match playlist_req { PlaylistRequest::Target(target_id) => { - get_playlist_for_target(app_state.app_config.get_target_by_id(target_id).as_deref(), &app_state.app_config, accept.as_deref()).await.into_response() + get_playlist_for_target(app_state.app_config.get_target_by_id(*target_id).as_deref(), &app_state.app_config, cluster, accept.as_deref()).await.into_response() } PlaylistRequest::Input(input_id) => { - get_playlist(client.as_ref(), app_state.app_config.get_input_by_id(input_id).as_ref(), &app_state.app_config, accept.as_deref()).await.into_response() + get_playlist_for_input(app_state.app_config.get_input_by_id(*input_id).as_ref(), &app_state.app_config, cluster, accept.as_deref()).await.into_response() } PlaylistRequest::CustomXtream(xtream) => { match Url::parse(&xtream.url) { Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => { let input = Arc::new(create_config_input_for_xtream(&xtream.username, &xtream.password, &xtream.url)); - get_playlist(client.as_ref(), Some(&input), &app_state.app_config, accept.as_deref()).await.into_response() + get_playlist_for_custom_provider(client.as_ref(), Some(&input), &app_state.app_config, cluster, accept.as_deref()).await.into_response() } _ => { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response() @@ -109,7 +111,7 @@ async fn playlist_content( match Url::parse(&m3u.url) { Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => { let input = Arc::new(create_config_input_for_m3u(&m3u.url)); - get_playlist(client.as_ref(), Some(&input), &app_state.app_config, accept.as_deref()).await.into_response() + get_playlist_for_custom_provider(client.as_ref(), Some(&input), &app_state.app_config, cluster, accept.as_deref()).await.into_response() } _ => { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response() @@ -119,6 +121,64 @@ async fn playlist_content( } } +macro_rules! create_player_api_for_cluster { + ($fn_name:ident, $cluster:expr) => { + async fn $fn_name( + ExtractAcceptHeader(accept): ExtractAcceptHeader, + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(playlist_req): axum::extract::Json, + ) -> impl IntoResponse + Send { + playlist_content( + accept.clone(), + &app_state, + &playlist_req, + $cluster + ) + .await + .into_response() + } + }; +} + +create_player_api_for_cluster!(playlist_content_live, XtreamCluster::Live); +create_player_api_for_cluster!(playlist_content_vod, XtreamCluster::Video); +create_player_api_for_cluster!(playlist_content_series, XtreamCluster::Series); + +async fn playlist_series_info( + axum::extract::Path((virtual_id, _provider_id)): axum::extract::Path<( + String, + String, + )>, + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(playlist_req): axum::extract::Json, +) -> impl IntoResponse + Send { + match playlist_req { + PlaylistRequest::Target(target_id) => { + if let Some(target) = app_state.app_config.get_target_by_id(target_id) { + if target.has_output(TargetType::Xtream) { + let mut user = create_api_proxy_user(&app_state); + user.proxy = ProxyType::Redirect; + return xtream_get_stream_info_response(&app_state, &user, &target, &virtual_id, XtreamCluster::Series).await.into_response(); + } + } + } + PlaylistRequest::Input(input_id) => { + if let Some(input) = app_state.app_config.get_input_by_id(input_id) { + if matches!(input.input_type, InputType::Xtream | InputType::XtreamBatch) { + // TODO: Implement series info retrieval for input-based requests + info!("TODO: Implement series info retrieval for input-based requests"); + } + } + } + PlaylistRequest::CustomXtream(_xtream) => { + // TODO: Implement series info retrieval for custom Xtream requests + info!("TODO: Implement series info retrieval for custom Xtream requests"); + }, + PlaylistRequest::CustomM3u(_) => {} + } + axum::http::StatusCode::NO_CONTENT.into_response() +} + async fn playlist_webplayer( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_item): axum::extract::Json, @@ -140,7 +200,7 @@ async fn playlist_epg( PlaylistEpgRequest::Target(target_id) => { if let Some(target) = app_state.app_config.get_target_by_id(target_id) { let config = &app_state.app_config.config.load(); - if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_target(config, &target) { + if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_target(config, &target) { if let Ok(epg) = parse_xmltv_for_web_ui_from_file(&epg_path).await { return json_or_bin_response(accept.as_deref(), &epg).into_response(); } @@ -149,14 +209,14 @@ async fn playlist_epg( } PlaylistEpgRequest::Input(_input_id) => { // TODO: This is currently not supported, because we could have multiple epg sources for one input - // if let Some(target) = app_state.app_config.get_input_by_id(input_id) { - // let config = &app_state.app_config.config.load(); - // if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_input(config, &target) { - // if let Ok(epg) = parse_xmltv_for_web_ui(&epg_path) { - // return json_or_bin_response(accept.as_ref(), &epg).into_response(); - // } - // } - // } + // if let Some(target) = app_state.app_config.get_input_by_id(input_id) { + // let config = &app_state.app_config.config.load(); + // if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_input(config, &target) { + // if let Ok(epg) = parse_xmltv_for_web_ui(&epg_path) { + // return json_or_bin_response(accept.as_ref(), &epg).into_response(); + // } + // } + // } } PlaylistEpgRequest::Custom(url) => { if let Ok(epg) = parse_xmltv_for_web_ui_from_url(&app_state, &url).await { @@ -172,5 +232,8 @@ pub fn v1_api_playlist_register(router: Router>) -> axum::Router, - filter: Option, + filter: Option>, ) -> axum::response::Response { if let Ok(exists) = tokio::fs::try_exists(epg_path).await { if exists { @@ -188,7 +188,7 @@ async fn serve_epg_with_rewrites( rewrite_urls: bool, secret: &[u8; 16], base_url: &str, - filter: Option, + filter: Option>, ) -> axum::response::Response { match tokio::fs::try_exists(epg_path).await { Ok(exists) => { @@ -256,14 +256,14 @@ async fn serve_epg_with_rewrites( .filter_map(Result::ok) .find(|a| a.key.as_ref() == b"id") .and_then(|a| a.unescape_value().ok()) - .is_some_and(|v| !flt.eq(v.as_ref())) + .is_some_and(|v| flt.as_ref() != v.as_ref()) } b"programme" => { e.attributes() .filter_map(Result::ok) .find(|a| a.key.as_ref() == b"channel") .and_then(|a| a.unescape_value().ok()) - .is_some_and(|v| !flt.eq(v.as_ref())) + .is_some_and(|v| flt.as_ref() != v.as_ref()) } _ => false, }; @@ -409,7 +409,7 @@ async fn serve_epg_with_rewrites( .header(axum::http::header::CONTENT_ENCODING, "gzip") // Set Content-Encoding header .body(axum::body::Body::from_stream(body_stream))) } - Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), + Err(_) => internal_server_error!(), } } @@ -446,7 +446,6 @@ async fn xmltv_api( serve_epg(&app_state, &epg_path, &user, &target, None).await } -#[axum::debug_handler] async fn epg_api_resource( req_headers: axum::http::HeaderMap, axum::extract::Query(api_req): axum::extract::Query, diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index e7308cd72..877d58bdb 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -1,13 +1,11 @@ // https://github.com/tellytv/go.xtream-codes/blob/master/structs.go // Xtream api -> https://9tzx6f0ozj.apidog.io/ use crate::api::api_utils; -use crate::api::api_utils::{create_session_fingerprint, local_stream_response, try_unwrap_body}; -use crate::api::api_utils::{ - force_provider_stream_response, get_user_target, get_user_target_by_credentials, - is_seek_request, redirect_response, resource_response, separate_number_and_remainder, - stream_response, RedirectParams, -}; -use crate::api::api_utils::{redirect, try_option_bad_request, try_result_bad_request, try_result_not_found}; +use crate::api::api_utils::{create_api_proxy_user, create_session_fingerprint, force_provider_stream_response, + get_user_target, get_user_target_by_credentials, internal_server_error, + is_seek_request, local_stream_response, redirect, redirect_response, + resource_response, separate_number_and_remainder, stream_response, try_option_bad_request, try_result_bad_request, try_result_not_found, + try_unwrap_body, RedirectParams}; use crate::api::endpoints::hls_api::handle_hls_stream_request; use crate::api::endpoints::xmltv_api::{get_empty_epg_response, get_epg_path_for_target, serve_epg}; use crate::api::model::AppState; @@ -33,13 +31,15 @@ use futures::Stream; use log::{debug, error, warn}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use shared::error::{info_err_res, info_err, TuliproxError}; -use shared::model::{create_stream_channel_with_type, PlaylistEntry, PlaylistItemType, ProxyType, TargetType, UserConnectionPermission, XtreamCluster, XtreamPlaylistItem}; -use shared::utils::{deserialize_as_string, extract_extension_from_url, generate_playlist_uuid, sanitize_sensitive_info, trim_slash, HLS_EXT}; +use shared::concat_string; +use shared::error::{info_err, info_err_res, TuliproxError}; +use shared::model::{create_stream_channel_with_type, PlaylistEntry, PlaylistItemType, ProxyType, + TargetType, UserConnectionPermission, XtreamCluster, XtreamPlaylistItem}; +use shared::utils::{deserialize_as_string, extract_extension_from_url, generate_playlist_uuid, + sanitize_sensitive_info, trim_slash, HLS_EXT}; use std::fmt::{Display, Formatter}; use std::str::FromStr; use std::sync::Arc; -use shared::concat_string; #[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)] pub enum ApiStreamContext { @@ -153,8 +153,8 @@ pub(in crate::api) fn get_xtream_player_api_stream_url( input: &ConfigInput, context: ApiStreamContext, action_path: &str, - fallback_url: &str, -) -> Option { + fallback_url: &Arc, +) -> Option> { if let Some(input_user_info) = input.get_user_info() { let ctx = match context { ApiStreamContext::LiveAlt | ApiStreamContext::Live => { @@ -176,9 +176,9 @@ pub(in crate::api) fn get_xtream_player_api_stream_url( trim_slash(action_path), ]; parts.retain(|s| !s.is_empty()); - Some(parts.join("/")) + Some(parts.join("/").into()) } else if !fallback_url.is_empty() { - Some(String::from(fallback_url)) + Some(fallback_url.clone()) } else { None } @@ -266,6 +266,7 @@ async fn xtream_player_api_stream( &target, &user, connection_permission, + true, ).await.into_response(); } @@ -278,7 +279,7 @@ async fn xtream_player_api_stream( debug_if_enabled!( "ID chain for xtream endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}", stream_req.stream_id); - let session_key = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id); + let session_key = create_session_fingerprint(fingerprint, &user.username, virtual_id); let user_session = app_state .active_users .get_and_update_user_session(&user.username, &session_key).await; @@ -321,9 +322,9 @@ async fn xtream_player_api_stream( .into_response(); } - session.stream_url.as_str() + session.stream_url.clone() } else { - pli.url.as_str() + pli.url.clone() }; let connection_permission = user.connection_permission(app_state).await; @@ -356,7 +357,7 @@ async fn xtream_player_api_stream( let (query_path, extension) = get_query_path(stream_req.action_path, stream_ext.as_ref(), &pli, app_state); let stream_url = try_option_bad_request!( - get_xtream_player_api_stream_url(&input, stream_req.context, &query_path, session_url), + get_xtream_player_api_stream_url(&input, stream_req.context, &query_path, &session_url), true, format!( "Can't find stream url for target {target_name}, context {}, stream_id {virtual_id}", @@ -403,8 +404,13 @@ async fn xtream_player_api_stream( } fn get_query_path(action_path: &str, stream_ext: Option<&String>, pli: &XtreamPlaylistItem, app_state: &Arc) -> (String, String) { - - let discard_extension = pli.item_type.is_live() && app_state.app_config.sources.load().get_input_by_name(&pli.input_name).as_ref().and_then(|i| i.options.as_ref()).is_some_and(|o| o.xtream_live_stream_without_extension); + let discard_extension = if pli.item_type.is_live() { + app_state.app_config.sources.load().get_input_by_name(&pli.input_name) + .as_ref().and_then(|i| i.options.as_ref()) + .is_some_and(|o| o.xtream_live_stream_without_extension) + } else { + false + }; let extracted_ext; let extension: &str = if discard_extension { @@ -456,9 +462,7 @@ async fn xtream_player_api_stream_with_token( ); let virtual_id = pli.virtual_id; let input = try_option_bad_request!( - app_state - .app_config - .get_input_by_name(&pli.input_name), + app_state.app_config.get_input_by_name(&pli.input_name), true, format!( "Can't find input {} for target {target_name}, context {}, stream_id {}", @@ -466,42 +470,23 @@ async fn xtream_player_api_stream_with_token( ) ); - let config = app_state.app_config.config.load(); - - let server = config - .web_ui - .as_ref() - .and_then(|web_ui| web_ui.player_server.as_ref()) - .map_or("default", |server_name| server_name.as_str()); - - let user = ProxyUserCredentials { - username: "api_user".to_string(), - password: "api_user".to_string(), - token: None, - proxy: ProxyType::Reverse(None), - server: Some(server.to_string()), - epg_timeshift: None, - created_at: None, - exp_date: None, - max_connections: 0, - status: None, - ui_enabled: false, - comment: None, - }; + let user = create_api_proxy_user(app_state); if pli.item_type.is_local() { - return local_stream_response(fingerprint, - app_state, - pli.to_stream_channel(target.id), - req_headers, - &input, - &target, - &user, - UserConnectionPermission::Allowed, + return local_stream_response( + fingerprint, + app_state, + pli.to_stream_channel(target.id), + req_headers, + &input, + &target, + &user, + UserConnectionPermission::Allowed, + true, ).await.into_response(); } - let session_key = create_session_fingerprint(&fingerprint.key, "webui", virtual_id); + 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); @@ -532,7 +517,7 @@ async fn xtream_player_api_stream_with_token( &input, stream_req.context, &query_path, - pli.url.as_str() + &pli.url ), true, format!( @@ -802,7 +787,7 @@ async fn xtream_player_api_timeshift_query_stream( .into_response() } -fn empty_json_response() -> axum::http::Result { +fn empty_json_response_as_object() -> axum::http::Result { axum::response::Response::builder() .status(axum::http::StatusCode::OK) .header( @@ -812,7 +797,18 @@ fn empty_json_response() -> axum::http::Result { .body(axum::body::Body::from("{}".as_bytes())) } -async fn xtream_get_stream_info_response( +fn empty_json_response_as_array() -> 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())) +} + + +pub async fn xtream_get_stream_info_response( app_state: &Arc, user: &ProxyUserCredentials, target: &Arc, @@ -821,7 +817,7 @@ async fn xtream_get_stream_info_response( ) -> impl IntoResponse + Send { let virtual_id: u32 = match FromStr::from_str(stream_id) { Ok(id) => id, - Err(_) => return axum::http::StatusCode::BAD_REQUEST.into_response(), + Err(_) => return try_unwrap_body!(empty_json_response_as_array()), }; if let Ok(pli) = xtream_repository::xtream_get_item_for_stream_id( @@ -832,7 +828,7 @@ async fn xtream_get_stream_info_response( ).await { if pli.item_type.is_local() { let Ok(xtream_output) = target.get_xtream_output().ok_or_else(|| info_err!("Unexpected: xtream output required for target {}", target.name)) else { - return try_unwrap_body!(empty_json_response()); + return try_unwrap_body!(empty_json_response_as_array()); }; let server_info = app_state.app_config.get_user_server_info(user); @@ -883,17 +879,11 @@ async fn xtream_get_stream_info_response( .body(axum::body::Body::from(content))) } XtreamCluster::Live | XtreamCluster::Series => { - 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_as_array()) } }; } - try_unwrap_body!(empty_json_response()) + try_unwrap_body!(empty_json_response_as_object()) } async fn xtream_get_short_epg( @@ -907,7 +897,7 @@ async fn xtream_get_short_epg( if target.has_output(TargetType::Xtream) { let virtual_id: u32 = match FromStr::from_str(stream_id.trim()) { Ok(id) => id, - Err(_) => return axum::http::StatusCode::BAD_REQUEST.into_response(), + Err(_) => return get_empty_epg_response().into_response(), }; if let Ok(pli) = xtream_repository::xtream_get_item_for_stream_id( @@ -920,7 +910,7 @@ async fn xtream_get_short_epg( if let Some(epg_path) = get_epg_path_for_target(config, target) { if let Ok(exists) = tokio::fs::try_exists(&epg_path).await { if exists { - return serve_epg(app_state, &epg_path, user, target, pli.epg_channel_id.clone()).await; + return serve_epg(app_state, &epg_path, user, target, pli.epg_channel_id).await; } } } @@ -954,7 +944,7 @@ async fn xtream_get_short_epg( &input_source, None, None, - false + false, ) .await { @@ -1015,7 +1005,7 @@ async fn xtream_player_api_handle_content_action( categories.retain(|c| fltr.contains(&c.category_id)); } return Some(axum::Json(categories).into_response()); - }, + } Err(err) => error!("Failed to parse json file {}: {err}", file_path.display()), } } @@ -1068,8 +1058,9 @@ async fn xtream_get_catchup_response( let config = &app_state.app_config.config.load(); let target_path = try_option_bad_request!(get_target_storage_path(config, target.name.as_str())); - let (mut target_id_mapping, file_lock) = - get_target_id_mapping(&app_state.app_config, &target_path).await; + let Ok((mut target_id_mapping, file_lock)) = get_target_id_mapping(&app_state.app_config, &target_path, target.use_memory_cache).await else { + return internal_server_error!() + }; let mut in_memory_updates = Vec::new(); for epg_list_item in epg_listings.iter_mut().filter_map(Value::as_object_mut) { // TODO epg_id @@ -1292,7 +1283,7 @@ async fn xtream_player_api( ) .await ), - _ => Some(info_err_res!("Can't find content: {action} for target: {}", &target.name)), + _ => Some(info_err_res!("Unknown api call: {action} for target: {}", &target.name)), }; match result { diff --git a/backend/src/api/hdhomerun_proprietary.rs b/backend/src/api/hdhomerun_proprietary.rs index f6c39fa4b..dea641365 100644 --- a/backend/src/api/hdhomerun_proprietary.rs +++ b/backend/src/api/hdhomerun_proprietary.rs @@ -10,6 +10,7 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, UdpSocket}; use tokio_util::sync::CancellationToken; +use shared::utils::Internable; const HDHR_PROPRIETARY_PORT: u16 = 65001; @@ -370,7 +371,7 @@ async fn process_getset_request(request: &[u8], app_state: &Arc) -> Ve let vchannel = if let Some(stream_info) = active_streams.get(tuner_index) { stream_info.channel.title.clone() } else { - "none".to_string() + "none".intern() }; write_tlv_str( &mut response_payload, diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index 522abf46a..573b9547a 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -12,7 +12,7 @@ use crate::api::endpoints::xtream_api::xtream_api_register; use crate::api::hdhomerun_proprietary::spawn_proprietary_tasks; use crate::api::hdhomerun_ssdp::spawn_ssdp_discover_task; use crate::api::model::{create_cache, create_http_client, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadQueue, EventManager, HdHomerunAppState, PlaylistStorageState, SharedStreamManager, UpdateGuard}; -use crate::api::scheduler::exec_scheduler; +use crate::api::scheduler::{exec_interner_prune, exec_scheduler}; use crate::api::serve::serve; use crate::model::{AppConfig, Config, Healthcheck, ProcessTargets, RateLimitConfig}; use crate::processing::processor::playlist; @@ -296,6 +296,8 @@ pub async fn start_server( exec_file_lock_prune(&app_state); + exec_interner_prune(&app_state); + exec_config_watch(&app_state, &cancel_token_file_watch); let web_auth_enabled = is_web_auth_enabled(&cfg, web_ui_enabled); @@ -310,10 +312,7 @@ pub async fn start_server( .and_then(|c| c.path.as_ref()) .cloned() .unwrap_or_default(); - infos.push(format!( - "Server running: http://{}:{}", - &cfg.api.host, &cfg.api.port - )); + infos.push(format!("Server running: http://{}:{}", &cfg.api.host, &cfg.api.port)); for info in &infos { info!("{info}"); } diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index 64a485899..0c63a510f 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -85,7 +85,7 @@ impl ActiveProviderManager { } pub async fn reconcile_connections(&self) { - let mut counts = HashMap::::new(); + let mut counts = HashMap::, usize>::new(); { let connections = self.connections.read().await; @@ -111,7 +111,7 @@ impl ActiveProviderManager { async fn acquire_connection_inner( &self, - provider_or_input_name: &str, + provider_or_input_name: &Arc, addr: &SocketAddr, force: bool, allow_grace_override: Option, @@ -152,35 +152,35 @@ impl ActiveProviderManager { None } - pub async fn force_exact_acquire_connection(&self, provider_name: &str, addr: &SocketAddr) -> Option { + pub async fn force_exact_acquire_connection(&self, provider_name: &Arc, addr: &SocketAddr) -> Option { self.acquire_connection_inner(provider_name, addr, true, None).await } // Returns the next available provider connection - pub async fn acquire_connection(&self, input_name: &str, addr: &SocketAddr) -> Option { + pub async fn acquire_connection(&self, input_name: &Arc, addr: &SocketAddr) -> Option { self.acquire_connection_inner(input_name, addr, false, None).await } /// Acquire a provider connection while optionally disabling provider grace allocations. pub async fn acquire_connection_with_grace_override( &self, - input_name: &str, + input_name: &Arc, addr: &SocketAddr, allow_grace: bool, ) -> Option { self.acquire_connection_inner(input_name, addr, false, Some(allow_grace)).await } - // This method is used for redirects to cycle through provider - pub async fn get_next_provider(&self, provider_name: &str) -> Option> { + // This method is used for redirects to cycle through the provider + pub async fn get_next_provider(&self, provider_name: &Arc) -> Option> { self.providers.get_next_provider(provider_name).await } - pub async fn active_connections(&self) -> Option> { + pub async fn active_connections(&self) -> Option, usize>> { self.providers.active_connections().await } - pub async fn is_over_limit(&self, provider_name: &str) -> bool { + pub async fn is_over_limit(&self, provider_name: &Arc) -> bool { self.providers.is_over_limit(provider_name).await } diff --git a/backend/src/api/model/active_user_manager.rs b/backend/src/api/model/active_user_manager.rs index 3088f1360..c04cc2e8a 100644 --- a/backend/src/api/model/active_user_manager.rs +++ b/backend/src/api/model/active_user_manager.rs @@ -7,7 +7,8 @@ use arc_swap::ArcSwapOption; use jsonwebtoken::get_current_timestamp; use log::{debug, info}; use shared::model::{ActiveUserConnectionChange, StreamChannel, StreamInfo, UserConnectionPermission, VirtualId}; -use shared::utils::{current_time_secs, default_grace_period_millis, default_grace_period_timeout_secs, intern, sanitize_sensitive_info, strip_port}; +use shared::utils::{current_time_secs, default_grace_period_millis, default_grace_period_timeout_secs, + sanitize_sensitive_info, strip_port, Internable}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; @@ -31,8 +32,8 @@ fn get_grace_options(config: &Config) -> (u64, u64) { pub struct UserSession { pub token: String, pub virtual_id: u32, - pub provider: String, - pub stream_url: String, + pub provider: Arc, + pub stream_url: Arc, pub addr: SocketAddr, pub ts: u64, pub permission: UserConnectionPermission, @@ -250,8 +251,8 @@ impl ActiveUserManager { for stream in &mut connection_data.streams { if &stream.addr == addr { stream.provider = String::from("tuliprox"); - stream.channel.title = video_type.to_string(); - stream.channel.group = intern(""); + stream.channel.title = video_type.to_string().into(); + stream.channel.group = "".intern(); return Some(stream.clone()); } } @@ -351,8 +352,8 @@ impl ActiveUserManager { UserSession { token: session_token.to_string(), virtual_id, - provider: provider.to_string(), - stream_url: stream_url.to_string(), + provider: provider.intern(), + stream_url: stream_url.intern(), addr: *addr, ts: current_time_secs(), permission: connection_permission, @@ -379,11 +380,11 @@ impl ActiveUserManager { for session in &mut connection_data.sessions { if session.token == session_token { session.ts = current_time_secs(); - if session.stream_url != stream_url { - session.stream_url = stream_url.to_string(); + if &*session.stream_url != stream_url { + session.stream_url = stream_url.intern(); } - if session.provider != provider { - session.provider = provider.to_string(); + if &*session.provider != provider { + session.provider = provider.intern(); } session.permission = connection_permission; debug_if_enabled!("Using session for user {} with url: {}", user.username, sanitize_sensitive_info(stream_url)); diff --git a/backend/src/api/model/event_manager.rs b/backend/src/api/model/event_manager.rs index 32cc45881..1bfa992b4 100644 --- a/backend/src/api/model/event_manager.rs +++ b/backend/src/api/model/event_manager.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use log::{trace}; use shared::model::{ActiveUserConnectionChange, ConfigType, LibraryScanSummary, PlaylistUpdateState, SystemInfo}; @@ -6,7 +7,7 @@ use shared::model::{ActiveUserConnectionChange, ConfigType, LibraryScanSummary, pub enum EventMessage { ServerError(String), ActiveUser(ActiveUserConnectionChange), // user_count, connection count - ActiveProvider(String, usize), // provider name, connections + ActiveProvider(Arc, usize), // provider name, connections ConfigChange(ConfigType), PlaylistUpdate(PlaylistUpdateState), PlaylistUpdateProgress(String, String), @@ -41,8 +42,8 @@ impl EventManager { } } - pub fn send_provider_event(&self, provider: &str, connection_count: usize) { - if !self.send_event(EventMessage::ActiveProvider(String::from(provider), connection_count)) { + pub fn send_provider_event(&self, provider: &Arc, connection_count: usize) { + if !self.send_event(EventMessage::ActiveProvider(Arc::clone(provider), connection_count)) { trace!("Failed to send connection change: {provider}: {connection_count}"); } } diff --git a/backend/src/api/model/provider_config.rs b/backend/src/api/model/provider_config.rs index 47f8ba4aa..23c268315 100644 --- a/backend/src/api/model/provider_config.rs +++ b/backend/src/api/model/provider_config.rs @@ -12,7 +12,7 @@ use crate::api::model::ProviderAllocation; use crate::utils::debug_if_enabled; -pub type ProviderConnectionChangeCallback = Arc; +pub type ProviderConnectionChangeCallback = Arc, usize) + Send + Sync>; #[derive(Debug, Clone, Copy)] pub enum ProviderConfigAllocation { @@ -37,7 +37,7 @@ pub struct ProviderConfigConnection { /// `current_connections`: A `RwLock` to safely track the number of active connections. pub struct ProviderConfig { pub id: u16, - pub name: String, + pub name: Arc, pub url: String, pub username: Option, pub password: Option, diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index dbd82e70e..226322413 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -18,7 +18,7 @@ macro_rules! gen_provider_search { for lineup in providers { match lineup { ProviderLineup::Single(single) => { - if single.provider.$field == criteria { + if &single.provider.$field == criteria { return Some((lineup, &single.provider)); } } @@ -26,13 +26,13 @@ macro_rules! gen_provider_search { for group in &multi.providers { match group { ProviderPriorityGroup::SingleProviderGroup(single) => { - if single.$field == criteria { + if &single.$field == criteria { return Some((lineup, single)); } } ProviderPriorityGroup::MultiProviderGroup(_, configs) => { for config in configs { - if config.$field == criteria { + if &config.$field == criteria { return Some((lineup, config)); } } @@ -48,11 +48,11 @@ macro_rules! gen_provider_search { } fn get_or_create_provider_connection( - provider_connections: &DashMap>>, - provider_name: &str, + provider_connections: &DashMap, Arc>>, + provider_name: &Arc, ) -> Arc> { provider_connections - .entry(provider_name.to_string()) + .entry(provider_name.clone()) .or_insert_with(|| Arc::new(RwLock::new(ProviderConfigConnection::default()))) .clone() } @@ -81,7 +81,7 @@ impl ProviderAllocation { ProviderAllocation::GracePeriod(config) } - pub fn get_provider_name(&self) -> Option { + pub fn get_provider_name(&self) -> Option> { match self { ProviderAllocation::Exhausted => None, ProviderAllocation::Available(ref cfg) | @@ -200,8 +200,8 @@ impl SingleProviderLineup { } #[cfg(test)] - async fn release(&self, provider_name: &str) { - if self.provider.name == provider_name { + async fn release(&self, provider_name: &Arc) { + if &self.provider.name == provider_name { self.provider.release().await; } } @@ -235,21 +235,21 @@ impl fmt::Display for ProviderPriorityGroup { #[repr(align(64))] #[derive(Debug)] struct MultiProviderLineup { - name: String, + name: Arc, providers: Vec, } impl MultiProviderLineup { pub fn new( cfg_input: &ConfigInput, - provider_connections: &DashMap>>, + provider_connections: &DashMap, Arc>>, connection_change: &ProviderConnectionChangeCallback, ) -> Self { - let input_connection = get_or_create_provider_connection(provider_connections, cfg_input.name.as_str()); + let input_connection = get_or_create_provider_connection(provider_connections, &cfg_input.name); let mut inputs = vec![ProviderConfigWrapper::new(ProviderConfig::new(cfg_input, input_connection, Arc::clone(connection_change)))]; if let Some(aliases) = &cfg_input.aliases { for alias in aliases { - let alias_connection = get_or_create_provider_connection(provider_connections, alias.name.as_str()); + let alias_connection = get_or_create_provider_connection(provider_connections, &alias.name); inputs.push(ProviderConfigWrapper::new(ProviderConfig::new_alias( cfg_input, alias, @@ -464,18 +464,18 @@ impl MultiProviderLineup { #[cfg(test)] - async fn release(&self, provider_name: &str) { + async fn release(&self, provider_name: &Arc) { for g in &self.providers { match g { ProviderPriorityGroup::SingleProviderGroup(pc) => { - if pc.name == provider_name { + if &pc.name == provider_name { pc.release().await; break; } } ProviderPriorityGroup::MultiProviderGroup(_, group) => { for pc in group { - if pc.name == provider_name { + if &pc.name == provider_name { pc.release().await; return; } @@ -509,13 +509,13 @@ pub(in crate::api::model) struct ProviderLineupManager { grace_period_timeout_secs: AtomicU64, inputs: Arc>>>, providers: Arc>>, - provider_connections: DashMap>>, + provider_connections: DashMap, Arc>>, event_manager: Arc, } impl ProviderLineupManager { pub fn new(inputs: Vec>, grace_period_millis: u64, grace_period_timeout_secs: u64, event_manager: &Arc) -> Self { - let provider_connections: DashMap>> = DashMap::new(); + let provider_connections: DashMap, Arc>> = DashMap::new(); let lineups = inputs .iter() .map(|i| Self::create_lineup(i, &provider_connections, event_manager)) @@ -532,18 +532,18 @@ impl ProviderLineupManager { fn create_lineup( cfg_input: &ConfigInput, - provider_connections: &DashMap>>, + provider_connections: &DashMap, Arc>>, event_manager: &Arc, ) -> ProviderLineup { let event_manager = Arc::clone(event_manager); - let on_connection_change: ProviderConnectionChangeCallback = Arc::new(move |name: &str, connections: usize| { + let on_connection_change: ProviderConnectionChangeCallback = Arc::new(move |name: &Arc, connections: usize| { event_manager.send_provider_event(name, connections); }); if cfg_input.aliases.as_ref().is_some_and(|a| !a.is_empty()) { ProviderLineup::Multi(MultiProviderLineup::new(cfg_input, provider_connections, &on_connection_change)) } else { - let connection = get_or_create_provider_connection(provider_connections, cfg_input.name.as_str()); + let connection = get_or_create_provider_connection(provider_connections, &cfg_input.name); ProviderLineup::Single(SingleProviderLineup::new(cfg_input, connection, &on_connection_change)) } } @@ -628,7 +628,7 @@ impl ProviderLineupManager { self.providers.store(Arc::new(new_lineups)); } - pub async fn reconcile_connections(&self, mut counts: HashMap) { + pub async fn reconcile_connections(&self, mut counts: HashMap, usize>) { // 1. Synchronize known providers from actual counts. // This avoids a transient "zero state" by updating each provider in one step. for entry in &self.provider_connections { @@ -669,7 +669,7 @@ impl ProviderLineupManager { // nor referenced by any active stream allocations. // // deterministic check: Is the name still in any current lineup? - let current_names: std::collections::HashSet = { + let current_names: std::collections::HashSet> = { let mut names = std::collections::HashSet::new(); let lineups = self.providers.load(); for lineup in lineups.iter() { @@ -704,7 +704,7 @@ impl ProviderLineupManager { }); } - gen_provider_search!(get_provider_config_by_name, name, &str); + gen_provider_search!(get_provider_config_by_name, name, &Arc); fn log_allocation(allocation: &ProviderAllocation) { @@ -723,7 +723,7 @@ impl ProviderLineupManager { } } - pub async fn force_exact_acquire_connection(&self, provider_name: &str) -> ProviderAllocation { + pub async fn force_exact_acquire_connection(&self, provider_name: &Arc) -> ProviderAllocation { let providers = self.providers.load(); let allocation = match Self::get_provider_config_by_name(provider_name, &providers) { None => ProviderAllocation::Exhausted, // No Name matched, we don't have this provider @@ -734,7 +734,7 @@ impl ProviderLineupManager { } // Returns the next available provider connection - pub(crate) async fn acquire_connection(&self, input_name: &str) -> ProviderAllocation { + pub(crate) async fn acquire_connection(&self, input_name: &Arc) -> ProviderAllocation { self.acquire_connection_with_grace_override(input_name, true).await } @@ -744,7 +744,7 @@ impl ProviderLineupManager { /// even if a global grace period is configured. pub(crate) async fn acquire_connection_with_grace_override( &self, - input_name: &str, + input_name: &Arc, allow_grace: bool, ) -> ProviderAllocation { let providers = self.providers.load(); @@ -809,7 +809,7 @@ impl ProviderLineupManager { // This method is used for redirects to cycle through provider // - pub async fn get_next_provider(&self, input_name: &str) -> Option> { + pub async fn get_next_provider(&self, input_name: &Arc) -> Option> { let providers = self.providers.load(); match Self::get_provider_config_by_name(input_name, &providers) { None => None, @@ -825,11 +825,11 @@ impl ProviderLineupManager { } } - pub async fn active_connections(&self) -> Option> { - let mut result = HashMap::::new(); - let mut add_provider = |name: String, count: usize| { + pub async fn active_connections(&self) -> Option, usize>> { + let mut result = HashMap::, usize>::new(); + let mut add_provider = |name: &Arc, count: usize| { if count > 0 { - result.insert(name, count); + result.insert(name.clone(), count); } }; let providers = self.providers.load(); @@ -837,11 +837,11 @@ impl ProviderLineupManager { match lineup { ProviderLineup::Single(provider_lineup) => { let connections = provider_lineup.provider.get_current_connections().await; - add_provider(provider_lineup.provider.name.clone(), connections); + add_provider(&provider_lineup.provider.name, connections); } ProviderLineup::Multi(provider_lineup) => { let connections = provider_lineup.get_total_connections().await; - add_provider(provider_lineup.name.clone(), connections); + add_provider(&provider_lineup.name, connections); } } } @@ -868,7 +868,7 @@ impl ProviderLineupManager { count } - pub async fn is_over_limit(&self, provider_name: &str) -> bool { + pub async fn is_over_limit(&self, provider_name: &Arc) -> bool { let providers = self.providers.load(); if let Some((_, config)) = Self::get_provider_config_by_name(provider_name, &providers) { config.is_over_limit(self.grace_period_timeout_secs.load(Ordering::Relaxed)).await @@ -887,6 +887,8 @@ mod tests { use shared::model::{InputFetchMethod, InputType}; use std::sync::atomic::AtomicU16; use std::thread; + use shared::concat_string; + use shared::utils::Internable; macro_rules! should_available { ($lineup:expr, $provider_id:expr, $grace_period_timeout_secs: expr) => { @@ -921,10 +923,10 @@ mod tests { } // Helper function to create a ConfigInput instance - fn create_config_input(id: u16, name: &str, priority: i16, max_connections: u16) -> ConfigInput { + fn create_config_input(id: u16, name: &Arc, priority: i16, max_connections: u16) -> ConfigInput { ConfigInput { id, - name: name.to_string(), + name: Arc::clone(name), url: "http://example.com".to_string(), epg: Option::default(), username: None, @@ -950,7 +952,7 @@ mod tests { fn create_config_input_alias(id: u16, url: &str, priority: i16, max_connections: u16) -> ConfigInputAlias { ConfigInputAlias { id, - name: format!("alias_{id}"), + name: concat_string!("alias_", &id.to_string()).intern(), url: url.to_string(), username: Some("alias_user".to_string()), password: Some("alias_pass".to_string()), @@ -960,20 +962,20 @@ mod tests { } } - fn dummy_callback(_: &str, _: usize) {} + fn dummy_callback(_: &Arc, _: usize) {} // Test acquiring with an alias #[test] fn test_provider_with_alias() { - let mut input = create_config_input(1, "provider1_1", 1, 1); + let mut input = create_config_input(1, &"provider1_1".intern(), 1, 1); let alias = create_config_input_alias(2, "http://alias1", 2, 2); // Adding alias to the provider input.aliases = Some(vec![alias]); let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); + let provider_connections: DashMap, Arc>> = DashMap::new(); // Create MultiProviderLineup with the provider and alias let lineup = MultiProviderLineup::new(&input, &provider_connections, &change_callback); let rt = tokio::runtime::Runtime::new().unwrap(); @@ -993,12 +995,12 @@ mod tests { // Test acquiring from a MultiProviderLineup where the alias has a different priority #[test] fn test_provider_with_priority_alias() { - let mut input = create_config_input(1, "provider2_1", 1, 2); + let mut input = create_config_input(1, &"provider2_1".intern(), 1, 2); let alias = create_config_input_alias(2, "http://alias.com", 0, 2); // Adding alias with different priority input.aliases = Some(vec![alias]); let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); + let provider_connections: DashMap, Arc>> = DashMap::new(); let lineup = MultiProviderLineup::new(&input, &provider_connections, &change_callback); // The alias has a higher priority, so the alias should be acquired first let rt = tokio::runtime::Runtime::new().unwrap(); @@ -1013,14 +1015,14 @@ mod tests { // Test provider when there are multiple aliases, all with distinct priorities #[test] fn test_provider_with_multiple_aliases() { - let mut input = create_config_input(1, "provider3_1", 1, 1); + let mut input = create_config_input(1, &"provider3_1".intern(), 1, 1); let alias1 = create_config_input_alias(2, "http://alias1.com", 1, 2); let alias2 = create_config_input_alias(3, "http://alias2.com", 0, 1); // Adding multiple aliases input.aliases = Some(vec![alias1, alias2]); let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); + let provider_connections: DashMap, Arc>> = DashMap::new(); let lineup = MultiProviderLineup::new(&input, &provider_connections, &change_callback); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { @@ -1044,14 +1046,14 @@ mod tests { // Test acquiring when all aliases are exhausted #[test] fn test_provider_with_exhausted_aliases() { - let mut input = create_config_input(1, "provider4_1", 1, 1); + let mut input = create_config_input(1, &"provider4_1".intern(), 1, 1); let alias1 = create_config_input_alias(2, "http://alias.com", 2, 1); let alias2 = create_config_input_alias(3, "http://alias.com", -2, 1); // Adding alias input.aliases = Some(vec![alias1, alias2]); let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); + let provider_connections: DashMap, Arc>> = DashMap::new(); let lineup = MultiProviderLineup::new(&input, &provider_connections, &change_callback); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { @@ -1077,10 +1079,10 @@ mod tests { // Test acquiring a connection when there is available capacity #[test] fn test_acquire_when_capacity_available() { - let cfg = create_config_input(1, "provider5_1", 1, 2); + let cfg = create_config_input(1, &"provider5_1".intern(), 1, 2); let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); - let connection = get_or_create_provider_connection(&provider_connections, cfg.name.as_str()); + let provider_connections: DashMap, Arc>> = DashMap::new(); + let connection = get_or_create_provider_connection(&provider_connections, &cfg.name); let lineup = SingleProviderLineup::new(&cfg, connection, &change_callback); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { @@ -1099,10 +1101,10 @@ mod tests { // Test releasing a connection #[test] fn test_release_connection() { - let cfg = create_config_input(1, "provider7_1", 1, 2); + let cfg = create_config_input(1, &"provider7_1".intern(), 1, 2); let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); - let connection = get_or_create_provider_connection(&provider_connections, cfg.name.as_str()); + let provider_connections: DashMap, Arc>> = DashMap::new(); + let connection = get_or_create_provider_connection(&provider_connections, &cfg.name); let lineup = SingleProviderLineup::new(&cfg, connection, &change_callback); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { @@ -1111,10 +1113,10 @@ mod tests { should_available!(lineup, 1, 5); should_grace_period!(lineup, 1, 5); should_exhausted!(lineup, 5); - lineup.release("provider7_1").await; + lineup.release(&"provider7_1".intern()).await; should_grace_period!(lineup, 1, 5); - lineup.release("provider7_1").await; - lineup.release("provider7_1").await; + lineup.release(&"provider7_1".intern()).await; + lineup.release(&"provider7_1".intern()).await; should_available!(lineup, 1, 5); should_grace_period!(lineup, 1, 5); should_exhausted!(lineup, 5); @@ -1124,7 +1126,7 @@ mod tests { // Test acquiring with MultiProviderLineup and round-robin allocation #[test] fn test_multi_provider_acquire() { - let mut cfg1 = create_config_input(1, "provider8_1", 1, 2); + let mut cfg1 = create_config_input(1, &"provider8_1".intern(), 1, 2); let alias = create_config_input_alias(2, "http://alias1", 1, 1); // Adding alias to the provider @@ -1132,7 +1134,7 @@ mod tests { // Create MultiProviderLineup with the provider and alias let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); + let provider_connections: DashMap, Arc>> = DashMap::new(); let lineup = MultiProviderLineup::new(&cfg1, &provider_connections, &change_callback); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { @@ -1148,9 +1150,9 @@ mod tests { should_grace_period!(lineup, 1, 5); should_grace_period!(lineup, 2, 5); - lineup.release("provider8_1").await; - lineup.release("alias_2").await; - lineup.release("provider8_1").await; + lineup.release(&"provider8_1".intern()).await; + lineup.release(&"alias_2".intern()).await; + lineup.release(&"provider8_1".intern()).await; should_available!(lineup, 1, 5); should_grace_period!(lineup, 1, 5); @@ -1163,10 +1165,10 @@ mod tests { // Test concurrent access to `acquire` using multiple threads #[test] fn test_concurrent_acquire() { - let cfg = create_config_input(1, "provider9_1", 1, 2); + let cfg = create_config_input(1, &"provider9_1".intern(), 1, 2); let change_callback: ProviderConnectionChangeCallback = Arc::new(dummy_callback); - let provider_connections: DashMap>> = DashMap::new(); - let connection = get_or_create_provider_connection(&provider_connections, cfg.name.as_str()); + let provider_connections: DashMap, Arc>> = DashMap::new(); + let connection = get_or_create_provider_connection(&provider_connections, &cfg.name); let lineup = Arc::new(SingleProviderLineup::new(&cfg, connection, &change_callback)); let available_count = Arc::new(AtomicU16::new(2)); diff --git a/backend/src/api/model/stream.rs b/backend/src/api/model/stream.rs index d16c1080a..e452b2f89 100644 --- a/backend/src/api/model/stream.rs +++ b/backend/src/api/model/stream.rs @@ -16,8 +16,8 @@ pub type ProviderStreamResponse = (Option, ProviderStreamIn pub type ProviderStreamFactoryResponse = (BoxedProviderStream, ProviderStreamInfo); -type StreamUrl = String; -type ProviderName = String; +type StreamUrl = Arc; +type ProviderName = Arc; pub enum ProviderStreamState { Custom(ProviderStreamResponse), @@ -28,7 +28,7 @@ pub enum ProviderStreamState { pub struct StreamDetails { pub stream: Option, pub(crate) stream_info: ProviderStreamInfo, - pub provider_name: Option, + pub provider_name: Option>, pub grace_period_millis: u64, pub reconnect_flag: Option>, pub provider_handle: Option, diff --git a/backend/src/api/model/streams/active_client_stream.rs b/backend/src/api/model/streams/active_client_stream.rs index a7fac73a7..d8079b415 100644 --- a/backend/src/api/model/streams/active_client_stream.rs +++ b/backend/src/api/model/streams/active_client_stream.rs @@ -125,8 +125,7 @@ impl ActiveClientStream { let connection_manager = Arc::clone(&app_state.connection_manager); let provider_grace_check = if stream_details.has_grace_period() && stream_details.provider_name.is_some() { - let provider_name = stream_details.provider_name.as_ref().map_or_else(String::new, ToString::to_string); - Some(provider_name) + stream_details.provider_name.clone() } else { None }; diff --git a/backend/src/api/model/streams/shared_stream_manager.rs b/backend/src/api/model/streams/shared_stream_manager.rs index 6ed204b04..595dd6484 100644 --- a/backend/src/api/model/streams/shared_stream_manager.rs +++ b/backend/src/api/model/streams/shared_stream_manager.rs @@ -157,7 +157,7 @@ impl SharedStreamState { } } - async fn subscribe(&self, addr: &SocketAddr, manager: Arc) -> (BoxedProviderStream, Option) { + async fn subscribe(&self, addr: &SocketAddr, manager: Arc) -> (BoxedProviderStream, Option>) { let (client_tx, client_rx) = mpsc::channel(self.buf_size); let mut broadcast_rx = self.broadcaster.subscribe(); let cancel_token = CancellationToken::new(); @@ -428,7 +428,7 @@ impl SharedStreamManager { stream_url: &str, addr: &SocketAddr, manager: Arc, - ) -> Option<(BoxedProviderStream, Option)> { + ) -> Option<(BoxedProviderStream, Option>)> { let shared_state_opt = { let mut shared_streams = self.shared_streams.write().await; if let Some(shared_state) = shared_streams.by_key.get(stream_url).cloned() { @@ -464,7 +464,7 @@ impl SharedStreamManager { addr: &SocketAddr, headers: Vec<(String, String)>, buffer_size: usize, - provider_handle: Option) -> Option<(BoxedProviderStream, Option)> + provider_handle: Option) -> Option<(BoxedProviderStream, Option>)> where S: Stream> + Unpin + 'static + Send, E: std::fmt::Debug + Send, @@ -487,7 +487,7 @@ impl SharedStreamManager { app_state: &AppState, stream_url: &str, addr: &SocketAddr, - ) -> Option<(BoxedProviderStream, Option)> { + ) -> Option<(BoxedProviderStream, Option>)> { let manager = Arc::clone(&app_state.shared_stream_manager); if let Some(result) = app_state.shared_stream_manager.subscribe_stream(stream_url, addr, manager).await { app_state.active_provider.add_shared_connection(addr, stream_url).await; diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index 1e948ba60..88401697c 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use crate::api::config_file::ConfigFile; use crate::api::model::AppState; use crate::model::{is_input_expired, ConfigInput, PanelApiConfig, PanelApiQueryParam}; @@ -7,14 +8,14 @@ use log::{debug, error, warn}; use serde_json::Value; use shared::error::{info_err_res, info_err, TuliproxError}; use shared::model::{ConfigInputAliasDto, InputType}; -use shared::utils::{get_credentials_from_url, parse_timestamp, sanitize_sensitive_info, trim_last_slash}; +use shared::utils::{get_credentials_from_url, parse_timestamp, sanitize_sensitive_info, trim_last_slash, Internable}; use std::path::{Path, PathBuf}; use std::sync::Arc; use url::Url; #[derive(Debug, Clone)] struct AccountCredentials { - name: String, + name: Arc, username: String, password: String, exp_date: Option, @@ -293,8 +294,8 @@ fn collect_expired_accounts(input: &ConfigInput) -> Vec { async fn patch_source_yml_add_alias( app_state: &Arc, source_file_path: &Path, - input_name: &str, - alias_name: &str, + input_name: &Arc, + alias_name: &Arc, base_url: &str, username: &str, password: &str, @@ -305,13 +306,13 @@ async fn patch_source_yml_add_alias( Err(e) => return info_err_res!("panel_api: failed to read source file: {e}"), }; - let Some(input) = sources.inputs.iter_mut().find(|i| i.name == input_name) else { + let Some(input) = sources.inputs.iter_mut().find(|i| &i.name == input_name) else { return info_err_res!("panel_api: could not find input '{input_name}' in source.yml"); }; let alias = ConfigInputAliasDto { id: 0, - name: alias_name.to_string(), + name: Arc::clone(alias_name), url: base_url.to_string(), username: Some(username.to_string()), password: Some(password.to_string()), @@ -329,7 +330,7 @@ async fn patch_source_yml_add_alias( async fn patch_source_yml_update_exp_date( app_state: &Arc, source_file_path: &Path, - input_name: &str, + input_name: &Arc, account_name: &str, exp_date: i64, ) -> Result<(), TuliproxError> { @@ -338,7 +339,7 @@ async fn patch_source_yml_update_exp_date( Err(e) => return info_err_res!("panel_api: failed to read source file: {e}"), }; - let Some(input) = sources.inputs.iter_mut().find(|i| i.name == input_name) else { + let Some(input) = sources.inputs.iter_mut().find(|i| &i.name == input_name) else { return info_err_res!("panel_api: could not find input '{input_name}' in source.yml"); }; @@ -471,14 +472,14 @@ async fn patch_batch_csv_update_exp_date( const MAX_ALIAS_NAME_ATTEMPTS: usize = 1000; -fn derive_unique_alias_name(existing: &[String], input_name: &str, username: &str) -> String { +fn derive_unique_alias_name(existing: &HashSet<&str>, input_name: &Arc, username: &str) -> String { let base = format!("{input_name}-{username}"); - if !existing.contains(&base) { + if !existing.contains(base.as_str()) { return base; } for i in 2..MAX_ALIAS_NAME_ATTEMPTS { let cand = format!("{base}-{i}"); - if !existing.contains(&cand) { + if !existing.contains(cand.as_str()) { return cand; } } @@ -548,11 +549,12 @@ async fn try_create_new_account( let base_url = base_url_from_resp.unwrap_or_else(|| input.url.clone()); let base_url = extract_base_url(base_url.as_str()).unwrap_or_else(|| base_url.clone()); - let mut existing_names: Vec = vec![input.name.clone()]; + let mut existing_names: HashSet<&str> = HashSet::new(); + existing_names.insert(&*input.name); if let Some(aliases) = input.aliases.as_ref() { - existing_names.extend(aliases.iter().map(|a| a.name.clone())); + existing_names.extend(aliases.iter().map(|a| &*a.name)); } - let alias_name = derive_unique_alias_name(&existing_names, &input.name, &username); + let alias_name = derive_unique_alias_name(&existing_names, &input.name, &username).intern(); let exp_date = panel_client_info(app_state, panel_cfg, &username, &password) .await diff --git a/backend/src/api/scheduler.rs b/backend/src/api/scheduler.rs index 011234214..716ebd065 100644 --- a/backend/src/api/scheduler.rs +++ b/backend/src/api/scheduler.rs @@ -4,11 +4,12 @@ use crate::processing::processor::playlist::exec_processing; use crate::utils::exit; use chrono::{DateTime, FixedOffset, Local}; use cron::Schedule; -use log::error; +use log::{error}; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; use tokio_util::sync::CancellationToken; +use shared::utils::interner_gc; pub fn datetime_to_instant(datetime: DateTime) -> Instant { // Convert DateTime to SystemTime @@ -105,6 +106,24 @@ fn get_process_targets(cfg: &Arc, process_targets: &Arc) { + let app_state = Arc::clone(app_state); + tokio::spawn({ + async move { + loop { + tokio::time::sleep(Duration::from_secs(180)).await; + if let Some(permit) = app_state.update_guard.try_playlist() { + // Gate check: ensure updates aren't in progress; permit dropped to allow concurrent updates during GC + drop(permit); + interner_gc(); + } + } + } + }); +} + #[cfg(test)] mod tests { use crate::api::scheduler::datetime_to_instant; diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index e1c4038c4..3c45213c9 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -128,7 +128,7 @@ mod tests { use crate::model::{LibraryMetadataConfig, LibraryMetadataReadConfig, LibraryPlaylistConfig, LibraryTmdbConfig}; use std::path::PathBuf; use std::time::Duration; - use shared::utils::intern; + use shared::utils::Internable; use crate::library::{MediaClassification, MediaClassifier}; fn create_test_config(tmdb_enabled: bool) -> LibraryConfig { @@ -158,8 +158,8 @@ mod tests { formats: vec![], }, playlist: LibraryPlaylistConfig { - movie_category: intern("Movies"), - series_category: intern("Series"), + movie_category: "Movies".intern(), + series_category: "Series".intern(), }, } } diff --git a/backend/src/library/scanner.rs b/backend/src/library/scanner.rs index 5bc88a62a..55e74440f 100644 --- a/backend/src/library/scanner.rs +++ b/backend/src/library/scanner.rs @@ -281,8 +281,8 @@ impl LibraryScanner { #[cfg(test)] mod tests { + use shared::utils::Internable; use super::*; - use shared::utils::intern; use crate::model::{LibraryMetadataConfig, LibraryMetadataReadConfig, LibraryPlaylistConfig, LibraryTmdbConfig}; fn create_test_config() -> LibraryConfig { @@ -312,8 +312,8 @@ mod tests { formats: vec![], }, playlist: LibraryPlaylistConfig { - movie_category: intern("Local Movies"), - series_category: intern("Local Series"), + movie_category: "Local Movies".intern(), + series_category: "Local Series".intern(), }, } } diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index 0fd4def99..23af47845 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -201,20 +201,20 @@ impl AppConfig { self.api_proxy.load().as_ref().as_ref().and_then(|api_proxy| api_proxy.get_user_credentials(username)) } - pub fn get_input_by_name(&self, input_name: &str) -> Option> { + pub fn get_input_by_name(&self, input_name: &Arc) -> Option> { let sources = > as Access>::load(&self.sources); for input in &sources.inputs { - if input.name == input_name { + if &input.name == input_name { return Some(Arc::clone(input)); } } None } - pub fn get_input_options_by_name(&self, input_name: &str) -> Option { + pub fn get_input_options_by_name(&self, input_name: &Arc) -> Option { let sources = > as Access>::load(&self.sources); for input in &sources.inputs { - if input.name == input_name { + if &input.name == input_name { return input.options.clone(); } } diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index daeee59b0..7d25ed9ff 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -4,12 +4,13 @@ use chrono::Utc; use log::warn; use shared::error::TuliproxError; use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto}; -use shared::utils::{get_credentials_from_url}; +use shared::utils::{get_credentials_from_url, Internable}; use shared::{check_input_connections, info_err_res, write_if_some}; use shared::check_input_credentials; use std::collections::HashMap; use std::fmt; use std::path::PathBuf; +use std::sync::Arc; use url::Url; use crate::model::config::panel_api::PanelApiConfig; @@ -71,7 +72,7 @@ impl InputUserInfo { #[derive(Debug, Clone, Default)] pub struct StagedInput { - pub name: String, + pub name: Arc, pub url: String, pub username: Option, pub password: Option, @@ -98,7 +99,7 @@ impl From<&StagedInputDto> for StagedInput { #[derive(Debug, Clone)] pub struct ConfigInputAlias { pub id: u16, - pub name: String, + pub name: Arc, pub url: String, pub username: Option, pub password: Option, @@ -126,7 +127,7 @@ impl From<&ConfigInputAliasDto> for ConfigInputAlias { #[derive(Debug, Clone, Default)] pub struct ConfigInput { pub id: u16, - pub name: String, + pub name: Arc, pub input_type: InputType, pub headers: HashMap, pub url: String, @@ -150,7 +151,7 @@ pub struct ConfigInput { impl ConfigInput { pub fn prepare(&mut self) -> Result, TuliproxError> { let batch_file_path = self.prepare_batch(); - self.name = self.name.trim().to_string(); + self.name = self.name.trim().intern(); check_input_credentials!(self, self.input_type, false, false); check_input_connections!(self, self.input_type, false); if let Some(staged_input) = &mut self.staged { diff --git a/backend/src/model/config/library.rs b/backend/src/model/config/library.rs index 1c6b5ce6e..48300bf20 100644 --- a/backend/src/model/config/library.rs +++ b/backend/src/model/config/library.rs @@ -3,7 +3,7 @@ use shared::error::{TuliproxError, info_err_res}; use shared::model::{LibraryConfigDto, LibraryContentType, LibraryMetadataFormat}; use std::path::PathBuf; use std::sync::Arc; -use shared::utils::{default_metadata_path, intern}; +use shared::utils::{default_metadata_path, Internable}; #[derive(Debug, Clone, Default)] pub struct LibraryScanDirectory { @@ -121,8 +121,8 @@ impl From<&LibraryConfigDto> for LibraryConfig { formats: dto.metadata.formats.clone(), }, playlist: LibraryPlaylistConfig { - movie_category: intern(&dto.playlist.movie_category), - series_category: intern(&dto.playlist.series_category), + movie_category: dto.playlist.movie_category.as_str().intern(), + series_category: dto.playlist.series_category.as_str().intern(), }, } } diff --git a/backend/src/model/config/source.rs b/backend/src/model/config/source.rs index 5272b3755..8182895b4 100644 --- a/backend/src/model/config/source.rs +++ b/backend/src/model/config/source.rs @@ -8,7 +8,7 @@ use std::sync::Arc; #[derive(Debug, Clone)] pub struct ConfigSource { - pub inputs: Vec, + pub inputs: Vec>, pub targets: Vec>, } @@ -98,7 +98,7 @@ impl SourcesConfig { None } - pub fn get_source_inputs_by_target_by_name(&self, target_name: &str) -> Option> { + pub fn get_source_inputs_by_target_by_name(&self, target_name: &str) -> Option>> { for source in &self.sources { for target in &source.targets { if target.name == target_name { @@ -176,7 +176,7 @@ impl SourcesConfig { file_names } - pub fn get_input_by_name(&self, name: &str) -> Option<&Arc> { - self.inputs.iter().find(|i| i.name == name) + pub fn get_input_by_name(&self, name: &Arc) -> Option<&Arc> { + self.inputs.iter().find(|i| &i.name == name) } } diff --git a/backend/src/model/config/trakt.rs b/backend/src/model/config/trakt.rs index e60406654..79ea8b6e5 100644 --- a/backend/src/model/config/trakt.rs +++ b/backend/src/model/config/trakt.rs @@ -69,6 +69,7 @@ impl From<&TraktListConfig> for TraktListConfigDto { #[derive(Debug, Clone)] pub struct TraktConfig { + pub enabled: bool, pub api: TraktApiConfig, pub lists: Vec, } @@ -77,6 +78,7 @@ macros::from_impl!(TraktConfig); impl From<&TraktConfigDto> for TraktConfig { fn from(dto: &TraktConfigDto) -> Self { Self { + enabled: dto.enabled, api: TraktApiConfig::from(&dto.api), lists: dto.lists.iter().map(Into::into).collect(), } @@ -85,6 +87,7 @@ impl From<&TraktConfigDto> for TraktConfig { impl From<&TraktConfig> for TraktConfigDto { fn from(dto: &TraktConfig) -> Self { Self { + enabled: dto.enabled, api: TraktApiConfigDto::from(&dto.api), lists: dto.lists.iter().map(TraktListConfigDto::from).collect(), } diff --git a/backend/src/model/input_source.rs b/backend/src/model/input_source.rs index 8fe3c0787..fc5daab63 100644 --- a/backend/src/model/input_source.rs +++ b/backend/src/model/input_source.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; +use std::sync::Arc; use shared::model::InputFetchMethod; use crate::model::{ConfigInput, StagedInput}; #[derive(Clone, Debug)] pub struct InputSource { - pub name: String, + pub name: Arc, pub url: String, pub username: Option, pub password: Option, diff --git a/backend/src/model/playlist.rs b/backend/src/model/playlist.rs index e1a2204b7..1bb6f5fa4 100644 --- a/backend/src/model/playlist.rs +++ b/backend/src/model/playlist.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use crate::model::{ConfigInput, TVGuide}; -use shared::model::{PlaylistGroup, PlaylistItem, UUIDType}; +use shared::model::{PlaylistGroup, PlaylistItem}; +use shared::model::UUIDType; use crate::repository::PlaylistSource; pub struct FetchedPlaylist<'a> { diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index cb1148865..4c3bde2de 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use futures::TryFutureExt; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use url::Url; -use shared::utils::sanitize_sensitive_info; +use shared::utils::{sanitize_sensitive_info, Internable}; use crate::api::model::AppState; use crate::model::{InputSource}; use crate::utils::async_file_reader; @@ -227,7 +227,7 @@ pub async fn parse_xmltv_for_web_ui_from_url(app_state: &Arc, url: &st if let Ok(request_url) = Url::parse(url) { let client = app_state.http_client.load(); let input_source: InputSource = InputSource { - name: String::from("xmltv"), + name: "xmltv".intern(), url: request_url.to_string(), username: None, password: None, diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index 122b0039e..316941f93 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -1,9 +1,10 @@ +use std::sync::Arc; use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, XtreamTargetOutput}; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared::model::{PlaylistItem, PlaylistItemType, PlaylistItemTypeSet, ProxyUserStatus, XtreamMappingOptions}; -use shared::utils::{deserialize_as_string, deserialize_number_from_string_or_zero}; +use shared::utils::{arc_str_serde, deserialize_number_from_string_or_zero}; use enum_iterator::all; #[derive(Debug, Default)] @@ -16,8 +17,8 @@ pub struct XtreamLoginInfo { pub struct XtreamCategory { #[serde(deserialize_with = "deserialize_number_from_string_or_zero", serialize_with = "shared::utils::serialize_number_as_string")] pub category_id: u32, - #[serde(deserialize_with = "deserialize_as_string")] - pub category_name: String, + #[serde(with = "arc_str_serde")] + pub category_name: Arc, //pub parent_id: i32, #[serde(default)] pub channels: Vec, @@ -79,7 +80,7 @@ pub fn normalize_release_date(document: &mut serde_json::Map) { #[derive(Deserialize, Serialize, Clone)] pub struct PlaylistXtreamCategory { #[serde(alias = "category_id")] - pub id: String, + pub id: u32, #[serde(alias = "category_name")] pub name: String, } \ No newline at end of file diff --git a/backend/src/processing/parser/hls.rs b/backend/src/processing/parser/hls.rs index c0fc5680b..408b5d0a4 100644 --- a/backend/src/processing/parser/hls.rs +++ b/backend/src/processing/parser/hls.rs @@ -1,12 +1,16 @@ +use std::borrow::Cow; use crate::model::ProxyUserCredentials; -use shared::utils::{CONSTANTS, HLS_PREFIX}; use crate::utils::{deobfuscate_text, obfuscate_text}; +use shared::utils::{CONSTANTS, HLS_PREFIX}; use std::str; +use url::Url; +use shared::concat_string; const TOKEN_SEPARATOR: char = '\x1F'; +const TOKEN_SEPARATOR_STR: &str = "\x1F"; fn create_hls_session_token_and_url(secret: &[u8], session_token: &str, stream_url: &str) -> Option { - if let Ok(cookie_value) = obfuscate_text(secret, &format!("{session_token}{TOKEN_SEPARATOR}{stream_url}")) { + if let Ok(cookie_value) = obfuscate_text(secret, &concat_string!(session_token, TOKEN_SEPARATOR_STR, stream_url)) { return Some(cookie_value); } None @@ -15,7 +19,7 @@ fn create_hls_session_token_and_url(secret: &[u8], session_token: &str, stream_u pub fn get_hls_session_token_and_url_from_token(secret: &[u8], token: &str) -> Option<(Option, String)> { if let Ok(decrypted) = deobfuscate_text(secret, token) { let parts: Vec<&str> = decrypted.split(TOKEN_SEPARATOR).collect(); - if !parts.is_empty() && parts.len() == 2 { + if parts.len() == 2 { let session_token: String = parts[0].to_string(); let stream_url: String = parts[1].to_string(); return Some((Some(session_token), stream_url)); @@ -24,7 +28,6 @@ pub fn get_hls_session_token_and_url_from_token(secret: &[u8], token: &str) -> O None } - pub struct RewriteHlsProps<'a> { pub secret: &'a [u8; 16], pub base_url: &'a str, @@ -35,33 +38,43 @@ pub struct RewriteHlsProps<'a> { pub user_token: Option<&'a str>, } -fn rewrite_hls_url(input: &str, replacement: &str) -> String { - if replacement.starts_with('/') { - let parts = input.splitn(4, '/').collect::>(); - if parts.len() < 4 { - return replacement.to_string(); // less than 3 Slashes → replace all - } - format!("{}/{}/{}{}", parts[0], parts[1], parts[2], replacement) - } else { - match input.rsplitn(2, '/').collect::>().as_slice() { - [_after, before] => format!("{before}/{replacement}"), - [_only] => replacement.to_string(), - _ => input.to_string(), - } +/// Rewrites an HLS URI relative to a base playlist URL. +/// Absolute URIs are returned unchanged. +pub fn rewrite_hls_url<'a>(base: &'a str, reference: &'a str) -> Cow<'a, str> { + // absolute URI → passthrough + if Url::parse(reference).is_ok() { + return Cow::Borrowed(reference); } + + let Ok(base_url) = Url::parse(base) else { + return Cow::Borrowed(reference); + }; + + base_url.join(reference).map_or_else(|_| Cow::Borrowed(reference), |u| Cow::Owned(u.to_string())) } -fn rewrite_uri_attrib(line: &str, props: &RewriteHlsProps) -> String { - if let Some(caps) = CONSTANTS.re_hls_uri.captures(line) { - let uri = &caps[1]; - let target_url = &rewrite_hls_url(&props.hls_url, uri); - if let Some(user_token) = &props.user_token { - if let Some(token) = create_hls_session_token_and_url(props.secret, user_token, target_url) { - return CONSTANTS.re_hls_uri.replace(line, format!(r#"URI="{token}""#)).to_string(); - } - } - } - line.to_string() +fn rewrite_uri_attrib<'a>(line: &'a str, props: &RewriteHlsProps) -> Cow<'a, str> { + let Some(caps) = CONSTANTS.re_hls_uri.captures(line) else { + return Cow::Borrowed(line); + }; + + let uri = &caps[1]; + let rewritten = rewrite_hls_url(&props.hls_url, uri); + + let final_uri = if let Some(user_token) = &props.user_token { + create_hls_session_token_and_url( + props.secret, + user_token, + &rewritten, + ).map(Cow::Owned).unwrap_or(rewritten) + } else { + rewritten + }; + + Cow::Owned(CONSTANTS + .re_hls_uri + .replace(line, format!(r#"URI="{final_uri}""#)) + .to_string()) } pub fn rewrite_hls(user: &ProxyUserCredentials, props: &RewriteHlsProps) -> String { @@ -69,7 +82,6 @@ pub fn rewrite_hls(user: &ProxyUserCredentials, props: &RewriteHlsProps) -> Stri let password = &user.password; let mut result = Vec::new(); for line in props.content.lines() { - if line.trim().is_empty() { continue; } @@ -77,16 +89,12 @@ pub fn rewrite_hls(user: &ProxyUserCredentials, props: &RewriteHlsProps) -> Stri // skip comments if line.starts_with('#') { let rewritten = rewrite_uri_attrib(line, props); - result.push(rewritten); + result.push(rewritten.to_string()); continue; } // target url - let target_url = if line.starts_with("http") { - line.to_string() - } else { - rewrite_hls_url(&props.hls_url, line) - }; + let target_url = rewrite_hls_url(&props.hls_url, line); if let Some(user_token) = &props.user_token { if let Some(token) = create_hls_session_token_and_url(props.secret, user_token, &target_url) { let url = format!( @@ -110,6 +118,7 @@ pub fn rewrite_hls(user: &ProxyUserCredentials, props: &RewriteHlsProps) -> Stri mod test { use rand::RngCore; use shared::utils::u32_to_base64; + use crate::processing::parser::hls::{rewrite_hls_url}; #[test] fn test_token_size() { @@ -119,4 +128,75 @@ mod test { } } + #[test] + fn rewrite_http_relative_segment() { + let base = "http://example.com/hls/playlist.m3u8"; + let uri = "seg001.ts"; + + let out = rewrite_hls_url(base, uri); + assert_eq!(out, "http://example.com/hls/seg001.ts"); + } + + #[test] + fn rewrite_http_root_relative_segment() { + let base = "http://example.com/hls/playlist.m3u8"; + let uri = "/media/seg001.ts"; + + let out = rewrite_hls_url(base, uri); + assert_eq!(out, "http://example.com/media/seg001.ts"); + } + + #[test] + fn rewrite_http_parent_directory() { + let base = "http://example.com/hls/level1/playlist.m3u8"; + let uri = "../seg001.ts"; + + let out = rewrite_hls_url(base, uri); + assert_eq!(out, "http://example.com/hls/seg001.ts"); + } + + #[test] + fn rewrite_https_absolute_passthrough() { + let base = "http://example.com/hls/playlist.m3u8"; + let uri = "https://cdn.example.org/video/seg.ts"; + + let out = rewrite_hls_url(base, uri); + assert_eq!(out, uri); + } + + #[test] + fn rewrite_file_relative_segment() { + let base = "file:///mnt/media/hls/playlist.m3u8"; + let uri = "seg001.ts"; + + let out = rewrite_hls_url(base, uri); + assert_eq!(out, "file:///mnt/media/hls/seg001.ts"); + } + + #[test] + fn rewrite_file_parent_directory() { + let base = "file:///mnt/media/hls/level1/playlist.m3u8"; + let uri = "../seg001.ts"; + + let out = rewrite_hls_url(base, uri); + assert_eq!(out, "file:///mnt/media/hls/seg001.ts"); + } + + #[test] + fn rewrite_file_absolute_passthrough() { + let base = "file:///mnt/media/hls/playlist.m3u8"; + let uri = "file:///mnt/other/seg.ts"; + + let out = rewrite_hls_url(base, uri); + assert_eq!(out, uri); + } + + #[test] + fn rewrite_hls_fragment() { + let base = "http://example.com/hls/playlist.m3u8"; + let fragment = "seg.ts#t=10"; + + let out = rewrite_hls_url(base, fragment); + assert_eq!(out, "http://example.com/hls/seg.ts#t=10"); + } } \ No newline at end of file diff --git a/backend/src/processing/parser/m3u.rs b/backend/src/processing/parser/m3u.rs index 902e17954..0c815e31b 100644 --- a/backend/src/processing/parser/m3u.rs +++ b/backend/src/processing/parser/m3u.rs @@ -1,25 +1,26 @@ use crate::model::{Config, ConfigInput}; use crate::utils::request::DynReader; use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; -use shared::utils::{default_supported_video_extensions, extract_id_from_url, StringInterner}; +use shared::utils::{default_supported_video_extensions, extract_id_from_url, Internable}; use std::borrow::BorrowMut; +use std::sync::Arc; use tokio::io::AsyncBufReadExt; use indexmap::IndexMap; use shared::concat_string; // 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. -fn get_title_group(text: &str) -> String { +fn get_title_group(text: &Arc) -> Arc { let alphabetic_only: String = text.chars().map(|c| if c.is_alphanumeric() { c } else { ' ' }).collect(); let parts = alphabetic_only.split_whitespace(); let mut combination = String::new(); for p in parts { combination = concat_string!(&combination, " " , p); if combination.len() > 2 { - return combination; + return combination.intern(); } } - text.to_string() + text.clone() } #[inline] @@ -90,11 +91,11 @@ fn skip_digit(it: &mut std::str::Chars) -> Option { } } -fn create_empty_playlistitem_header(interner: &mut StringInterner, input_name: &str, url: String) -> PlaylistItemHeader { +fn create_empty_playlistitem_header(input_name: &Arc, url: String) -> PlaylistItemHeader { PlaylistItemHeader { - url, + url: Arc::from(url), category_id: 0, - input_name: interner.intern(input_name), + input_name: Arc::clone(input_name), ..Default::default() } } @@ -110,7 +111,7 @@ macro_rules! process_header_fields { }; } -fn process_header(interner: &mut StringInterner, input_name: &str, video_suffixes: &[String], content: &str, url: String) -> PlaylistItemHeader { +fn process_header(input_name: &Arc, video_suffixes: &[String], content: &str, url: String) -> PlaylistItemHeader { let url_id = extract_id_from_url(&url); let url_types = if video_suffixes.iter().any(|suffix| url.ends_with(suffix)) { // TODO find Series based on group or configured names @@ -119,7 +120,7 @@ fn process_header(interner: &mut StringInterner, input_name: &str, video_suffixe None }; - let mut plih = create_empty_playlistitem_header(interner, input_name, url); + let mut plih = create_empty_playlistitem_header(input_name, url); let mut it = content.chars(); let mut stack = String::with_capacity(64); let line_token = token_till(&mut stack, &mut it, ':', false); @@ -133,7 +134,7 @@ fn process_header(interner: &mut StringInterner, input_name: &str, video_suffixe if chr.is_whitespace() { // skip } else if chr == ',' { - plih.title = get_value(&mut stack, &mut it); + plih.title = get_value(&mut stack, &mut it).intern(); } else { stack.push(chr); let token = token_till(&mut stack, &mut it, '=', true); @@ -146,19 +147,22 @@ fn process_header(interner: &mut StringInterner, input_name: &str, video_suffixe } } else if token == "tvg-chno" { plih.chno = value.parse::().unwrap_or(0); + } else if token == "group-title" { + plih.group = value.intern(); + } else if token == "tvg-id" { + plih.id = value.intern(); + } else if token == "tvg-name" { + plih.name = value.intern(); + } else if token == "tvg-logo" { + plih.logo = value.intern(); + } else if token == "tvg-logo-small" { + plih.logo_small = value.intern(); } else { process_header_fields!(plih, token.as_str(), - (id, "tvg-id"), - (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.clone()); - if token == "group-title" { - plih.group = interner.intern(&value); - } + (rec, "tvg-rec"); value.intern()); } } } @@ -170,14 +174,14 @@ fn process_header(interner: &mut StringInterner, input_name: &str, video_suffixe if plih.id.is_empty() { plih.epg_channel_id = None; if let Some(pid) = provider_id { - plih.id = pid; + plih.id = pid.intern(); } else if let Some(chanid) = url_id { - plih.id = chanid; + plih.id = chanid.intern(); } } else { - plih.epg_channel_id = Some(plih.id.clone()); + plih.epg_channel_id = Some(Arc::clone(&plih.id)); if let Some(pid) = provider_id { - plih.id = pid; + plih.id = pid.intern(); } } } @@ -204,8 +208,7 @@ fn process_header(interner: &mut StringInterner, input_name: &str, video_suffixe pub async fn consume_m3u(cfg: &Config, input: &ConfigInput, lines: DynReader, mut visit: F) { let mut header: Option = None; let mut group: Option = None; - let mut interner = StringInterner::new(); - let input_name = input.name.as_str(); + let input_name = &input.name; let video_suffixes = match cfg.video.as_ref() { Some(config) => { @@ -228,19 +231,19 @@ pub async fn consume_m3u(cfg: &Config, input: &ConfigInp continue; } if let Some(header_value) = header { - let mut item = PlaylistItem { header: process_header(&mut interner, input_name, &video_suffixes, &header_value, line) }; + let mut item = PlaylistItem { header: process_header(input_name, &video_suffixes, &header_value, line) }; let header = &mut item.header; header.source_ordinal = ord_counter; ord_counter += 1; - if header.group.is_empty() { - if let Some(group_value) = group { - header.group = interner.intern(&group_value); - } else { - let current_title = header.title.clone(); - header.group = interner.intern(&get_title_group(current_title.as_str())); + if header.group.is_empty() { + if let Some(group_value) = group { + header.group = group_value.intern(); + } else { + let group = get_title_group(&header.title); + header.group = group; + } } - } - visit(item); + visit(item); } header = None; group = None; @@ -274,53 +277,50 @@ pub async fn parse_m3u(cfg: &Config, input: &ConfigInput, lines: DynReader) -> V #[cfg(test)] mod test { + use shared::utils::Internable; use crate::processing::parser::m3u::process_header; - use shared::utils::StringInterner; #[test] fn test_process_header_1() { - let mut interner = StringInterner::new(); - let input: &str = "hello"; + let input = "hello".intern(); let video_suffixes = Vec::new(); let url = "http://hello.de/hello.ts"; let line = r#"#EXTINF:-1 channel-id="abc-seven" tvg-id="abc-seven" tvg-logo="https://abc.nz/.images/seven.png" tvg-chno="7" group-title="Sydney" , Seven"#; - let pli = process_header(&mut interner, input, &video_suffixes, line, url.to_string()); - assert_eq!(pli.title, "Seven"); - assert_eq!(pli.id, "abc-seven"); - assert_eq!(pli.logo, "https://abc.nz/.images/seven.png"); + let pli = process_header(&input, &video_suffixes, line, url.to_string()); + assert_eq!(pli.title, "Seven".intern()); + assert_eq!(pli.id, "abc-seven".intern()); + assert_eq!(pli.logo, "https://abc.nz/.images/seven.png".intern()); assert_eq!(pli.chno, 7); assert_eq!(&*pli.group, "Sydney"); } #[test] fn test_process_header_2() { - let mut interner = StringInterner::new(); - let input: &str = "hello"; + let input = "hello".intern(); let video_suffixes = Vec::new(); let url = "http://hello.de/hello.ts"; let line = r#"#EXTINF:-1 channel-id="abc-seven" tvg-id="abc-seven" tvg-logo="https://abc.nz/.images/seven.png" tvg-chno="7" group-title="Sydney", Seven"#; - let pli = process_header(&mut interner, input, &video_suffixes, line, url.to_string()); - assert_eq!(pli.title, "Seven"); - assert_eq!(pli.id, "abc-seven"); - assert_eq!(pli.logo, "https://abc.nz/.images/seven.png"); + let pli = process_header(&input, &video_suffixes, line, url.to_string()); + assert_eq!(pli.title, "Seven".intern()); + assert_eq!(pli.id, "abc-seven".intern()); + assert_eq!(pli.logo, "https://abc.nz/.images/seven.png".intern()); assert_eq!(pli.chno, 7); assert_eq!(&*pli.group, "Sydney"); } #[test] fn test_process_header_xui_id() { - let mut interner = StringInterner::new(); - let input: &str = "hello"; + let input = "hello".intern(); let video_suffixes = Vec::new(); let url = "http://hello.de/hello.ts"; let line = r#"#EXTINF:-1 tvg-id="abc-seven" xui-id="provider-123" group-title="Sydney", Seven"#; - let pli = process_header(&mut interner, input, &video_suffixes, line, url.to_string()); - assert_eq!(pli.title, "Seven"); - assert_eq!(pli.id, "provider-123"); // Should use xui-id - assert_eq!(pli.epg_channel_id, Some("abc-seven".to_string())); // Should preserve original tvg-id + let pli = process_header(&input, &video_suffixes, line, url.to_string()); + assert_eq!(pli.title, "Seven".intern()); + assert_eq!(pli.id, "provider-123".intern()); // Should use xui-id + assert_eq!(pli.epg_channel_id, Some("abc-seven".intern())); // Should preserve original tvg-id assert_eq!(&*pli.group, "Sydney"); } } \ No newline at end of file diff --git a/backend/src/processing/parser/xmltv.rs b/backend/src/processing/parser/xmltv.rs index b90bf74ad..a7a230b55 100644 --- a/backend/src/processing/parser/xmltv.rs +++ b/backend/src/processing/parser/xmltv.rs @@ -7,7 +7,7 @@ use dashmap::DashMap; use quick_xml::events::{BytesStart, BytesText, Event}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use shared::model::EpgNamePrefix; -use shared::utils::{deunicode_string, CONSTANTS}; +use shared::utils::{deunicode_string, Internable, CONSTANTS}; use std::borrow::Cow; use std::cell::RefCell; use std::cmp::min; @@ -488,7 +488,7 @@ pub fn flatten_tvguide(tv_guides: &[Epg]) -> Option { let epg_children: Mutex>> = Mutex::new(Vec::new()); let epg_attributes: Option, String>> = tv_guides.first().and_then(|t| t.attributes.clone()); let count = tv_guides.iter().map(|tvg| tvg.children.len()).sum(); - let channel_mapping: DashMap = DashMap::with_capacity(count); + let channel_mapping: DashMap, i16> = DashMap::with_capacity(count); let mut sorted_guides = tv_guides.to_vec(); // sort by priority @@ -499,14 +499,15 @@ pub fn flatten_tvguide(tv_guides: &[Epg]) -> Option { guide.children.iter().for_each(|c| { if c.name.as_ref() == EPG_TAG_CHANNEL { if let Some(chan_id) = c.get_attribute_value(EPG_ATTRIB_ID) { + let chan_id = chan_id.intern(); let should_add = { // if not stored - !channel_mapping.contains_key(chan_id) || + !channel_mapping.contains_key(&chan_id) || // or if priority is higher (less means higher priority) - channel_mapping.get(chan_id).as_deref().is_none_or(|&priority| guide.priority < priority) + channel_mapping.get(&chan_id).as_deref().is_none_or(|&priority| guide.priority < priority) }; if should_add { - if let Some(mut existing) = channel_mapping.get_mut(chan_id) { + if let Some(mut existing) = channel_mapping.get_mut(&chan_id) { if guide.priority < *existing { *existing = guide.priority; children.push(c.clone()); @@ -522,7 +523,8 @@ pub fn flatten_tvguide(tv_guides: &[Epg]) -> Option { guide.children.iter().for_each(|c| { if c.name.as_ref() == EPG_TAG_PROGRAMME { if let Some(chan_id) = c.get_attribute_value(EPG_ATTRIB_CHANNEL) { - if let Some(stored_priority) = channel_mapping.get(chan_id) { + let chan_id = chan_id.intern(); + if let Some(stored_priority) = channel_mapping.get(&chan_id) { if *stored_priority == guide.priority { children.push(c.clone()); } diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 9a6650ec0..cdbd58f1c 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -1,19 +1,18 @@ -use std::borrow::Cow; use crate::model::ConfigInput; -use crate::model::{XtreamCategory}; +use crate::model::XtreamCategory; use crate::utils::request::DynReader; use crate::utils::xtream::get_xtream_stream_url_base; -use shared::error::{notify_err_res, notify_err, TuliproxError}; +use indexmap::IndexMap; +use serde::Deserializer; +use shared::error::{notify_err, notify_err_res, TuliproxError}; +use shared::model::UUIDType; use shared::model::{EpisodeStreamProperties, LiveStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, - SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamProperties, + SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem}; -use shared::utils::{generate_playlist_uuid, trim_last_slash, StringInterner}; -use indexmap::IndexMap; +use shared::utils::{generate_playlist_uuid, trim_last_slash, Internable}; +use std::sync::Arc; use tokio::task::spawn_blocking; -use serde::Deserializer; - - async fn map_to_xtream_category(categories: DynReader) -> Result, TuliproxError> { spawn_blocking(move || { @@ -51,46 +50,46 @@ async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader }).await.map_err(|e| notify_err!("Mapping xtream streams failed: {e}"))? } -fn create_xtream_series_episode_url<'a>(url: &'a str, username: &'a str, password: &'a str, episode: &'a SeriesStreamDetailEpisodeProperties) -> Cow<'a, str> { +fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &SeriesStreamDetailEpisodeProperties) -> Arc { if episode.direct_source.is_empty() { let ext = episode.container_extension.clone(); let stream_base_url = format!("{url}/series/{username}/{password}/{}.{ext}", episode.id); - Cow::Owned(stream_base_url) + stream_base_url.into() } else { - Cow::Borrowed(&episode.direct_source) + Arc::clone(&episode.direct_source) } } - pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStreamProperties, - group_title: &str, series_name: &str, input: &ConfigInput, - interner: &mut StringInterner) -> Option> { +pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStreamProperties, + group_title: &str, series_name: &Arc, 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_id = episode.id.to_string(); let episode_url = create_xtream_series_episode_url(url, username, password, episode); 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), - // we use parent_code to track the parent series - parent_code: parent_uuid.to_string(), - name: series_name.to_string(), - logo: episode.movie_image.clone(), - group: interner.intern(group_title), - 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: interner.intern(&input.name), - ..Default::default() - } - } + PlaylistItem { + header: PlaylistItemHeader { + uuid: generate_playlist_uuid(&input.name, &episode_id, PlaylistItemType::Series, &episode_url), + id: episode_id.into(), + // we use parent_code to track the parent series + parent_code: parent_uuid.intern(), + name: Arc::clone(series_name), + logo: Arc::clone(&episode.movie_image), + group: group_title.intern(), + title: Arc::clone(&episode.title), + url: episode_url, + item_type: PlaylistItemType::Series, + xtream_cluster: XtreamCluster::Series, + additional_properties: Some(StreamProperties::Episode(episode_info)), + category_id: 0, + input_name: input.name.intern(), + ..Default::default() + } + } }).collect(); return if result.is_empty() { None } else { Some(result) }; } @@ -122,12 +121,12 @@ pub fn get_xtream_url(xtream_cluster: XtreamCluster, url: &str, stream_base_url } -pub fn create_xtream_url<'a>(xtream_cluster: XtreamCluster, url: &'a str, username: &'a str, password: &'a str, - stream: &'a StreamProperties, live_stream_use_prefix: bool, live_stream_without_extension: bool) -> Cow<'a, str> { +pub fn create_xtream_url(xtream_cluster: XtreamCluster, url: &str, username: &str, password: &str, + stream: &StreamProperties, live_stream_use_prefix: bool, live_stream_without_extension: bool) -> Arc { stream.get_direct_source().unwrap_or_else(|| - Cow::Owned(get_xtream_url(xtream_cluster, url, username, password, stream.get_stream_id(), + get_xtream_url(xtream_cluster, url, username, password, stream.get_stream_id(), stream.get_container_extension().as_deref(), - live_stream_use_prefix, live_stream_without_extension)) + live_stream_use_prefix, live_stream_without_extension).into() ) } @@ -137,7 +136,6 @@ pub async fn parse_xtream(input: &ConfigInput, streams: DynReader) -> Result>, TuliproxError> { match map_to_xtream_category(categories).await { Ok(xtream_categories) => { - let mut interner = StringInterner::new(); let input_name = input.name.clone(); let url = input.url.as_str(); let username = input.username.as_ref().map_or("", |v| v); @@ -149,7 +147,7 @@ pub async fn parse_xtream(input: &ConfigInput, xtream_categories.into_iter().map(|category| (category.category_id, category)).collect(); let mut unknown_grp = XtreamCategory { category_id: 0u32, - category_name: "Unknown".to_string(), + category_name: "Unknown".intern(), channels: vec![], }; @@ -165,19 +163,19 @@ pub async fn parse_xtream(input: &ConfigInput, let item_type = PlaylistItemType::from(xtream_cluster); let mut item = PlaylistItem { header: PlaylistItemHeader { - id: stream.get_stream_id().to_string(), + id: stream.get_stream_id().intern(), uuid: generate_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type, &stream_url), - name: stream.get_name().to_string(), - logo: stream.get_stream_icon().to_string(), - group: interner.intern(category_name), - title: stream.get_name().to_string(), - url: stream_url.to_string(), - epg_channel_id: stream.get_epg_channel_id(), + name: Arc::clone(&stream.get_name()), + logo: Arc::clone(&stream.get_stream_icon()), + group: Arc::clone(category_name), + title: Arc::clone(&stream.get_name()), + url: stream_url.clone(), + epg_channel_id: stream.get_epg_channel_id().clone(), item_type, xtream_cluster, + category_id: stream.get_category_id(), additional_properties: Some(stream), - category_id: 0, - input_name: interner.intern(&input_name), + input_name: input_name.clone(), ..Default::default() }, }; @@ -197,7 +195,7 @@ pub async fn parse_xtream(input: &ConfigInput, PlaylistGroup { id: category.category_id, xtream_cluster, - title: interner.intern(&category.category_name), + title: Arc::clone(&category.category_name), channels: category.channels.clone(), } }).collect())) @@ -215,112 +213,115 @@ pub async fn parse_xtream_streaming( categories: DynReader, streams: DynReader, mut on_item: F, -) -> Result, TuliproxError> -where - F: FnMut(XtreamPlaylistItem) -> Result<(), TuliproxError> + Send + 'static +) -> Result, TuliproxError> +where + F: FnMut(XtreamPlaylistItem) -> Result<(), TuliproxError> + Send + 'static, { // 1. Parse Categories let xtream_categories = map_to_xtream_category(categories).await?; - + // 2. Prepare for Stream Parsing - let mut interner = StringInterner::new(); let input_name = input.name.clone(); - let url = input.url.as_str().to_string(); + let url = input.url.as_str().to_string(); let username = input.username.as_ref().map_or("", |v| v).to_string(); let password = input.password.as_ref().map_or("", |v| v).to_string(); let options = input.options.clone(); // Map categories for lookup - let group_map: IndexMap = xtream_categories.iter().map(|c| (c.category_id, c.category_name.clone())).collect(); - let unknown_group_name = "Unknown".to_string(); + let group_map: IndexMap> = xtream_categories.iter().map(|c| (c.category_id, c.category_name.clone())).collect(); + let unknown_group_name = "Unknown".intern(); spawn_blocking(move || { let reader = tokio_util::io::SyncIoBridge::new(streams); let mut deserializer = serde_json::Deserializer::from_reader(reader); - + let (live_stream_use_prefix, live_stream_without_extension) = options.as_ref() - .map_or((true, false), |o| (o.xtream_live_stream_use_prefix, o.xtream_live_stream_without_extension)); + .map_or((true, false), |o| (o.xtream_live_stream_use_prefix, o.xtream_live_stream_without_extension)); let mut source_ordinal = 0u32; match xtream_cluster { - XtreamCluster::Live => { - let mut on_stream = |stream: LiveStreamProperties| { - source_ordinal += 1; - let stream_prop = StreamProperties::Live(stream); - process_stream_item(&input_name, &url, &username, &password, - xtream_cluster, &mut interner, &group_map, &unknown_group_name, - stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal) - }; - let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData }; - deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?; - } - XtreamCluster::Video => { - let mut on_stream = |stream: VideoStreamProperties| { - source_ordinal += 1; - let stream_prop = StreamProperties::Video(Box::new(stream)); - process_stream_item(&input_name, &url, &username, &password, - xtream_cluster, &mut interner, &group_map, &unknown_group_name, - stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal) - }; - let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData }; - deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?; - } - XtreamCluster::Series => { - let mut on_stream = |stream: SeriesStreamProperties| { - source_ordinal += 1; - let stream_prop = StreamProperties::Series(Box::new(stream)); - process_stream_item(&input_name, &url, &username, &password, - xtream_cluster, &mut interner, &group_map, &unknown_group_name, - stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal) - }; - let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData }; - deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?; - } + XtreamCluster::Live => { + let mut on_stream = |stream: LiveStreamProperties| { + source_ordinal += 1; + let stream_prop = StreamProperties::Live(stream); + process_stream_item(&input_name, &url, &username, &password, + xtream_cluster, &group_map, &unknown_group_name, + stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal) + }; + let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData }; + deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?; + } + XtreamCluster::Video => { + let mut on_stream = |stream: VideoStreamProperties| { + source_ordinal += 1; + let stream_prop = StreamProperties::Video(Box::new(stream)); + process_stream_item(&input_name, &url, &username, &password, + xtream_cluster, &group_map, &unknown_group_name, + stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal) + }; + let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData }; + deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?; + } + XtreamCluster::Series => { + let mut on_stream = |stream: SeriesStreamProperties| { + source_ordinal += 1; + let stream_prop = StreamProperties::Series(Box::new(stream)); + process_stream_item(&input_name, &url, &username, &password, + xtream_cluster, &group_map, &unknown_group_name, + stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal) + }; + let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData }; + deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?; + } } Ok(()) }).await.map_err(|e| notify_err!("Streaming parse failed: {e}"))??; - Ok(xtream_categories) + Ok(xtream_categories) } #[allow(clippy::too_many_arguments)] fn process_stream_item( - input_name: &str, url: &str, username: &str, password: &str, - cluster: XtreamCluster, interner: &mut StringInterner, - group_map: &IndexMap, unknown_group_name: &str, + input_name: &Arc, + url: &str, username: &str, password: &str, + cluster: XtreamCluster, + group_map: &IndexMap>, + unknown_group_name: &Arc, mut stream: StreamProperties, callback: &mut F, - live_stream_use_prefix: bool, live_stream_without_extension: bool, + live_stream_use_prefix: bool, + live_stream_without_extension: bool, source_ordinal: u32, ) -> Result<(), TuliproxError> -where F: FnMut(XtreamPlaylistItem) -> Result<(), TuliproxError> +where + F: FnMut(XtreamPlaylistItem) -> Result<(), TuliproxError>, { - stream.prepare(); - let category_id = stream.get_category_id(); - let category_name = group_map.get(&category_id).map_or(unknown_group_name, String::as_str); - let stream_url = create_xtream_url(cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension); - - let item_type = PlaylistItemType::from(cluster); - 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.get_name().to_string(), - logo: stream.get_stream_icon().to_string(), - group: interner.intern(category_name), - title: stream.get_name().to_string(), - url: stream_url.to_string(), - epg_channel_id: stream.get_epg_channel_id(), - item_type, - xtream_cluster: cluster, - additional_properties: Some(stream), - category_id, - source_ordinal, - input_name: interner.intern(input_name), - ..Default::default() - }, - }; + stream.prepare(); + let category_id = stream.get_category_id(); + let category_name = group_map.get(&category_id).unwrap_or(unknown_group_name); + let stream_url = create_xtream_url(cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension); + + let item_type = PlaylistItemType::from(cluster); + let item = PlaylistItem { + header: PlaylistItemHeader { + id: stream.get_stream_id().intern(), + uuid: generate_playlist_uuid(input_name, &stream.get_stream_id().to_string(), item_type, &stream_url), + name: stream.get_name(), + logo: stream.get_stream_icon(), + group: category_name.clone(), + title: stream.get_name(), + url: stream_url, + epg_channel_id: stream.get_epg_channel_id(), + item_type, + xtream_cluster: cluster, + additional_properties: Some(stream), + category_id, + source_ordinal, + input_name: Arc::clone(input_name), + ..Default::default() + }, + }; // if let Some(StreamProperties::Series(props)) = item.header.additional_properties.as_mut() { // // We need to set category_id for Series properties just like parse_xtream might expect or use? @@ -329,7 +330,7 @@ where F: FnMut(XtreamPlaylistItem) -> Result<(), TuliproxError> // let _ = props; // } - callback(XtreamPlaylistItem::from(&item)) + callback(XtreamPlaylistItem::from(&item)) } struct XtreamItemVisitor<'a, T, F> { diff --git a/backend/src/processing/playlist_watch.rs b/backend/src/processing/playlist_watch.rs index dcd24e999..1a5f19e29 100644 --- a/backend/src/processing/playlist_watch.rs +++ b/backend/src/processing/playlist_watch.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::sync::Arc; use std::path::{Path}; use log::{error, info}; use shared::model::{MsgKind, PlaylistGroup}; @@ -23,8 +24,8 @@ pub async fn process_group_watch(client: &reqwest::Client, cfg: &Config, target_ if let Ok(true) = tokio::fs::try_exists(&path).await { if let Some(loaded_tree) = load_watch_tree(&path).await { // Find elements in set2 but not in set1 - let added_difference: BTreeSet = new_tree.difference(&loaded_tree).cloned().collect(); - let removed_difference: BTreeSet = loaded_tree.difference(&new_tree).cloned().collect(); + let added_difference: BTreeSet> = new_tree.difference(&loaded_tree).cloned().collect(); + let removed_difference: BTreeSet> = loaded_tree.difference(&new_tree).cloned().collect(); if !added_difference.is_empty() || !removed_difference.is_empty() { changed = true; handle_watch_notification(client, cfg, &added_difference, &removed_difference, target_name, &pl.title).await; @@ -59,7 +60,7 @@ struct WatchChanges { pub removed: Vec, } -async fn handle_watch_notification(client: &reqwest::Client, cfg: &Config, added: &BTreeSet, removed: &BTreeSet, target_name: &str, group_name: &str) { +async fn handle_watch_notification(client: &reqwest::Client, cfg: &Config, added: &BTreeSet>, removed: &BTreeSet>, target_name: &str, group_name: &str) { let added = added.iter().map(std::string::ToString::to_string).collect::>(); let removed = removed.iter().map(std::string::ToString::to_string).collect::>(); if !added.is_empty() || !removed.is_empty() { @@ -77,12 +78,12 @@ async fn handle_watch_notification(client: &reqwest::Client, cfg: &Config, added } } -async fn load_watch_tree(path: &Path) -> Option> { +async fn load_watch_tree(path: &Path) -> Option>> { let encoded = tokio::fs::read(path).await.ok()?; binary_deserialize(&encoded[..]).ok() } -async fn save_watch_tree(path: &Path, tree: &BTreeSet) -> std::io::Result<()> { +async fn save_watch_tree(path: &Path, tree: &BTreeSet>) -> std::io::Result<()> { let encoded: Vec = binary_serialize(&tree)?; tokio::fs::write(path, encoded).await } diff --git a/backend/src/processing/processor/epg.rs b/backend/src/processing/processor/epg.rs index 98895cf80..bcec64e30 100644 --- a/backend/src/processing/processor/epg.rs +++ b/backend/src/processing/processor/epg.rs @@ -169,9 +169,9 @@ async fn assign_channel_epg(new_epg: &mut Vec, fp: &mut FetchedPlaylist<'_> let mut icon_assigned = HashSet::new(); for epg_source in epg_sources { // icon tags - let icon_tags: HashMap<&String, &Arc> = epg_source.children.iter() + let icon_tags: HashMap<&str, &Arc> = epg_source.children.iter() .filter(|tag| tag.icon != XmlTagIcon::Undefined) - .filter_map(|tag| tag.get_attribute_value(EPG_ATTRIB_ID).map(|id| (id, tag))) + .filter_map(|tag| tag.get_attribute_value(EPG_ATTRIB_ID).map(|id| (id.as_str(), tag))) .collect(); let assign_values = |chan: &mut PlaylistItem| { @@ -180,7 +180,7 @@ async fn assign_channel_epg(new_epg: &mut Vec, fp: &mut FetchedPlaylist<'_> // if the channel has no epg_id or the epg_id is not present in xmltv/tvguide then we need to match one from existing tvguide let not_found_in_epg = match &chan.header.epg_channel_id { None => true, - Some(epg_id) => !id_cache.processed.contains(epg_id), + Some(epg_id) => !id_cache.processed.contains(&**epg_id), }; if not_found_in_epg { let try_match = |key: &str| { @@ -195,22 +195,22 @@ async fn assign_channel_epg(new_epg: &mut Vec, fp: &mut FetchedPlaylist<'_> if let Some(new_id) = try_match(&chan.header.name) .or_else(|| chan.header.epg_channel_id.as_deref().and_then(try_match)) { - chan.header.epg_channel_id = Some(new_id); + chan.header.epg_channel_id = Some(new_id.into()); } } } if let Some(epg_channel_id) = chan.header.epg_channel_id.as_ref() { if !icon_assigned.contains(epg_channel_id) && (epg_source.logo_override || chan.header.logo.is_empty() || chan.header.logo_small.is_empty()) { - if let Some(icon_tag) = icon_tags.get(epg_channel_id) { + if let Some(icon_tag) = icon_tags.get(&**epg_channel_id) { if let XmlTagIcon::Src(icon) = &icon_tag.icon { icon_assigned.insert(epg_channel_id.clone()); if epg_source.logo_override || chan.header.logo.is_empty() { trace!("Matched channel {} to epg icon {icon}", chan.header.name); - chan.header.logo = icon.clone(); + chan.header.logo = icon.clone().into(); } if epg_source.logo_override || chan.header.logo_small.is_empty() { - chan.header.logo_small = icon.clone(); + chan.header.logo_small = icon.clone().into(); } } } diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 479c158f5..3d306a79a 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -1,12 +1,13 @@ -use std::collections::HashMap; use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; use crate::model::{AppConfig, ConfigInput}; +use shared::concat_string; use shared::error::TuliproxError; -use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster}; -use shared::utils::{generate_playlist_uuid, StringInterner}; +use shared::model::UUIDType; +use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster}; +use shared::utils::{generate_playlist_uuid, Internable}; +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; -use shared::concat_string; pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> (Vec, Vec) { let config = &*app_config.config.load(); @@ -27,14 +28,13 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A channels: vec![], xtream_cluster: XtreamCluster::Series, }; - let mut interner = StringInterner::new(); while let Some(entry) = metadata_iter.next().await { match entry.metadata { MediaMetadata::Movie(_) => { - to_playlist_item(&mut interner, &entry, &input.name, &library_config.playlist.movie_category, &mut group_movies.channels); + to_playlist_item(&entry, &input.name, &library_config.playlist.movie_category, &mut group_movies.channels); } MediaMetadata::Series(_) => { - to_playlist_item(&mut interner, &entry, &input.name, &library_config.playlist.series_category, &mut group_series.channels); + to_playlist_item(&entry, &input.name, &library_config.playlist.series_category, &mut group_series.channels); } } } @@ -51,24 +51,26 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A } -fn to_playlist_item(interner: &mut StringInterner, entry: &MetadataCacheEntry, input_name: &str, group_name: &str, channels: &mut Vec) { +fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &Arc, group_name: &str, channels: &mut Vec) { let metadata = &entry.metadata; match metadata { MediaMetadata::Movie(_) => { let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry); + let title = metadata.title().intern(); + let group = group_name.intern(); channels.push(PlaylistItem { header: PlaylistItemHeader { uuid: UUIDType::from_valid_uuid(&entry.uuid), - name: metadata.title().to_string(), - group: interner.intern(group_name), - title: metadata.title().to_string(), - logo: metadata.poster().map_or_else(String::new, ToString::to_string), - url: format!("file://{}", entry.file_path), + name: Arc::clone(&title), + title, + group, + logo: metadata.poster().unwrap_or("").intern(), + url: concat_string!("file://", &entry.file_path).into(), xtream_cluster: XtreamCluster::Video, additional_properties, item_type: PlaylistItemType::LocalVideo, - input_name: interner.intern(input_name), + input_name: Arc::clone(input_name), ..PlaylistItemHeader::default() } }); @@ -76,30 +78,31 @@ fn to_playlist_item(interner: &mut StringInterner, entry: &MetadataCacheEntry, i MediaMetadata::Series(_series) => { if let Some(additional_properties) = metadata_cache_entry_to_xtream_series_info(entry) { let mut episodes = vec![]; + let group_arc: Arc = group_name.intern(); 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 logo = if episode.movie_image.is_empty() { metadata.poster().map_or_else(String::new, ToString::to_string) } else { episode.movie_image.clone() }; - let container_extension = Path::new(&episode.direct_source) + let logo: Arc = if episode.movie_image.is_empty() { metadata.poster().unwrap_or("").intern() } else { episode.movie_image.clone() }; + 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(), + id: episode.id.to_string().into(), // we use parent_code for local series to find the parent series info and straighten the virtual_ids - parent_code: entry.uuid.clone(), + parent_code: entry.uuid.clone().into(), uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source), logo: logo.clone(), name: episode.title.clone(), - group: interner.intern(group_name), + group: Arc::clone(&group_arc), title: episode.title.clone(), url: episode.direct_source.clone(), xtream_cluster: XtreamCluster::Series, item_type: PlaylistItemType::LocalSeries, category_id: 0, - input_name: interner.intern(input_name), + input_name: Arc::clone(input_name), additional_properties: Some(StreamProperties::Episode(EpisodeStreamProperties { episode_id: episode.id, episode: episode.episode_num, @@ -108,7 +111,7 @@ fn to_playlist_item(interner: &mut StringInterner, entry: &MetadataCacheEntry, i release_date: Some(episode.release_date.clone()), tmdb: episode.tmdb, movie_image: logo, - container_extension, + container_extension: container_extension.intern(), audio: None, video: None, })), @@ -123,15 +126,15 @@ fn to_playlist_item(interner: &mut StringInterner, entry: &MetadataCacheEntry, i let series_info = PlaylistItem { header: PlaylistItemHeader { uuid: UUIDType::from_valid_uuid(&entry.uuid), - id: entry.uuid.clone(), - name: metadata.title().to_string(), - group: interner.intern(group_name), - title: metadata.title().to_string(), - logo: metadata.poster().map_or_else(String::new, ToString::to_string), - url: format!("file://{}", entry.file_path), + id: entry.uuid.clone().into(), + name: metadata.title().intern(), + group: group_name.intern(), + title: metadata.title().intern(), + logo: metadata.poster().unwrap_or("").intern(), + url: concat_string!("file://", &entry.file_path).into(), xtream_cluster: XtreamCluster::Series, item_type: PlaylistItemType::LocalSeriesInfo, - input_name: interner.intern(input_name), + input_name: Arc::clone(input_name), additional_properties: Some(additional_properties), ..PlaylistItemHeader::default() } @@ -156,64 +159,64 @@ pub fn metadata_cache_entry_to_xtream_movie_info( .and_then(|s| s.to_str()) .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 actor_names = movie.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::>().join(", ").intern()); let properties = VideoStreamProperties { - name: movie.title.clone(), + name: movie.title.clone().into(), category_id: 0, stream_id: 0, - stream_icon: movie.poster.as_deref().or(movie.fanart.as_deref()).unwrap_or("").to_owned(), - direct_source: String::new(), + stream_icon: movie.poster.as_deref().or(movie.fanart.as_deref()).unwrap_or("").to_owned().into(), + direct_source: "".into(), custom_sid: None, - added: entry.file_modified.to_string(), - container_extension, + added: entry.file_modified.intern(), + container_extension: container_extension.intern(), rating: movie.rating, rating_5based: None, - stream_type: Some("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())), + stream_type: Some("movie".intern()), + trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone().into())), 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")), + kinopoisk_url: movie.tmdb_id.map(|id| concat_string!("https://www.themoviedb.org/movie/", &id.to_string()).into()), + o_name: movie.original_title.clone().map(Into::into), + cover_big: movie.poster.clone().map(Into::into), + movie_image: movie.poster.clone().map(Into::into), + release_date: movie.year.map(|y| format!("{y}-01-01").into()), 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())), + director: movie.directors.as_ref().map(|d| d.join(", ").into()), + 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().into())), actors: actor_names.clone(), - cast: actor_names, - genre: movie.genres.as_ref().map(|g| g.join(", ")), - description: movie.plot.clone(), - plot: movie.plot.clone(), + cast: actor_names.clone(), + genre: movie.genres.as_ref().map(|g| g.join(", ").into()), + description: movie.plot.clone().map(Into::into), + plot: movie.plot.clone().map(Into::into), age: None, - mpaa_rating: movie.mpaa.clone(), + mpaa_rating: movie.mpaa.clone().map(Into::into), rating_count_kinopoisk: 0, country: None, backdrop_path: movie .fanart .as_ref() .filter(|s| !s.is_empty()) - .map(|f| vec![f.clone()]) + .map(|f| vec![f.clone().into()]) .or_else(|| { movie.poster .as_ref() .filter(|s| !s.is_empty()) - .map(|p| vec![p.clone()]) + .map(|p| vec![p.clone().into()]) }), - duration_secs: movie.runtime.map(|r| (r * 60).to_string()), + duration_secs: movie.runtime.map(|r| (r * 60).to_string().into()), duration: movie.runtime.map(|r| { let h = r / 60; let m = r % 60; - format!("{h:02}:{m:02}:00") + format!("{h:02}:{m:02}:00").into() }), video: None, audio: None, bitrate: 0, - runtime: movie.runtime.map(|r| (r * 60).to_string()), - status: Some("Released".to_string()), + runtime: movie.runtime.map(|r| (r * 60).to_string().into()), + status: Some("Released".intern()), }), }; @@ -229,22 +232,22 @@ pub fn metadata_cache_entry_to_xtream_series_info( 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 actor_names: Arc = series.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::>().join(", ")).unwrap_or_default().into(); 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 mut season_data = HashMap::new(); series.seasons.as_ref().iter().for_each(|seasons| seasons.iter().for_each(|season_metadata| { - season_data.insert(season_metadata.season_number,SeriesStreamDetailSeasonProperties { - name: season_metadata.name.clone(), + season_data.insert(season_metadata.season_number, SeriesStreamDetailSeasonProperties { + name: season_metadata.name.clone().into(), season_number: season_metadata.season_number, episode_count: 0, - overview: season_metadata.overview.clone(), - air_date: season_metadata.air_date.clone(), - cover: season_metadata.poster_path.clone(), - cover_tmdb: season_metadata.poster_path.clone(), + overview: season_metadata.overview.clone().map(Into::into), + air_date: season_metadata.air_date.clone().map(Into::into), + cover: season_metadata.poster_path.clone().map(Into::into), + cover_tmdb: season_metadata.poster_path.clone().map(Into::into), cover_big: None, - duration: Some(String::from("0")), + duration: Some(String::from("0").into()), }); })); @@ -258,39 +261,39 @@ pub fn metadata_cache_entry_to_xtream_series_info( 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); - let season_entry =season_data.entry(episode.season).or_insert_with(|| { + let season_entry = season_data.entry(episode.season).or_insert_with(|| { SeriesStreamDetailSeasonProperties { - name: concat_string!(&series.title, " ", &episode.season.to_string()), + name: concat_string!(&series.title, " ", &episode.season.to_string()).into(), season_number: episode.season, episode_count: 0, - overview: series.poster.clone(), - air_date: episode.aired.clone(), - cover: series.poster.clone(), + overview: series.poster.clone().map(Into::into), + air_date: episode.aired.clone().map(Into::into), + cover: series.poster.clone().map(Into::into), cover_tmdb: None, cover_big: None, duration: None, } - }); - season_entry.episode_count = season_entry.episode_count.saturating_add(1); + }); + season_entry.episode_count = season_entry.episode_count.saturating_add(1); SeriesStreamDetailEpisodeProperties { id: tmdb_id.unwrap_or_default(), episode_num: episode.episode, season: episode.season, - title: episode.title.clone(), - container_extension, + title: episode.title.clone().into(), + container_extension: container_extension.into(), custom_sid: None, - added: episode.file_modified.to_string(), - direct_source: episode.file_path.clone(), + added: episode.file_modified.to_string().into(), + direct_source: episode.file_path.clone().into(), tmdb: tmdb_id, - release_date: episode_release_date.clone(), - plot: episode.plot.clone(), - crew: Some(actor_names.clone()), + release_date: episode_release_date.clone().into(), + plot: episode.plot.clone().map(Into::into), + crew: Some(Arc::clone(&actor_names)), 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(), + .unwrap_or_default().into(), + movie_image: episode.thumb.clone().unwrap_or_default().into(), audio: None, video: None, bitrate: 0, @@ -304,30 +307,30 @@ pub fn metadata_cache_entry_to_xtream_series_info( seasons.sort_by_key(|s| s.season_number); let properties = SeriesStreamProperties { - name: series.title.clone(), + name: series.title.clone().into(), category_id: 0, series_id: 0, backdrop_path: series .fanart .as_ref() .filter(|s| !s.is_empty()) - .map(|f| vec![f.clone()]) + .map(|f| vec![f.clone().into()]) .or_else(|| { series.poster.as_ref() .filter(|s| !s.is_empty()) - .map(|p| vec![p.clone()]) + .map(|p| vec![p.clone().into()]) }), - cast: actor_names, - cover: series.poster.clone().unwrap_or_default(), - director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default(), + cast: Arc::clone(&actor_names), + cover: series.poster.clone().unwrap_or_default().into(), + director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default().into(), 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(), + genre: series.genres.as_ref().map(|d| d.join(", ").into()), + last_modified: Some(series.last_updated.to_string().into()), + plot: series.plot.clone().map(Into::into), rating: series.rating.unwrap_or(0f64), rating_5based: 0.0, - release_date, - youtube_trailer, + release_date: release_date.map(Into::into), + youtube_trailer: youtube_trailer.into(), tmdb: series.tmdb_id, details: Some(SeriesStreamDetailProperties { year: series.year, diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 8a1070def..4176d7d4c 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -40,9 +40,11 @@ use shared::foundation::filter::{get_field_value, set_field_value, Filter, Value use shared::model::xtream_const::XTREAM_CLUSTER; use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateState, - ProcessingOrder, UUIDType, XtreamCluster}; -use shared::utils::{create_alias_uuid, default_as_default, StringInterner}; + ProcessingOrder, XtreamCluster}; +use shared::utils::{create_alias_uuid, default_as_default, interner_gc, Internable}; use std::time::Instant; +use shared::concat_string; +use shared::model::UUIDType; fn is_valid(pli: &PlaylistItem, filter: &Filter, match_as_ascii: bool) -> bool { let provider = ValueProvider { pli, match_as_ascii }; @@ -71,7 +73,7 @@ pub fn apply_filter_to_source(source: &mut dyn PlaylistSource, filter: &Filter) if groups.is_empty() { None } else { Some(groups.into_values().collect()) } } -fn filter_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget, _interner: &mut StringInterner) -> Option> { +fn filter_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget) -> Option> { apply_filter_to_source(source, &target.filter) } @@ -111,24 +113,24 @@ fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { } } -fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>, interner: &mut StringInterner) { +fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>) { if let Some(renames) = rename { if !renames.is_empty() { let result = pli; for r in renames { let value = get_field_value(result, r.field); let cap = r.pattern.replace_all(&value, &r.new_name); - if log_enabled!(log::Level::Debug) && value != cap { + if log_enabled!(log::Level::Debug) && *value != *cap { trace_if_enabled!("Renamed {}={value} to {cap}", &r.field); } let value = cap.into_owned(); - set_field_value(result, r.field, value, interner); + set_field_value(result, r.field, value); } } } } -fn rename_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget, interner: &mut StringInterner) -> Option> { +fn rename_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget) -> Option> { match &target.rename { Some(renames) if !renames.is_empty() => { let mut groups: IndexMap<(XtreamCluster, Arc), PlaylistGroup> = IndexMap::new(); @@ -139,11 +141,11 @@ fn rename_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget, inter let value = &*pli.header.group; let cap = r.pattern.replace_all(value, &r.new_name); if *value != cap { - pli.header.group = interner.intern(&cap); + pli.header.group = cap.intern(); } } } - exec_rename(&mut pli, Some(renames), interner); + exec_rename(&mut pli, Some(renames)); let group_title = pli.header.group.clone(); let cluster = pli.header.xtream_cluster; let cat_id = pli.header.category_id; @@ -197,7 +199,7 @@ fn map_channel_and_flatten(channel: PlaylistItem, mapping: &Mapping) -> Vec Option> { +fn map_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget) -> Option> { let mapping_binding = target.mapping.load(); let mappings = mapping_binding.as_ref()?; let valid_mappings = mappings.iter().filter(|m| m.mapper.as_ref().is_some_and(|v| !v.is_empty())); @@ -368,10 +370,9 @@ async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> (Vec, Vec, Vec) { let sources = ctx.config.sources.load(); let mut errors = vec![]; - let mut input_stats = HashMap::::new(); + let mut input_stats = HashMap::, InputStats>::new(); let mut target_stats = Vec::::new(); if let Some(source) = sources.get_source_at(source_idx) { - let mut interner = StringInterner::default(); let mut source_playlists = Vec::with_capacity(128); let broadcast_step = create_broadcast_callback(ctx.event_manager.as_ref()); // Download the sources @@ -402,7 +403,7 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( let tvguide = if input.input_type == InputType::Library { None } else { - download_input_epg(ctx,input, &mut error_list).await + download_input_epg(ctx, input, &mut error_list).await }; errors.append(&mut error_list); @@ -430,14 +431,13 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( if source_downloaded { if source_playlists.is_empty() { debug!("Source at index {source_idx} is empty"); - errors.push(notify_err!("Source at index {source_idx} is empty: {}", source.inputs.iter().map(std::string::String::as_str).collect::>().join(", "))); + errors.push(notify_err!("Source at index {source_idx} is empty: {}", source.inputs.iter().map(Clone::clone).collect::>>().join(", "))); } else { debug_if_enabled!("Source has {} groups", source_playlists.iter_mut().map(FetchedPlaylist::get_channel_count).sum::()); for target in &source.targets { if is_target_enabled(target, &ctx.user_targets) { match process_playlist_for_target(ctx, &mut source_playlists, target, - &mut input_stats, &mut errors, - &mut interner).await { + &mut input_stats, &mut errors).await { Ok(()) => { target_stats.push(TargetStats::success(&target.name)); } @@ -454,7 +454,7 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( (input_stats.into_values().collect(), target_stats, errors) } -async fn download_input_epg(ctx: &PlaylistProcessingContext, input: &Arc, +async fn download_input_epg(ctx: &PlaylistProcessingContext, input: &Arc, error_list: &mut Vec) -> Option { // Download epg for input let (tvguide, mut tvguide_errors) = if error_list.is_empty() { @@ -468,7 +468,7 @@ async fn download_input_epg(ctx: &PlaylistProcessingContext, input: &Arc) - -> (Vec, Box, Option) { + -> (Vec, Box, Option) { // Coordination Logic let need_download = !ctx.is_input_downloaded(&input.name).await; @@ -544,8 +544,9 @@ pub struct PlaylistProcessingContext { pub playlist_state: Option>, // Coordination - processed_inputs: Arc>>, - input_locks: Arc>>>>, + processed_inputs: Arc>>>, + #[allow(clippy::type_complexity)] + input_locks: Arc, Weak>>>>, } impl PlaylistProcessingContext { @@ -553,19 +554,19 @@ impl PlaylistProcessingContext { let processed = self.processed_inputs.lock().await; processed.contains(input_name) } - pub async fn mark_input_downloaded(&self, input_name: String) -> bool { + pub async fn mark_input_downloaded(&self, input_name: Arc) -> bool { let mut processed = self.processed_inputs.lock().await; processed.insert(input_name) } - pub async fn get_input_lock(&self, input_name: &str) -> OwnedRwLockWriteGuard<()> { + pub async fn get_input_lock(&self, input_name: &Arc) -> OwnedRwLockWriteGuard<()> { let mut locks = self.input_locks.lock().await; // Try to upgrade the existing weak reference let lock = locks.get(input_name) .and_then(Weak::upgrade) .unwrap_or_else(|| { let new_lock = Arc::new(RwLock::new(())); - locks.insert(input_name.to_string(), Arc::downgrade(&new_lock)); + locks.insert(input_name.clone(), Arc::downgrade(&new_lock)); new_lock }); @@ -594,7 +595,7 @@ async fn process_sources(processing_ctx: &PlaylistProcessingContext) -> (Vec (Vec Option>>; +pub type ProcessingPipe = Vec Option>>; fn get_processing_pipe(target: &ConfigTarget) -> ProcessingPipe { match &target.processing_order { @@ -652,8 +653,7 @@ fn get_processing_pipe(target: &ConfigTarget) -> ProcessingPipe { } fn execute_pipe<'a>(target: &ConfigTarget, pipe: &ProcessingPipe, fpl: &FetchedPlaylist<'a>, - duplicates: &mut HashSet, - interner: &mut StringInterner) -> FetchedPlaylist<'a> { + duplicates: &mut HashSet) -> FetchedPlaylist<'a> { let mut new_fpl = FetchedPlaylist { input: fpl.input, source: fpl.clone_source(), @@ -664,7 +664,7 @@ fn execute_pipe<'a>(target: &ConfigTarget, pipe: &ProcessingPipe, fpl: &FetchedP } for f in pipe { - if let Some(groups) = f(new_fpl.source.as_mut(), target, interner) { + if let Some(groups) = f(new_fpl.source.as_mut(), target) { new_fpl.source = MemoryPlaylistSource::new(groups).boxed(); } } @@ -703,9 +703,8 @@ fn flatten_groups(playlistgroups: Vec) -> Vec { async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, playlists: &mut [FetchedPlaylist<'_>], target: &ConfigTarget, - stats: &mut HashMap, + stats: &mut HashMap, InputStats>, errors: &mut Vec, - interner: &mut StringInterner, ) -> Result<(), Vec> { debug_if_enabled!("Processing order is {}", &target.processing_order); @@ -719,9 +718,9 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, let mut step = StepMeasure::new(&target.name, broadcast_step); for provider_fpl in playlists.iter_mut() { step.broadcast("Executing transformations on '{}' playlist", &target.name); - let mut processed_fpl = execute_pipe(target, &pipe, provider_fpl, &mut duplicates, interner); + let mut processed_fpl = execute_pipe(target, &pipe, provider_fpl, &mut duplicates); processed_fpl.sort_by_provider_ordinal(); - playlist_resolve_series(&ctx.config, &ctx.client, target, errors, &pipe, provider_fpl, &mut processed_fpl, interner).await; + playlist_resolve_series(&ctx.config, &ctx.client, target, errors, &pipe, provider_fpl, &mut processed_fpl).await; playlist_resolve_vod(&ctx.config, &ctx.client, target, errors, provider_fpl, &mut processed_fpl).await; // stats let input_entry_name = processed_fpl.input.name.clone(); @@ -767,7 +766,7 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, if process_watch(&config, &ctx.client, target, &flat_new_playlist).await { step.tick("group watches"); } - let result = persist_playlist(&ctx.config, &mut flat_new_playlist, flatten_tvguide(&new_epg).as_ref(), target, ctx.playlist_state.as_ref(), interner).await; + let result = persist_playlist(&ctx.config, &mut flat_new_playlist, flatten_tvguide(&new_epg).as_ref(), target, ctx.playlist_state.as_ref()).await; step.stop("Persisting playlists"); result } @@ -866,7 +865,7 @@ async fn process_watch(cfg: &Config, client: &reqwest::Client, target: &ConfigTa pub async fn exec_processing(client: &reqwest::Client, app_config: Arc, targets: Arc, event_manager: Option>, playlist_state: Option>, update_guard: Option) { - let _guard = if let Some(guard) = update_guard { + let _guard = if let Some(guard) = update_guard { if let Some(permit) = guard.try_playlist() { Some(permit) } else { @@ -936,6 +935,8 @@ pub async fn exec_processing(client: &reqwest::Client, app_config: Arc>>(); let sorted = channels .into_iter() @@ -340,7 +341,7 @@ mod tests { .enumerate() .map(|(i, name)| PlaylistItem { header: PlaylistItemHeader { - title: name.to_string(), + title: name.to_string().into(), source_ordinal: i as u32, ..Default::default() }, @@ -404,7 +405,7 @@ mod tests { "US| East d", "US| West e", "US| West f", - ]; + ].into_iter().map(Into::into).collect::>>(); let sorted = channels .into_iter() diff --git a/backend/src/processing/processor/trakt.rs b/backend/src/processing/processor/trakt.rs index 20db6845d..a7ead6731 100644 --- a/backend/src/processing/processor/trakt.rs +++ b/backend/src/processing/processor/trakt.rs @@ -4,11 +4,10 @@ use crate::utils::{extract_year_from_title, normalize_title_for_matching, TraktC use crate::utils::{trace_if_enabled, with}; use log::{debug, info, trace, warn}; use shared::error::TuliproxError; -use shared::utils::StringInterner; use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster}; -use shared::utils::CONSTANTS; +use shared::utils::{Internable, CONSTANTS}; use indexmap::IndexMap; -use std::borrow::Cow; +use std::sync::Arc; use strsim::normalized_levenshtein; fn extract_quality(value: &str) -> Option<&str> { @@ -130,7 +129,6 @@ fn find_best_match_for_item<'a>( fn create_category_from_matches<'a>( matches: Vec>, list_config: &'a TraktListConfig, - interner: &mut StringInterner, ) -> Vec { if matches.is_empty() { return vec![]; } @@ -147,12 +145,12 @@ fn create_category_from_matches<'a>( )) }); - let group_title = &list_config.category_name; + let group_title = list_config.category_name.as_str().intern(); for match_result in sorted_matches { let mut modified_item = match_result.playlist_item.clone(); with!(mut modified_item.header => header { - let title = header.get_field("caption").unwrap_or_else(|| Cow::Borrowed(&header.title)); + let title = header.get_field("caption").unwrap_or_else(|| Arc::clone(&header.title)); if extract_quality(&title).is_none() { if let Some(quality) = extract_quality(&header.group) { let mut caption = String::with_capacity(title.len() + 6); @@ -163,7 +161,7 @@ fn create_category_from_matches<'a>( header.set_field("caption", &caption); } } - header.group = interner.intern(group_title); + header.group = group_title.clone(); header.gen_uuid(); matched_items_by_cluster.entry(header.xtream_cluster).or_default().push(modified_item); }); @@ -172,7 +170,7 @@ fn create_category_from_matches<'a>( matched_items_by_cluster.into_iter().map(|(cluster, channels)| { PlaylistGroup { id: 0, - title: interner.intern(group_title), + title: group_title.clone(), channels, xtream_cluster: cluster, } @@ -183,7 +181,6 @@ fn match_trakt_items_with_playlist<'a>( trakt_items: &'a [TraktListItem], playlist: &'a [PlaylistGroup], list_config: &'a TraktListConfig, - interner: &mut StringInterner, ) -> Vec { let trakt_match_items: Vec> = trakt_items .iter() @@ -207,7 +204,7 @@ fn match_trakt_items_with_playlist<'a>( } } - create_category_from_matches(matches, list_config, interner) + create_category_from_matches(matches, list_config) } pub struct TraktCategoriesProcessor { @@ -234,7 +231,6 @@ impl TraktCategoriesProcessor { info!("Processing {} Trakt lists for target {}", trakt_config.lists.len(), target.name); let mut new_categories = Vec::new(); let mut total_matches = 0; - let mut interner = StringInterner::new(); for list_config in &trakt_config.lists { let cache_key = format!("{}:{}", list_config.user, list_config.list_slug); @@ -243,7 +239,7 @@ impl TraktCategoriesProcessor { Ok(trakt_items) => { debug!("Processing Trakt list {cache_key} with {} items", trakt_items.len()); - let categories = match_trakt_items_with_playlist(&trakt_items, playlist, list_config, &mut interner); + let categories = match_trakt_items_with_playlist(&trakt_items, playlist, list_config); for category in categories { if !category.channels.is_empty() { total_matches += category.channels.len(); @@ -275,6 +271,9 @@ pub async fn process_trakt_categories_for_target( debug!("No Trakt configuration found for target {}", target.name); return Ok(None); }; + if !trakt_config.enabled { + return Ok(None); + } let processor = TraktCategoriesProcessor::new(http_client, trakt_config); processor.process_trakt_categories(playlist, target, trakt_config).await diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index 3a10d34f1..90229d6d9 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -11,7 +11,6 @@ use log::{error, info, log_enabled, Level}; use shared::error::TuliproxError; use shared::model::{InputType, PlaylistEntry, SeriesStreamProperties, StreamProperties, XtreamSeriesInfo}; use shared::model::{PlaylistGroup, PlaylistItemType, XtreamCluster}; -use shared::utils::StringInterner; use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; @@ -25,23 +24,27 @@ async fn playlist_resolve_series_info(app_config: &Arc, client: &reqw errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>, resolve_series: bool, - resolve_delay: u16, - interner: &mut StringInterner) -> Vec { + resolve_delay: u16) -> Vec { + let input = fpl.input; let working_dir = &app_config.config.load().working_dir; let storage_path = match get_input_storage_path(&input.name, working_dir) { Ok(storage_path) => storage_path, Err(err) => { - error!("Can't resolve vod, input storage directory for input '{}' failed: {err}", input.name); + error!("Can't resolve series info, input storage directory for input '{}' failed: {err}", input.name); return vec![]; } }; - let series_info_count = fpl.get_missing_series_info_count(); - - if series_info_count > 0 { - info!("Found {series_info_count} series info to resolve"); - } + let series_info_count = if resolve_series { + let series_info_count = fpl.get_missing_series_info_count(); + if series_info_count > 0 { + info!("Found {series_info_count} series info to resolve"); + } + series_info_count + } else { + 0 + }; let mut last_log_time = Instant::now(); let mut processed_series_info_count = 0; @@ -94,7 +97,7 @@ async fn playlist_resolve_series_info(app_config: &Arc, client: &reqw let header = &pli.header; (header.group.clone(), if header.name.is_empty() { header.title.clone() } else { header.name.clone() }) }; - if let Some(episodes) = parse_xtream_series_info(&pli.get_uuid(), properties, &group, &series_name, input, interner) { + if let Some(episodes) = parse_xtream_series_info(&pli.get_uuid(), properties, &group, &series_name, input) { let group = group_series.entry(pli.header.category_id) .or_insert_with(|| { PlaylistGroup { @@ -134,12 +137,11 @@ pub async fn playlist_resolve_series(cfg: &Arc, pipe: &ProcessingPipe, provider_fpl: &mut FetchedPlaylist<'_>, processed_fpl: &mut FetchedPlaylist<'_>, - interner: &mut StringInterner, ) { let (resolve_series, resolve_delay) = get_resolve_series_options(target, processed_fpl); provider_fpl.source.release_resources(XtreamCluster::Series); - let series_playlist = playlist_resolve_series_info(cfg, client, errors, processed_fpl, resolve_series, resolve_delay, interner).await; + let series_playlist = playlist_resolve_series_info(cfg, client, errors, processed_fpl, resolve_series, resolve_delay).await; provider_fpl.source.obtain_resources().await; if series_playlist.is_empty() { return; } @@ -153,7 +155,7 @@ pub async fn playlist_resolve_series(cfg: &Arc, let mut new_playlist = series_playlist; for f in pipe { let mut source = MemoryPlaylistSource::new(new_playlist); - if let Some(v) = f(&mut source, target, interner) { + if let Some(v) = f(&mut source, target) { new_playlist = v; } else { new_playlist = source.take_groups(); diff --git a/backend/src/repository/bplustree.rs b/backend/src/repository/bplustree.rs index cf01b39c9..ac7eb7ee8 100644 --- a/backend/src/repository/bplustree.rs +++ b/backend/src/repository/bplustree.rs @@ -1,8 +1,10 @@ +use crate::repository::storage::get_file_path_for_db_index; use crate::utils; use crate::utils::{binary_deserialize, binary_serialize}; -use fs2::FileExt; +use fs2::FileExt as _; use indexmap::IndexMap; use log::error; +use memmap2::Mmap; use serde::{Deserialize, Serialize}; use shared::error::{string_to_io_error, to_io_error}; use std::ffi::OsString; @@ -10,9 +12,10 @@ use std::fs::{File, OpenOptions}; use std::io::{self, BufReader, Read, Seek, SeekFrom, Write}; use std::marker::PhantomData; use std::mem::size_of; +#[cfg(unix)] +use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; -use crate::repository::storage::get_file_path_for_db_index; // Constants (Restored) const PAGE_SIZE: u16 = 4096; @@ -23,6 +26,9 @@ const MAGIC: &[u8; 4] = b"BTRE"; const STORAGE_VERSION: u32 = 1; const HEADER_SIZE: u64 = PAGE_SIZE as u64; const ROOT_OFFSET_POS: u64 = 8; +const METADATA_OFFSET_POS: u64 = 16; +// Reserve space for metadata (e.g. 4096 - 16 = 4080 bytes max, but let's be safe) +const METADATA_MAX_SIZE: usize = 4000; const POINTER_SIZE: usize = 8; const INFO_SIZE: usize = 12; // (u64, u32) @@ -49,6 +55,8 @@ const PAGE_HEADER_SIZE: u16 = 16; const PAGE_HEADER_SIZE_USIZE: usize = PAGE_HEADER_SIZE as usize; const SLOT_SIZE: usize = 2; // u16 +const MAGIC_METADATA_TARGET_ID_MAPPING: u8 = 0x01; + /* Page Header Layout @@ -1140,7 +1148,8 @@ where file.read_exact(&mut buffer[0..header_required])?; let is_leaf = buffer[0] != 0; - let keys_len = u32_from_bytes(&buffer[FLAG_SIZE..=LEN_SIZE])? as usize; + #[allow(clippy::range_plus_one)] + let keys_len = u32_from_bytes(&buffer[FLAG_SIZE..FLAG_SIZE + LEN_SIZE])? as usize; let min_required = header_required + keys_len + LEN_SIZE; if buffer.len() < min_required { @@ -1149,7 +1158,7 @@ where file.read_exact(&mut buffer[header_required..min_required])?; - let mut read_pos = FLAG_SIZE + LEN_SIZE; + let mut read_pos = header_required; let keys: Vec = binary_deserialize(&buffer[read_pos..read_pos + keys_len])?; read_pos += keys_len; @@ -1194,6 +1203,47 @@ where Ok((Self { keys, children, is_leaf, value_info, values }, children_pointer)) } + fn deserialize_from_mmap( + mmap: &[u8], + file: &mut R, + offset: u64, + nested: bool, + ) -> io::Result<(Self, Option>)> { + let start = usize::try_from(offset).map_err(to_io_error)?; + let header_end = start.checked_add(FLAG_SIZE + LEN_SIZE) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "Mmap offset overflow"))?; + // Basic safety check for mmap bounds + if header_end > mmap.len() { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "Mmap access out of bounds")); + } + + let keys_len = u32_from_bytes(&mmap[start + FLAG_SIZE..start + FLAG_SIZE + LEN_SIZE])? as usize; + let keys_start = header_end; + let len_pos = keys_start + .checked_add(keys_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "Mmap offset overflow"))?; + + if len_pos + LEN_SIZE > mmap.len() { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "Mmap access out of bounds")); + } + let payload_len = u32_from_bytes(&mmap[len_pos..len_pos + LEN_SIZE])? as usize; + let total = len_pos + .checked_add(LEN_SIZE + payload_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "Mmap offset overflow"))?; + + if total > mmap.len() { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "Mmap access out of bounds")); + } + + // We need to know the total size of the node to slice the mmap + // For simplicity, we can just slice a PAGE_SIZE or slightly more if we know it overflows. + // Actually, our serialize_to_block uses PAGE_SIZE blocks. + + //let slice = &mmap[start..]; + let slice = &mmap[start..total]; + Self::deserialize_from_block_slice(slice, file, nested) + } + fn deserialize_from_block_slice( slice: &[u8], file: &mut R, @@ -1345,12 +1395,44 @@ where binary_deserialize(&data) } } +// ----------------------------------------------------------------------------- +// Metadata Enum +// ----------------------------------------------------------------------------- +#[derive(Clone, Debug, PartialEq)] +pub enum BPlusTreeMetadata { + Empty, + TargetIdMapping(u32), +} + +impl BPlusTreeMetadata { + pub fn to_bytes(&self) -> Vec { + match self { + Self::Empty => Vec::new(), + Self::TargetIdMapping(val) => { + let mut bytes = vec![MAGIC_METADATA_TARGET_ID_MAPPING]; // Type tag + bytes.extend_from_slice(&val.to_le_bytes()); + bytes + } + } + } + + pub fn from_bytes(bytes: &[u8]) -> Self { + match bytes.len() { + 5 if bytes[0] == MAGIC_METADATA_TARGET_ID_MAPPING => { + let arr: [u8; 4] = bytes[1..5].try_into().unwrap_or([0; 4]); + Self::TargetIdMapping(u32::from_le_bytes(arr)) + } + _ => Self::Empty, // Unknown metadata treated as Empty for now + } + } +} #[derive(Debug, Clone)] pub struct BPlusTree { root: BPlusTreeNode, inner_order: usize, leaf_order: usize, + metadata: BPlusTreeMetadata, dirty: bool, } @@ -1391,10 +1473,22 @@ where root: BPlusTreeNode::::new(true), inner_order, leaf_order, + metadata: BPlusTreeMetadata::Empty, dirty: true, // an empty tree is stored! } } + /// Helper to access metadata + pub fn get_metadata(&self) -> &BPlusTreeMetadata { + &self.metadata + } + + /// Helper to set metadata + pub fn set_metadata(&mut self, data: BPlusTreeMetadata) { + self.metadata = data; + self.dirty = true; + } + pub fn is_empty(&self) -> bool { self.root.keys.is_empty() } @@ -1476,7 +1570,7 @@ where pub fn store_index(filepath: &Path, sort_key_extractor: F) -> io::Result<()> where SortKey: Ord + Serialize, - F: Fn(&V) -> SortKey + F: Fn(&V) -> SortKey, { let index_path = get_file_path_for_db_index(filepath); @@ -1515,8 +1609,22 @@ where header[4..8].copy_from_slice(&STORAGE_VERSION.to_le_bytes()); // Placeholder for root offset, will be updated after serialization header[8..16].copy_from_slice(&HEADER_SIZE.to_le_bytes()); + + let meta_bytes = self.metadata.to_bytes(); + if meta_bytes.len() > METADATA_MAX_SIZE || 20 + meta_bytes.len() > PAGE_SIZE_USIZE { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "Metadata too large for header page")); + } + if !meta_bytes.is_empty() { + let len = u32::try_from(meta_bytes.len()).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + header[16..20].copy_from_slice(&len.to_le_bytes()); + header[20..20 + meta_bytes.len()].copy_from_slice(&meta_bytes); + } + file.write_all(&header)?; + // We need to ensure we pad to PAGE_SIZE before continuing + file.seek(SeekFrom::Start(HEADER_SIZE))?; + // Use breadth-first serialization for better disk locality match self.root.serialize_breadth_first(&mut file, &mut buffer, HEADER_SIZE) { Ok(result) => { @@ -1535,79 +1643,37 @@ where /// Bulk build a tree from pre-calculated `ValueInfos` (streaming compact helper). /// Writes nodes to `file` starting at `start_offset`. /// Returns the offset of the root node used to update the file header. - fn bulk_build_from_infos( - &mut self, + fn build_levels_from_pointers( + &self, file: &mut W, - items: &[(K, ValueInfo)], - start_offset: u64, + mut next_level_pointers: Vec<(K, u64)>, + mut current_offset: u64, + write_buffer: &mut [u8], ) -> io::Result { - if items.is_empty() { - // Write empty root - let root = BPlusTreeNode::::new(true); - let mut buffer = Vec::with_capacity(PAGE_SIZE_USIZE); - let _offset = root.serialize_to_block(file, &mut buffer, start_offset)?; - // For a single empty root, the root offset is the start offset. - // serialize_to_block returns the *end* offset (start of next block). - // But header needs the *start* of the root node. - // Wait - serialize_to_block logic: - // "Returns the offset of the NEXT block" - // So the root IS at start_offset. - return Ok(start_offset); + if next_level_pointers.is_empty() { + return Ok(current_offset); } - // 1. Sort items (Query iterator is usually sorted, but let's be safe or rely on caller) - // Since BPlusTreeQuery works on offsets, order might be physical not logical if we iterate blocks? - // BPlusTreeQuery::iter() uses range_scan which traverses logical order. So it IS sorted! - - let mut current_offset = start_offset; - let mut write_buffer = Vec::with_capacity(PAGE_SIZE_USIZE); - - // 2. Build Leaves - // Chunk items into leaf nodes - let mut next_level_pointers: Vec<(K, u64)> = Vec::new(); - - for chunk in items.chunks(self.leaf_order) { - let mut node = BPlusTreeNode::::new(true); - for (k, v) in chunk { - node.keys.push(k.clone()); - node.value_info.push(v.clone()); - } - - let node_offset = current_offset; - current_offset = node.serialize_to_block(file, &mut write_buffer, node_offset)?; - - if !node.keys.is_empty() { - next_level_pointers.push((node.keys[0].clone(), node_offset)); - } - } - - // 3. Build Internal Levels recursively while next_level_pointers.len() > 1 { let mut parent_level_pointers: Vec<(K, u64)> = Vec::new(); - let children = next_level_pointers; // Move + let children = next_level_pointers; - // Chunk size: `inner_order + 1` (pointers). for chunk in children.chunks(self.inner_order + 1) { let mut node = BPlusTreeNode::::new(false); let mut pointers = Vec::new(); - // First pointer if let Some((_, off)) = chunk.first() { pointers.push(*off); } - // Subsequent pointers and separators for (k, off) in &chunk[1..] { node.keys.push(k.clone()); pointers.push(*off); } let node_offset = current_offset; + current_offset = node.serialize_internal_with_offsets(file, write_buffer, node_offset, &pointers)?; - // Use serialize_internal_with_offsets - current_offset = node.serialize_internal_with_offsets(file, &mut write_buffer, node_offset, &pointers)?; - - // Track for upper level if let Some((k, _)) = chunk.first() { parent_level_pointers.push((k.clone(), node_offset)); } @@ -1615,19 +1681,19 @@ where next_level_pointers = parent_level_pointers; } - // 4. Return Root Offset if let Some((_, root_off)) = next_level_pointers.first() { Ok(*root_off) } else { - Ok(start_offset) + Ok(current_offset) } } + pub fn load(filepath: &Path) -> io::Result { let mut file = File::open(filepath)?; // Verify Header - let mut header = [0u8; 16]; + let mut header = [0u8; PAGE_SIZE_USIZE]; // Read the whole header page file.read_exact(&mut header)?; if &header[0..4] != MAGIC { return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid magic number")); @@ -1638,6 +1704,17 @@ where } let root_offset = u64::from_le_bytes(header[8..16].try_into().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid root offset slice"))?); + // Read metadata + let metadata_len = u32::from_le_bytes(header[16..20].try_into().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid metadata length slice"))?); + let metadata = if metadata_len > 0 { + if (20 + metadata_len as usize) > PAGE_SIZE_USIZE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Metadata length exceeds header page size")); + } + header[20..(20 + metadata_len as usize)].to_vec() + } else { + Vec::new() + }; + let mut reader = utils::file_reader(file); let mut buffer = vec![0u8; PAGE_SIZE_USIZE]; // Start after header block @@ -1648,6 +1725,7 @@ where root, inner_order, leaf_order, + metadata: BPlusTreeMetadata::from_bytes(&metadata), dirty: false, }) } @@ -1683,25 +1761,18 @@ where let mut offset = start_offset; loop { // Try Cache First - let (node, pointers) = if let Some(cached_block) = cache.get_mut(&offset) { - // Move to end (LRU) - let data = cached_block.clone(); - // We use a fresh buffer for the deserializer to avoid borrowing conflicts - let temp_buffer = data; - let res = BPlusTreeNode::::deserialize_from_block_slice(&temp_buffer, file, false) + let (node, pointers) = if let Some(data) = cache.shift_remove(&offset) { + // LRU: Use shift_remove + insert to avoid cloning or repeated lookups + let res = BPlusTreeNode::::deserialize_from_block_slice(&data, file, false) .map_err(BPlusTreeError::from)?; - - // Re-insert to mark as MRU - if let Some(removed) = cache.shift_remove(&offset) { - cache.insert(offset, removed); - } + cache.insert(offset, data); res } else { // Disk Read match BPlusTreeNode::::deserialize_from_block(file, buffer, offset, false) { Ok((node, pointers)) => { // Update Cache - if cache.len() >= CACHE_CAPACITY { // Cap at ~4MB of blocks + if cache.len() >= CACHE_CAPACITY { cache.shift_remove_index(0); } cache.insert(offset, buffer.to_owned()); @@ -1755,17 +1826,18 @@ where { let mut offset = start_offset; loop { - let (node, pointers) = if let Some(cached_block) = cache.get_mut(&offset) { - let temp_buffer = cached_block.clone(); - let res = BPlusTreeNode::::deserialize_from_block_slice(&temp_buffer, file, false) + // Try Cache First + let (node, pointers) = if let Some(data) = cache.shift_remove(&offset) { + // LRU + let res = BPlusTreeNode::::deserialize_from_block_slice(&data, file, false) .map_err(BPlusTreeError::from)?; - if let Some(removed) = cache.shift_remove(&offset) { - cache.insert(offset, removed); - } + cache.insert(offset, data); res } else { + // Disk Read match BPlusTreeNode::::deserialize_from_block(file, buffer, offset, false) { Ok((node, pointers)) => { + // Update Cache if cache.len() >= CACHE_CAPACITY { cache.shift_remove_index(0); } @@ -1820,14 +1892,10 @@ where let mut count = 0; let mut stack = vec![start_offset]; while let Some(offset) = stack.pop() { - let (node, pointers) = if let Some(cached_block) = cache.get_mut(&offset) { - let data = cached_block.clone(); - let temp_buffer = data; - let res = BPlusTreeNode::::deserialize_from_block_slice(&temp_buffer, file, false) + let (node, pointers) = if let Some(data) = cache.shift_remove(&offset) { + let res = BPlusTreeNode::::deserialize_from_block_slice(&data, file, false) .map_err(BPlusTreeError::from)?; - if let Some(removed) = cache.shift_remove(&offset) { - cache.insert(offset, removed); - } + cache.insert(offset, data); res } else { match BPlusTreeNode::::deserialize_from_block(file, buffer, offset, false) { @@ -1851,12 +1919,119 @@ where Ok(count) } +fn count_items_mmap( + mmap: &[u8], + start_offset: u64, +) -> Result +where + K: Ord + Serialize + for<'de> Deserialize<'de> + Clone, + V: Serialize + for<'de> Deserialize<'de> + Clone, +{ + let mut count = 0; + let mut stack = vec![start_offset]; + let mut cursor = io::Cursor::new(mmap); + while let Some(offset) = stack.pop() { + let (node, pointers) = BPlusTreeNode::::deserialize_from_mmap(mmap, &mut cursor, offset, false)?; + + if node.is_leaf { + count += node.keys.len(); + } else if let Some(ptrs) = pointers { + stack.extend(ptrs); + } + } + Ok(count) +} + +fn query_tree_mmap( + mmap: &[u8], + cursor: &mut io::Cursor<&[u8]>, + key: &K, + start_offset: u64, +) -> Result, BPlusTreeError> +where + K: Ord + Serialize + for<'de> Deserialize<'de> + Clone, + V: Serialize + for<'de> Deserialize<'de> + Clone, +{ + let mut offset = start_offset; + loop { + let (node, pointers) = BPlusTreeNode::::deserialize_from_mmap(mmap, cursor, offset, false)?; + + if node.is_leaf { + return match node.keys.binary_search(key) { + Ok(idx) => { + match node.value_info.get(idx) { + Some(info) => { + let value = BPlusTreeNode::::load_value_from_info(cursor, info)?; + Ok(Some(value)) + } + None => Ok(None), + } + } + Err(_) => Ok(None), + }; + } + + let child_idx = get_entry_index_upper_bound::(&node.keys, key); + if let Some(child_offsets) = pointers { + if let Some(child_offset) = child_offsets.get(child_idx) { + offset = *child_offset; + } else { + return Ok(None); + } + } else { + return Ok(None); + } + } +} + +fn query_tree_le_mmap( + mmap: &[u8], + cursor: &mut io::Cursor<&[u8]>, + key: &K, + start_offset: u64, +) -> Result, BPlusTreeError> +where + K: Ord + Serialize + for<'de> Deserialize<'de> + Clone, + V: Serialize + for<'de> Deserialize<'de> + Clone, +{ + let mut offset = start_offset; + loop { + let (node, pointers) = BPlusTreeNode::::deserialize_from_mmap(mmap, cursor, offset, false)?; + + if node.is_leaf { + let idx = get_entry_index_upper_bound::(&node.keys, key); + if idx == 0 { + return Ok(None); + } + return match node.value_info.get(idx - 1) { + Some(info) => { + let value = BPlusTreeNode::::load_value_from_info(cursor, info)?; + Ok(Some(value)) + } + None => Ok(None), + }; + } + + let child_idx = get_entry_index_upper_bound::(&node.keys, key); + if let Some(child_offsets) = pointers { + if let Some(child_offset) = child_offsets.get(child_idx) { + offset = *child_offset; + } else { + return Ok(None); + } + } else { + return Ok(None); + } + } +} + /// `BPlusTreeQuery` performs on-disk queries without loading the entire tree into memory. /// For frequent queries, consider using `BPlusTree::load()` instead, which loads the full tree into memory /// at the cost of higher memory usage. pub struct BPlusTreeQuery { - file: BufReader, + file: Option>, + mmap: Option, filepath: PathBuf, buffer: Vec, cache: IndexMap>, @@ -1870,12 +2045,28 @@ where K: Ord + Serialize + for<'de> Deserialize<'de> + Clone, V: Serialize + for<'de> Deserialize<'de> + Clone, { - pub fn try_from_file(mut file: File) -> io::Result { + pub fn try_from_file(file: File) -> io::Result { + let metadata = file.metadata()?; + let file_len = metadata.len(); + + if file_len < HEADER_SIZE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "File too small")); + } + + // Try Mmap + let mmap = unsafe { Mmap::map(&file).ok() }; // Verify Header let mut header = [0u8; 16]; - file.seek(SeekFrom::Start(0))?; - file.read_exact(&mut header)?; + #[cfg(unix)] + file.read_exact_at(&mut header, 0)?; + #[cfg(not(unix))] + { + let mut f = &file; + f.seek(SeekFrom::Start(0))?; + f.read_exact(&mut header)?; + } + if &header[0..4] != MAGIC { return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid magic number")); } @@ -1886,8 +2077,9 @@ where let root_offset = u64::from_le_bytes(header[8..16].try_into().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid root offset slice"))?); Ok(Self { - file: utils::file_reader(file), - filepath: PathBuf::new(), // Unknown when created from file directly + file: if mmap.is_some() { None } else { Some(utils::file_reader(file)) }, + mmap, + filepath: PathBuf::new(), buffer: vec![0u8; PAGE_SIZE_USIZE], cache: IndexMap::with_capacity(CACHE_CAPACITY), root_offset, @@ -1909,25 +2101,47 @@ where } pub fn query(&mut self, key: &K) -> Result, BPlusTreeError> { - query_tree(&mut self.file, &mut self.buffer, &mut self.cache, key, self.root_offset) - } - - pub fn is_empty(&mut self) -> Result { - let (node, _) = BPlusTreeNode::::deserialize_from_block( - &mut self.file, - &mut self.buffer, - self.root_offset, - false, - )?; - Ok(node.is_leaf && node.keys.is_empty()) - } - - pub fn len(&mut self) -> Result { - count_items::(&mut self.file, &mut self.buffer, &mut self.cache, self.root_offset) + if let Some(mmap) = &self.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + query_tree_mmap(mmap, &mut cursor, key, self.root_offset) + } else if let Some(file) = &mut self.file { + query_tree(file, &mut self.buffer, &mut self.cache, key, self.root_offset) + } else { + Err(BPlusTreeError::InvalidStructure("No data source available".into())) + } } pub fn query_le(&mut self, key: &K) -> Result, BPlusTreeError> { - query_tree_le(&mut self.file, &mut self.buffer, &mut self.cache, key, self.root_offset) + if let Some(mmap) = &self.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + query_tree_le_mmap(mmap, &mut cursor, key, self.root_offset) + } else if let Some(file) = &mut self.file { + query_tree_le(file, &mut self.buffer, &mut self.cache, key, self.root_offset) + } else { + Err(BPlusTreeError::InvalidStructure("No data source available".into())) + } + } + + pub fn len(&mut self) -> Result { + if let Some(mmap) = &self.mmap { + count_items_mmap::(mmap, self.root_offset) + } else if let Some(file) = &mut self.file { + count_items::(file, &mut self.buffer, &mut self.cache, self.root_offset) + } else { + Err(BPlusTreeError::InvalidStructure("No data source available".into())) + } + } + + pub fn is_empty(&mut self) -> Result { + let (node, _) = if let Some(mmap) = &self.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + BPlusTreeNode::::deserialize_from_mmap(mmap, &mut cursor, self.root_offset, false)? + } else if let Some(file) = &mut self.file { + BPlusTreeNode::::deserialize_from_block(file, &mut self.buffer, self.root_offset, false)? + } else { + return Err(BPlusTreeError::InvalidStructure("No data source available".into())); + }; + Ok(node.is_leaf && node.keys.is_empty()) } @@ -1935,9 +2149,9 @@ where pub fn iter(&mut self) -> BPlusTreeDiskIterator<'_, K, V> { BPlusTreeDiskIterator::new(self) } - + /// Owned iterator that traverses the tree in order defined by a secondary sorted index. - /// + /// /// The index path is automatically derived from the tree filepath by changing /// the extension to `.idx`. For example, if the tree is at `/data/items.bin`, /// the index is expected at `/data/items.idx`. @@ -1948,7 +2162,7 @@ where where SortKey: for<'de> Deserialize<'de>, { - super::sorted_index::BPlusTreeSortedIteratorOwned::new(self.filepath.clone(), self.file) + super::sorted_index::BPlusTreeSortedIteratorOwned::::new_hybrid(self.filepath.clone(), self.file, self.mmap) } /// Owned iterator with explicit index path. @@ -1956,7 +2170,7 @@ where where SortKey: for<'de> Deserialize<'de>, { - super::sorted_index::BPlusTreeSortedIteratorOwned::with_index_path(self.filepath.clone(), self.file, index_path) + super::sorted_index::BPlusTreeSortedIteratorOwned::::with_index_path_hybrid(self.filepath.clone(), self.file, self.mmap, index_path) } /// Traverses the tree and calls the provided closure for each leaf's keys and values. @@ -1978,43 +2192,44 @@ where pub fn collect_with_locations(&mut self) -> io::Result> { let mut result = Vec::new(); let mut stack = vec![self.root_offset]; - + while let Some(offset) = stack.pop() { - let (node, pointers) = BPlusTreeNode::::deserialize_from_block( - &mut self.file, - &mut self.buffer, - offset, - false, - )?; - + let (node, pointers) = if let Some(mmap) = &self.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + BPlusTreeNode::::deserialize_from_mmap(mmap, &mut cursor, offset, false)? + } else if let Some(file) = &mut self.file { + BPlusTreeNode::::deserialize_from_block(file, &mut self.buffer, offset, false)? + } else { + return Err(io::Error::other("No data source available")); + }; + if node.is_leaf { - for (key, info) in node.keys.into_iter().zip(node.value_info.iter()) { - let value = BPlusTreeNode::::load_value_from_info(&mut self.file, info)?; - let location = match info.mode { - ValueStorageMode::Single(offset) => { - super::sorted_index::ValueLocation::Single { - offset, - length: info.length, - } - } - ValueStorageMode::Packed(block_offset, index) => { - super::sorted_index::ValueLocation::Packed { - block_offset, - index, - length: info.length, - } - } - }; - result.push((key, value, location)); + if let Some(mmap) = &self.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + for (key, info) in node.keys.into_iter().zip(node.value_info.iter()) { + let value = BPlusTreeNode::::load_value_from_info(&mut cursor, info)?; + let location = match info.mode { + ValueStorageMode::Single(offset) => super::sorted_index::ValueLocation::Single { offset, length: info.length }, + ValueStorageMode::Packed(block_offset, index) => super::sorted_index::ValueLocation::Packed { block_offset, index, length: info.length }, + }; + result.push((key, value, location)); + } + } else if let Some(file) = &mut self.file { + for (key, info) in node.keys.into_iter().zip(node.value_info.iter()) { + let value = BPlusTreeNode::::load_value_from_info(file, info)?; + let location = match info.mode { + ValueStorageMode::Single(offset) => super::sorted_index::ValueLocation::Single { offset, length: info.length }, + ValueStorageMode::Packed(block_offset, index) => super::sorted_index::ValueLocation::Packed { block_offset, index, length: info.length }, + }; + result.push((key, value, location)); + } } } else if let Some(ptrs) = pointers { - // Process children in reverse order so we pop them in correct order for ptr in ptrs.into_iter().rev() { stack.push(ptr); } } } - Ok(result) } @@ -2022,7 +2237,6 @@ where pub fn disk_iter(self) -> BPlusTreeDiskIteratorOwned { BPlusTreeDiskIteratorOwned::new(self) } - } @@ -2058,18 +2272,28 @@ where loop { let Some((offset, child_idx)) = self.stack.pop() else { return Ok(None) }; - let (node, pointers) = BPlusTreeNode::::deserialize_from_block( - &mut self.query.file, - &mut self.query.buffer, - offset, - false, - )?; + let (node, pointers) = if let Some(mmap) = &self.query.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + BPlusTreeNode::::deserialize_from_mmap(mmap, &mut cursor, offset, false)? + } else if let Some(file) = &mut self.query.file { + BPlusTreeNode::::deserialize_from_block(file, &mut self.query.buffer, offset, false)? + } else { + return Err(io::Error::other("No data source available")); + }; if node.is_leaf { let mut vals = Vec::with_capacity(node.value_info.len()); - for value_info in &node.value_info { - let v = BPlusTreeNode::::load_value_from_info(&mut self.query.file, value_info)?; - vals.push(v); + if let Some(mmap) = &self.query.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + for value_info in &node.value_info { + let v = BPlusTreeNode::::load_value_from_info(&mut cursor, value_info)?; + vals.push(v); + } + } else if let Some(file) = &mut self.query.file { + for value_info in &node.value_info { + let v = BPlusTreeNode::::load_value_from_info(file, value_info)?; + vals.push(v); + } } return Ok(Some((node.keys, vals))); } else if let Some(pters) = pointers { @@ -2165,18 +2389,28 @@ where loop { let Some((offset, child_idx)) = self.stack.pop() else { return Ok(None) }; - let (node, pointers) = BPlusTreeNode::::deserialize_from_block( - &mut self.query.file, - &mut self.query.buffer, - offset, - false, - )?; + let (node, pointers) = if let Some(mmap) = &self.query.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + BPlusTreeNode::::deserialize_from_mmap(mmap, &mut cursor, offset, false)? + } else if let Some(file) = &mut self.query.file { + BPlusTreeNode::::deserialize_from_block(file, &mut self.query.buffer, offset, false)? + } else { + return Err(io::Error::other("No data source available")); + }; if node.is_leaf { let mut vals = Vec::with_capacity(node.value_info.len()); - for value_info in &node.value_info { - let v = BPlusTreeNode::::load_value_from_info(&mut self.query.file, value_info)?; - vals.push(v); + if let Some(mmap) = &self.query.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + for value_info in &node.value_info { + let v = BPlusTreeNode::::load_value_from_info(&mut cursor, value_info)?; + vals.push(v); + } + } else if let Some(file) = &mut self.query.file { + for value_info in &node.value_info { + let v = BPlusTreeNode::::load_value_from_info(file, value_info)?; + vals.push(v); + } } return Ok(Some((node.keys, vals))); } else if let Some(pters) = pointers { @@ -2223,7 +2457,7 @@ where } pub struct BPlusTreeUpdate { - file: File, + file: BufReader, read_buffer: Vec, write_buffer: Vec, cache: IndexMap>, @@ -2290,7 +2524,8 @@ where // Acquire lock first let lock = FileLock::try_lock(filepath)?; - let mut file = utils::open_read_write_file(filepath)?; + let file = utils::open_read_write_file(filepath)?; + let mut file = utils::file_reader(file); // Verify Header let mut header = [0u8; 16]; @@ -2311,14 +2546,51 @@ where write_buffer: vec![0u8; PAGE_SIZE_USIZE], cache: IndexMap::with_capacity(CACHE_CAPACITY), root_offset, - lock, inner_order, leaf_order, + lock, _marker_k: PhantomData, _marker_v: PhantomData, }) } + /// Helper to access metadata + pub fn get_metadata(&mut self) -> io::Result { + self.file.seek(SeekFrom::Start(METADATA_OFFSET_POS))?; + let mut len_buf = [0u8; 4]; + if self.file.read_exact(&mut len_buf).is_err() { + return Ok(BPlusTreeMetadata::Empty); + } + let len = u32::from_le_bytes(len_buf); + if len == 0 || len as usize > METADATA_MAX_SIZE { + return Ok(BPlusTreeMetadata::Empty); + } + let mut buf = vec![0u8; len as usize]; + self.file.read_exact(&mut buf)?; + + Ok(BPlusTreeMetadata::from_bytes(&buf)) + } + + /// Helper to set metadata + pub fn set_metadata(&mut self, data: &BPlusTreeMetadata) -> io::Result<()> { + let bytes = data.to_bytes(); + if bytes.len() > METADATA_MAX_SIZE { + return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("Metadata too large: {} > {}", bytes.len(), METADATA_MAX_SIZE))); + } + + self.file.seek(SeekFrom::Start(METADATA_OFFSET_POS))?; + { + let file = self.file.get_mut(); + file.write_all(&u32::try_from(bytes.len()).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?.to_le_bytes())?; + file.write_all(&bytes)?; + file.flush()?; // Ensure it hits disk + } + // Resync/clear reader buffer after direct writes. + self.file.seek_relative(0)?; // same as self.file.seek(SeekFrom::Current(0))?; + + Ok(()) + } + pub fn query(&mut self, key: &K) -> Result, BPlusTreeError> { let mut reader = utils::file_reader(&mut self.file); query_tree(&mut reader, &mut self.read_buffer, &mut self.cache, key, self.root_offset) @@ -2336,13 +2608,11 @@ where } pub fn len(&mut self) -> Result { - let mut reader = utils::file_reader(&mut self.file); - count_items::(&mut reader, &mut self.read_buffer, &mut self.cache, self.root_offset) + count_items::(&mut self.file, &mut self.read_buffer, &mut self.cache, self.root_offset) } pub fn query_le(&mut self, key: &K) -> Result, BPlusTreeError> { - let mut reader = utils::file_reader(&mut self.file); - query_tree_le(&mut reader, &mut self.read_buffer, &mut self.cache, key, self.root_offset) + query_tree_le(&mut self.file, &mut self.read_buffer, &mut self.cache, key, self.root_offset) } @@ -2351,10 +2621,10 @@ where let new_root_offset = self.update_batch_recursive(self.root_offset, &refs)?; // Atomic Header Swap - self.file.seek(SeekFrom::Start(ROOT_OFFSET_POS)).map_err(BPlusTreeError::Io)?; - self.file.write_all(&new_root_offset.to_le_bytes()).map_err(BPlusTreeError::Io)?; - self.file.flush().map_err(BPlusTreeError::Io)?; - self.file.sync_all().map_err(BPlusTreeError::Io)?; + self.file.get_mut().seek(SeekFrom::Start(ROOT_OFFSET_POS)).map_err(BPlusTreeError::Io)?; + self.file.get_mut().write_all(&new_root_offset.to_le_bytes()).map_err(BPlusTreeError::Io)?; + self.file.get_mut().flush().map_err(BPlusTreeError::Io)?; + self.file.get_mut().sync_all().map_err(BPlusTreeError::Io)?; self.root_offset = new_root_offset; Ok(new_root_offset) @@ -2368,8 +2638,7 @@ where offset: u64, items: &[(&K, &V)], ) -> Result { - let mut reader = utils::file_reader(&mut self.file); - let (mut node, pointers_opt) = BPlusTreeNode::::deserialize_from_block(&mut reader, &mut self.read_buffer, offset, false)?; + let (mut node, pointers_opt) = BPlusTreeNode::::deserialize_from_block(&mut self.file, &mut self.read_buffer, offset, false)?; if node.is_leaf { for (key, value) in items { @@ -2426,10 +2695,10 @@ where let new_root_offset = self.update_batch_recursive(self.root_offset, &sorted_items)?; // Atomic Header Swap - only once at the end - self.file.seek(SeekFrom::Start(ROOT_OFFSET_POS)).map_err(BPlusTreeError::Io)?; - self.file.write_all(&new_root_offset.to_le_bytes()).map_err(BPlusTreeError::Io)?; - self.file.flush().map_err(BPlusTreeError::Io)?; - self.file.sync_all().map_err(BPlusTreeError::Io)?; + self.file.get_mut().seek(SeekFrom::Start(ROOT_OFFSET_POS)).map_err(BPlusTreeError::Io)?; + self.file.get_mut().write_all(&new_root_offset.to_le_bytes()).map_err(BPlusTreeError::Io)?; + self.file.get_mut().flush().map_err(BPlusTreeError::Io)?; + self.file.get_mut().sync_all().map_err(BPlusTreeError::Io)?; self.root_offset = new_root_offset; Ok(new_root_offset) @@ -2446,13 +2715,13 @@ where // Decide whether to compress based on size and effectiveness let (flag, payload) = compress_if_beneficial(&raw_bytes); - self.file.seek(SeekFrom::End(0))?; - let offset = self.file.stream_position()?; + self.file.get_mut().seek(SeekFrom::End(0))?; + let offset = self.file.get_mut().stream_position()?; // Write: [flag:1][payload] - self.file.write_all(&[flag])?; + self.file.get_mut().write_all(&[flag])?; // NOTE: For LZ4, payload already includes original length (prepended) - self.file.write_all(&payload)?; + self.file.get_mut().write_all(&payload)?; // stored_len includes flag + payload let stored_len = 1 + payload.len(); @@ -2460,16 +2729,16 @@ where } fn write_node(&mut self, node: &BPlusTreeNode) -> io::Result { - self.file.seek(SeekFrom::End(0))?; - let offset = self.file.stream_position()?; - node.serialize_to_block(&mut self.file, &mut self.write_buffer, offset)?; + self.file.get_mut().seek(SeekFrom::End(0))?; + let offset = self.file.get_mut().stream_position()?; + node.serialize_to_block(self.file.get_mut(), &mut self.write_buffer, offset)?; Ok(offset) } fn write_internal_node(&mut self, node: &BPlusTreeNode, pointers: &[u64]) -> io::Result { - self.file.seek(SeekFrom::End(0))?; - let offset = self.file.stream_position()?; - node.serialize_internal_with_offsets(&mut self.file, &mut self.write_buffer, offset, pointers)?; + self.file.get_mut().seek(SeekFrom::End(0))?; + let offset = self.file.get_mut().stream_position()?; + node.serialize_internal_with_offsets(self.file.get_mut(), &mut self.write_buffer, offset, pointers)?; Ok(offset) } @@ -2479,9 +2748,8 @@ where offset: u64, items: &[(&K, &V)], ) -> io::Result<(u64, Vec<(K, u64)>)> { - let mut reader = utils::file_reader(&mut self.file); let (mut node, pointers_opt) = BPlusTreeNode::::deserialize_from_block( - &mut reader, + &mut self.file, &mut self.read_buffer, offset, false, // shallow @@ -2607,10 +2875,10 @@ where // Handle promotions (splits) using balanced approach current_root = self.build_higher_levels(current_root, promotions)?; - self.file.seek(SeekFrom::Start(ROOT_OFFSET_POS))?; - self.file.write_all(¤t_root.to_le_bytes())?; - self.file.flush()?; - self.file.sync_all()?; + self.file.get_mut().seek(SeekFrom::Start(ROOT_OFFSET_POS))?; + self.file.get_mut().write_all(¤t_root.to_le_bytes())?; + self.file.get_mut().flush()?; + self.file.get_mut().sync_all()?; self.root_offset = current_root; Ok(current_root) @@ -2618,25 +2886,62 @@ where /// Garbage Collection: Compacts the file by rewriting only live blocks sequentially. pub fn compact(&mut self, filepath: &Path) -> io::Result<()> { - let mut value_infos: Vec<(K, ValueInfo)> = Vec::new(); let mut temp_file = NamedTempFile::new_in(filepath.parent().unwrap_or(Path::new(".")))?; - // 1. Write Header placeholder + // 1. Read existing metadata from source (manually to avoid full load) + let mut metadata = Vec::new(); + { + if let Ok(mut src) = File::open(filepath) { + if src.seek(SeekFrom::Start(METADATA_OFFSET_POS)).is_ok() { + let mut lbuf = [0u8; 4]; + if src.read_exact(&mut lbuf).is_ok() { + let l = u32::from_le_bytes(lbuf); + if l > 0 && l as usize <= METADATA_MAX_SIZE { + let mut b = vec![0u8; l as usize]; + if src.read_exact(&mut b).is_ok() { + metadata = b; + } else { + error!("Failed to read metadata bytes during compaction"); + } + } else if l > u32::try_from(METADATA_MAX_SIZE).unwrap_or(4000) { + error!("Metadata too large during compaction: {l}"); + } + } + } + } + } + + // 2. Write Header placeholder temp_file.seek(SeekFrom::Start(0))?; - temp_file.write_all(MAGIC)?; - temp_file.write_all(&STORAGE_VERSION.to_le_bytes())?; - temp_file.seek(SeekFrom::Start(HEADER_SIZE))?; // Skip to start of content + // Construct full header block + let mut header = [0u8; PAGE_SIZE_USIZE]; + header[0..4].copy_from_slice(MAGIC); + header[4..8].copy_from_slice(&STORAGE_VERSION.to_le_bytes()); + // Root offset placeholder (will be filled later) + // Root offset placeholder (will be filled later) + if !metadata.is_empty() { + let len = u32::try_from(metadata.len()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + // Ensure we check max size? + header[16..20].copy_from_slice(&len.to_le_bytes()); + header[20..20 + metadata.len()].copy_from_slice(&metadata); + } + temp_file.write_all(&header)?; + + // temp_file.seek(SeekFrom::Start(HEADER_SIZE))?; // Skip to start of content (implied by write_all(4096)) let mut current_offset = HEADER_SIZE; + let mut leaf_pointers: Vec<(K, u64)> = Vec::new(); + let mut current_leaf = BPlusTreeNode::::new(true); - // 2. Iterate source and write values immediately (Streaming) + // 2. Iterate source and write values + leaf nodes immediately (Streaming) { let mut query = BPlusTreeQuery::::try_new(filepath).map_err(to_io_error)?; let mut write_buffer = std::io::BufWriter::new(&mut temp_file); + let mut node_buffer = vec![0u8; PAGE_SIZE_USIZE]; for (k, v) in query.iter() { let value_bytes = binary_serialize(&v)?; - let offset = current_offset; + let val_offset = current_offset; // Write value header (flag + payload) let (flag, payload) = compress_if_beneficial(&value_bytes); @@ -2646,20 +2951,36 @@ where let stored_len = u32::try_from(1 + payload.len()).map_err(to_io_error)?; current_offset += u64::from(stored_len); - value_infos.push((k, ValueInfo { - mode: ValueStorageMode::Single(offset), + current_leaf.keys.push(k); + current_leaf.value_info.push(ValueInfo { + mode: ValueStorageMode::Single(val_offset), length: stored_len, compressed_cache: None, - })); + }); + + if current_leaf.keys.len() >= self.leaf_order { + let first_key = current_leaf.keys[0].clone(); + let node_offset = current_offset; + current_offset = current_leaf.serialize_to_block(&mut write_buffer, &mut node_buffer, node_offset)?; + leaf_pointers.push((first_key, node_offset)); + current_leaf = BPlusTreeNode::new(true); + } + } + + // Handle trailing leaf + if !current_leaf.keys.is_empty() { + let first_key = current_leaf.keys[0].clone(); + let node_offset = current_offset; + current_offset = current_leaf.serialize_to_block(&mut write_buffer, &mut node_buffer, node_offset)?; + leaf_pointers.push((first_key, node_offset)); } write_buffer.flush()?; } - // 3. Build Tree Structure - current_offset = temp_file.seek(SeekFrom::End(0))?; - - let mut tree = BPlusTree::::new(); - let root_offset = tree.bulk_build_from_infos(&mut temp_file, &value_infos, current_offset)?; + // 3. Build Internal Levels + let tree = BPlusTree::::new(); + let mut node_buffer = vec![0u8; PAGE_SIZE_USIZE]; + let root_offset = tree.build_levels_from_pointers(&mut temp_file, leaf_pointers, current_offset, &mut node_buffer)?; // 4. Update Header temp_file.seek(SeekFrom::Start(ROOT_OFFSET_POS))?; @@ -2673,7 +2994,8 @@ where // 6. Refresh state self.root_offset = root_offset; - self.file = utils::open_read_write_file(filepath)?; + let file = utils::open_read_write_file(filepath)?; + self.file = utils::file_reader(file); self.cache.clear(); Ok(()) diff --git a/backend/src/repository/epg_repository.rs b/backend/src/repository/epg_repository.rs index 4a7bc91e1..50972cc3a 100644 --- a/backend/src/repository/epg_repository.rs +++ b/backend/src/repository/epg_repository.rs @@ -44,7 +44,7 @@ pub async fn epg_write_file(target: &ConfigTarget, epg: &Epg, path: &Path, playl for channel in &group.channels { if let Some(epg_id) = &channel.header.epg_channel_id { if !epg_id.is_empty() { - rename_map.insert(epg_id.as_str(), channel.header.name.as_str()); + rename_map.insert(epg_id, &channel.header.name); } } } diff --git a/backend/src/repository/library_repository.rs b/backend/src/repository/library_repository.rs index 47b95453d..1e1ba1161 100644 --- a/backend/src/repository/library_repository.rs +++ b/backend/src/repository/library_repository.rs @@ -1,10 +1,11 @@ use crate::model::AppConfig; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery}; -use shared::error::{TuliproxError, notify_err_res}; -use shared::model::{PlaylistGroup, PlaylistItem, UUIDType, XtreamCluster, XtreamPlaylistItem}; +use shared::error::{notify_err_res, TuliproxError}; +use shared::model::{PlaylistGroup, PlaylistItem, XtreamCluster, XtreamPlaylistItem}; use std::path::Path; use std::sync::Arc; use indexmap::IndexMap; +use shared::model::UUIDType; use crate::repository::xtream_repository::CategoryKey; pub async fn persist_input_library_playlist(app_config: &Arc, library_path: &Path, playlist: &[PlaylistGroup]) -> Result<(), TuliproxError> { diff --git a/backend/src/repository/m3u_playlist_iterator.rs b/backend/src/repository/m3u_playlist_iterator.rs index 56aa2ce5b..c3c78f841 100644 --- a/backend/src/repository/m3u_playlist_iterator.rs +++ b/backend/src/repository/m3u_playlist_iterator.rs @@ -12,7 +12,7 @@ use crate::utils::FileReadGuard; use std::collections::HashSet; use std::iter::Peekable; use log::error; -// concat_string! macro from shared utils is used for efficient String building +use shared::utils::Internable; #[allow(clippy::struct_excessive_bools)] pub struct M3uPlaylistIterator { @@ -195,7 +195,7 @@ impl M3uPlaylistIterator { } else { None }; - m3u_pli.t_stream_url = stream_url; + m3u_pli.t_stream_url = stream_url.intern(); m3u_pli.t_resource_url = resource_url; } else { // Keep original URL (clone required because target field is distinct) diff --git a/backend/src/repository/m3u_repository.rs b/backend/src/repository/m3u_repository.rs index 5e7df23b3..b868dce50 100644 --- a/backend/src/repository/m3u_repository.rs +++ b/backend/src/repository/m3u_repository.rs @@ -1,22 +1,24 @@ use crate::api::model::AppState; -use crate::model::{AppConfig, ProxyUserCredentials}; +use crate::model::{AppConfig, ConfigInput, ProxyUserCredentials}; use crate::model::{Config, ConfigTarget, M3uTargetOutput}; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery}; use crate::repository::m3u_playlist_iterator::M3uPlaylistM3uTextIterator; -use crate::repository::storage::{get_file_path_for_db_index, get_target_storage_path}; +use crate::repository::playlist_repository::get_input_m3u_playlist_file_path; +use crate::repository::storage::{get_input_storage_path, get_target_storage_path}; use crate::repository::storage_const; use crate::repository::xtream_repository::CategoryKey; use crate::utils; -use crate::utils::{async_file_writer, IO_BUFFER_SIZE}; +use crate::utils::{async_file_writer, FileReadGuard, IO_BUFFER_SIZE}; use indexmap::IndexMap; use log::error; use shared::concat_string; -use shared::error::{info_err, notify_err, str_to_io_error, string_to_io_error, TuliproxError}; +use shared::error::{notify_err, str_to_io_error, string_to_io_error, TuliproxError}; use shared::model::{M3uPlaylistItem, PlaylistGroup}; use shared::model::{PlaylistItem, PlaylistItemType, XtreamCluster}; use std::io::Error; use std::path::{Path, PathBuf}; use std::sync::Arc; +use serde::{Deserialize, Serialize}; use tokio::fs; use tokio::io::AsyncWriteExt; use tokio::task; @@ -158,41 +160,49 @@ pub async fn m3u_get_item_for_stream_id(stream_id: u32, app_state: &AppState, ta } } -pub async fn iter_raw_m3u_playlist(config: &AppConfig, target: &ConfigTarget) -> Option<(utils::FileReadGuard, impl Iterator)> { +pub async fn iter_raw_m3u_target_playlist(config: &AppConfig, target: &ConfigTarget, cluster: Option) -> Option<(FileReadGuard, Box + Send>)> { let target_path = get_target_storage_path(&config.config.load(), target.name.as_str())?; let m3u_path = m3u_get_file_path_for_db(&target_path); - if let Ok(false) = tokio::fs::try_exists(&m3u_path).await { + + iter_raw_m3u_playlist::(config, &m3u_path, cluster).await +} + +pub async fn iter_raw_m3u_input_playlist(app_config: &AppConfig, input: &ConfigInput, cluster: Option) -> Option<(FileReadGuard, Box + Send>)> { + let working_dir = &app_config.config.load().working_dir; + let storage_path = get_input_storage_path(&input.name, working_dir).ok()?; + let m3u_path = get_input_m3u_playlist_file_path(&storage_path, &input.name); + + iter_raw_m3u_playlist::>(app_config, &m3u_path, cluster).await +} + +async fn iter_raw_m3u_playlist(app_config: &AppConfig, m3u_path: &Path, cluster: Option) -> Option<(FileReadGuard, Box + Send>)> +where + ItemKey: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Send + 'static, + SortKey: for<'de> Deserialize<'de> + Send + 'static, +{ + let file_lock = app_config.file_locks.read_lock(m3u_path).await; + if !tokio::fs::try_exists(m3u_path).await.unwrap_or(false) { return None; } - let file_lock = config.file_locks.read_lock(&m3u_path).await; - match BPlusTreeQuery::::try_new(&m3u_path) - .map_err(|err| info_err!("Could not open BPlusTreeQuery {m3u_path:?} - {err}")) { - Ok(mut query) => { - let index_path = get_file_path_for_db_index(&m3u_path); - let items: Vec = if index_path.exists() { - match query.disk_iter_sorted::() { - Ok(iter) => iter.filter_map(Result::ok).map(|(_, v)| v).collect(), - Err(err) => { - error!("Sorted index error {}: {err}", m3u_path.display()); - // Re-open query for fallback - match BPlusTreeQuery::::try_new(&m3u_path) { - Ok(mut query) => query.iter().map(|(_, v)| v).collect(), - Err(fallback_err) => { - error!("Fallback query also failed {}: {fallback_err}", m3u_path.display()); - Vec::new() - } - } + + let iter: Box + Send> = { + match BPlusTreeQuery::::try_new(m3u_path) { + Ok(tree) => match tree.disk_iter_sorted::() { + Ok(sorted_iter) => Box::new(sorted_iter.filter_map(Result::ok).map(|(_, v)| v).filter(move |v| cluster.is_none_or(|c| v.item_type.is_cluster(c)))), + Err(_) => { + match BPlusTreeQuery::::try_new(m3u_path) { + Ok(tree) => Box::new(tree.disk_iter().map(|(_, v)| v).filter(move |v| cluster.is_none_or(|c| v.item_type.is_cluster(c)))), + Err(_) => return None, } } - } else { - query.iter().map(|(_, v)| v).collect() - }; - - let len = items.len(); - Some((file_lock, items.into_iter().enumerate().map(move |(i, v)| (v, i + 1 < len)))) + } + Err(_) => { + return None + } } - Err(_) => None - } + }; + + Some((file_lock, iter)) } pub async fn persist_input_m3u_playlist(app_config: &Arc, m3u_path: &Path, playlist: &[PlaylistGroup]) -> Result<(), TuliproxError> { @@ -226,7 +236,7 @@ pub async fn load_input_m3u_playlist(app_config: &Arc, m3u_path: &Pat if tokio::fs::try_exists(m3u_path).await.unwrap_or(false) { // Load Items let _file_lock = app_config.file_locks.read_lock(m3u_path).await; - if let Ok(mut query) = BPlusTreeQuery::::try_new(m3u_path) { + if let Ok(mut query) = BPlusTreeQuery::, M3uPlaylistItem>::try_new(m3u_path) { let mut group_cnt = 0; for (_, ref item) in query.iter() { let cluster = XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live); diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 43ca14308..4056d8bec 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -16,14 +16,14 @@ use crate::repository::{LocalLibraryDiskPlaylistSource, M3uDiskPlaylistSource, M use shared::error::{info_err, TuliproxError}; use shared::model::xtream_const::XTREAM_CLUSTER; use shared::model::{InputType, M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem}; -use shared::utils::{is_dash_url, is_hls_url, StringInterner}; +use shared::utils::{is_dash_url, is_hls_url, Internable}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::repository::library_repository::{load_input_local_library_playlist, persist_input_library_playlist}; struct LocalEpisodeKey { - path: String, + path: Arc, virtual_id: u32, } @@ -33,8 +33,7 @@ pub struct ProviderEpisodeKey { } pub async fn persist_playlist(app_config: &Arc, playlist: &mut [PlaylistGroup], epg: Option<&Epg>, - target: &ConfigTarget, playlist_state: Option<&Arc>, - interner: &mut StringInterner) -> Result<(), Vec> { + target: &ConfigTarget, playlist_state: Option<&Arc>) -> Result<(), Vec> { let mut errors = vec![]; let config = &app_config.config.load(); let target_path = match ensure_target_storage_path(config, &target.name) { @@ -42,10 +41,13 @@ pub async fn persist_playlist(app_config: &Arc, playlist: &mut [Playl Err(err) => return Err(vec![err]), }; - let (mut target_id_mapping, file_lock) = get_target_id_mapping(app_config, &target_path).await; + let (mut target_id_mapping, file_lock) = match get_target_id_mapping(app_config, &target_path, target.use_memory_cache).await { + Ok(result) => result, + Err(err) => return Err(vec![err]), + }; - let mut local_library_series = HashMap::>::new(); - let mut provider_series = HashMap::>::new(); + let mut local_library_series = HashMap::, Vec>::new(); + let mut provider_series = HashMap::, Vec>::new(); let mut source_ordinal: u32 = 0; // Virtual IDs assignment @@ -98,7 +100,7 @@ pub async fn persist_playlist(app_config: &Arc, playlist: &mut [Playl }; let result = match output { - TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(app_config, target, pl, interner).await, + TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(app_config, target, pl).await, TargetOutput::M3u(m3u_output) => m3u_write_playlist(app_config, target, m3u_output, &target_path, pl).await, TargetOutput::Strm(strm_output) => write_strm_playlist(app_config, target, strm_output, pl).await, TargetOutput::HdHomeRun(_hdhomerun_output) => Ok(()), @@ -120,6 +122,9 @@ pub async fn persist_playlist(app_config: &Arc, playlist: &mut [Playl if let Err(err) = target_id_mapping.persist() { errors.push(info_err!("{err}")); } + // We must drop target_id_mapping here to release the exclusive B+Tree lock + // otherwise the subsequent load_xtream_target_storage will deadlock waiting for a shared lock. + drop(target_id_mapping); drop(file_lock); if target.use_memory_cache { @@ -145,7 +150,7 @@ pub async fn persist_playlist(app_config: &Arc, playlist: &mut [Playl if errors.is_empty() { Ok(()) } else { Err(errors) } } -fn assign_local_series_info_episode_key(local_library_series: &mut HashMap>, header: &mut PlaylistItemHeader, item_type: PlaylistItemType) { +fn assign_local_series_info_episode_key(local_library_series: &mut HashMap, Vec>, header: &mut PlaylistItemHeader, item_type: PlaylistItemType) { // we need to rewrite local series info with the new virtual ids if item_type == PlaylistItemType::LocalSeries { local_library_series @@ -158,7 +163,7 @@ fn assign_local_series_info_episode_key(local_library_series: &mut HashMap>, header: &mut PlaylistItemHeader, item_type: PlaylistItemType) { +fn assign_provider_series_info_episode_key(provider_series: &mut HashMap, Vec>, header: &mut PlaylistItemHeader, item_type: PlaylistItemType) { // we need to rewrite local series info with the new virtual ids if item_type == PlaylistItemType::Series { provider_series @@ -172,11 +177,11 @@ fn assign_provider_series_info_episode_key(provider_series: &mut HashMap>) { +fn rewrite_local_series_info_episode_virtual_id(pli: &mut PlaylistItem, local_library_series: &HashMap, Vec>) { let header = &mut pli.header; // the local_library_series key is the id of the SeriesInfo. The episodes have their parent SeriesInfo id as parent_code. // When we populate local_library_series, we use the episodes.parent_code. Here we need to use the SeriesInfo.id to get the assigned episodes. - if let Some(episode_keys) = local_library_series.get(&header.id) { + if let Some(episode_keys) = local_library_series.get(&*header.id) { if let Some(StreamProperties::Series(series)) = header.additional_properties.as_mut() { if let Some(episodes) = series.details.as_mut().and_then(|d| d.episodes.as_mut()) @@ -195,11 +200,11 @@ fn rewrite_local_series_info_episode_virtual_id(pli: &mut PlaylistItem, local_li } #[allow(clippy::implicit_hasher)] -pub fn rewrite_provider_series_info_episode_virtual_id

(pli: &mut P, provider_series: &HashMap>) +pub fn rewrite_provider_series_info_episode_virtual_id

(pli: &mut P, provider_series: &HashMap, Vec>) where P: PlaylistEntry, { - if let Some(episode_keys) = provider_series.get(&pli.get_uuid().to_string()) { + if let Some(episode_keys) = provider_series.get(&pli.get_uuid().intern()) { if let Some(StreamProperties::Series(series)) = pli.get_additional_properties_mut() { if let Some(episodes) = series.details.as_mut().and_then(|d| d.episodes.as_mut()) @@ -218,8 +223,8 @@ where } fn rewrite_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup], - local_library_series: &HashMap>, - provider_series: &HashMap>) { + local_library_series: &HashMap, Vec>, + provider_series: &HashMap, Vec>) { if local_library_series.is_empty() && provider_series.is_empty() { return; } @@ -231,16 +236,17 @@ fn rewrite_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup], } else if item_type == PlaylistItemType::LocalSeriesInfo { rewrite_local_series_info_episode_virtual_id(channel, local_library_series); } else if item_type == PlaylistItemType::LocalSeries { - channel.header.parent_code = String::new(); + channel.header.parent_code = "".intern(); } } } } -pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path) -> (TargetIdMapping, utils::FileWriteGuard) { +pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path, use_memory_cache: bool) -> Result<(TargetIdMapping, utils::FileWriteGuard), TuliproxError> { let target_id_mapping_file = get_target_id_mapping_file(target_path); let file_lock = cfg.file_locks.write_lock(&target_id_mapping_file).await; - (TargetIdMapping::new(&target_id_mapping_file), file_lock) + let mapping = TargetIdMapping::new(&target_id_mapping_file, use_memory_cache)?; + Ok((mapping, file_lock)) } @@ -355,7 +361,7 @@ pub async fn persist_input_playlist(app_config: &Arc, input: &ConfigI return (playlist, Some(info_err!("Error creating input storage directory for input '{}' failed: {err}", input.name))); } }; - let file_path = get_input_m3u_playlist_file_path(&storage_path, input.name.as_str()); + let file_path = get_input_m3u_playlist_file_path(&storage_path, &input.name); if let Err(err) = persist_input_m3u_playlist(app_config, &file_path, &playlist).await { return (playlist, Some(err)); } @@ -370,7 +376,7 @@ pub async fn persist_input_playlist(app_config: &Arc, input: &ConfigI return (playlist, Some(info_err!("Error creating input storage directory for input '{}' failed: {err}", input.name))); } }; - let file_path = get_input_local_library_playlist_file_path(&storage_path, input.name.as_str()); + let file_path = get_input_local_library_playlist_file_path(&storage_path, &input.name); if let Err(err) = persist_input_library_playlist(app_config, &file_path, &playlist).await { return (playlist, Some(err)); } @@ -403,7 +409,7 @@ pub async fn load_input_playlist(ctx: &PlaylistProcessingContext, input: &Config } InputType::M3u | InputType::M3uBatch => { // Load M3U - let file_path = get_input_m3u_playlist_file_path(&storage_path, input.name.as_str()); + let file_path = get_input_m3u_playlist_file_path(&storage_path, &input.name); if disk_based_processing && file_path.exists() { Ok(Box::new(M3uDiskPlaylistSource::new(app_config, &file_path).await)) } else { @@ -412,7 +418,7 @@ pub async fn load_input_playlist(ctx: &PlaylistProcessingContext, input: &Config } } InputType::Library => { - let file_path = get_input_local_library_playlist_file_path(&storage_path, input.name.as_str()); + let file_path = get_input_local_library_playlist_file_path(&storage_path, &input.name); if disk_based_processing && file_path.exists() { Ok(Box::new(LocalLibraryDiskPlaylistSource::new(app_config, &file_path).await)) } else { @@ -423,14 +429,14 @@ pub async fn load_input_playlist(ctx: &PlaylistProcessingContext, input: &Config } } -fn get_input_m3u_playlist_file_path(storage_path: &Path, input_name: &str) -> PathBuf { +pub fn get_input_m3u_playlist_file_path(storage_path: &Path, input_name: &Arc) -> PathBuf { let sanitized_input_name: String = input_name.chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); storage_path.join(format!("m3u_{sanitized_input_name}.{FILE_SUFFIX_DB}")) } -fn get_input_local_library_playlist_file_path(storage_path: &Path, input_name: &str) -> PathBuf { +fn get_input_local_library_playlist_file_path(storage_path: &Path, input_name: &Arc) -> PathBuf { let sanitized_input_name: String = input_name.chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); diff --git a/backend/src/repository/playlist_source.rs b/backend/src/repository/playlist_source.rs index 37135a057..0ab8af01f 100644 --- a/backend/src/repository/playlist_source.rs +++ b/backend/src/repository/playlist_source.rs @@ -6,11 +6,12 @@ use futures::future::BoxFuture; use indexmap::IndexMap; use log::{error, warn}; use serde::{Deserialize, Serialize}; -use shared::model::{M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, UUIDType, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; use std::borrow::Cow; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; +use shared::model::UUIDType; pub trait PlaylistSource: Send + Sync { fn is_memory(&self) -> bool; diff --git a/backend/src/repository/sorted_index.rs b/backend/src/repository/sorted_index.rs index 3e0f59285..fabbeb3bb 100644 --- a/backend/src/repository/sorted_index.rs +++ b/backend/src/repository/sorted_index.rs @@ -26,15 +26,15 @@ //! > **Important**: The index is tightly coupled to the B+Tree file structure. //! > It must be rebuilt after any operation that changes value offsets (e.g., `compact()`). +use crate::repository::bplustree::{COMPRESSION_FLAG_LZ4, PAGE_SIZE_USIZE}; +use crate::repository::storage::get_file_path_for_db_index; use crate::utils::{binary_deserialize, binary_serialize}; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::fs::{File, OpenOptions}; use std::io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; -use crate::repository::bplustree::{COMPRESSION_FLAG_LZ4, PAGE_SIZE_USIZE}; -use indexmap::IndexMap; -use crate::repository::storage::get_file_path_for_db_index; const MAGIC: &[u8; 4] = b"SIDX"; const VERSION: u32 = 3; // Bumped for new format with flexible value location @@ -44,7 +44,6 @@ const MODE_SINGLE: u8 = 0; const MODE_PACKED: u8 = 1; - /// Represents how a value can be located and read from the tree file. #[derive(Debug, Clone, Copy)] pub enum ValueLocation { @@ -321,7 +320,8 @@ where /// tree file using stored offsets - no tree traversal needed (O(1) per item). pub struct BPlusTreeSortedIteratorOwned { index_reader: SortedIndexReader, - tree_file: BufReader, + tree_file: Option>, + mmap: Option, filepath: PathBuf, block_cache: IndexMap>, _marker: PhantomData, @@ -339,12 +339,30 @@ where /// /// The index path is automatically derived from the tree filepath /// by changing the extension to `.idx`. - pub fn new(filepath: PathBuf, tree_file: BufReader) -> io::Result { + pub fn new(filepath: PathBuf, tree_file: Option>) -> io::Result { let index_path = get_file_path_for_db_index(&filepath); let index_reader = SortedIndexReader::open(&index_path)?; Ok(Self { index_reader, tree_file, + mmap: None, + filepath, + block_cache: IndexMap::with_capacity(CACHE_CAPACITY), + _marker: PhantomData, + }) + } + + pub fn new_hybrid( + filepath: PathBuf, + tree_file: Option>, + mmap: Option, + ) -> io::Result { + let index_path = get_file_path_for_db_index(&filepath); + let index_reader = SortedIndexReader::open(&index_path)?; + Ok(Self { + index_reader, + tree_file, + mmap, filepath, block_cache: IndexMap::with_capacity(CACHE_CAPACITY), _marker: PhantomData, @@ -354,13 +372,31 @@ where /// Create from an explicit index path. pub fn with_index_path( filepath: PathBuf, - tree_file: BufReader, + tree_file: Option>, index_path: &Path, ) -> io::Result { let index_reader = SortedIndexReader::open(index_path)?; Ok(Self { index_reader, tree_file, + mmap: None, + filepath, + block_cache: IndexMap::with_capacity(CACHE_CAPACITY), + _marker: PhantomData, + }) + } + + pub fn with_index_path_hybrid( + filepath: PathBuf, + tree_file: Option>, + mmap: Option, + index_path: &Path, + ) -> io::Result { + let index_reader = SortedIndexReader::open(index_path)?; + Ok(Self { + index_reader, + tree_file, + mmap, filepath, block_cache: IndexMap::with_capacity(CACHE_CAPACITY), _marker: PhantomData, @@ -391,50 +427,123 @@ where /// Read a single value directly from the tree file at the given offset. fn read_value_single(&mut self, offset: u64, length: u32) -> io::Result { - self.tree_file.seek(SeekFrom::Start(offset))?; + if let Some(mmap) = &self.mmap { + let mut cursor = io::Cursor::new(mmap.as_ref()); + cursor.set_position(offset); - // Read compression flag - let mut flag = [0u8; 1]; - self.tree_file.read_exact(&mut flag)?; + let mut flag = [0u8; 1]; + cursor.read_exact(&mut flag)?; - let data = if flag[0] == COMPRESSION_FLAG_LZ4 { - // Compressed: [flag:1][lz4_payload_with_prepended_size] - let compressed_len = length as usize - 1; - let mut compressed = vec![0u8; compressed_len]; - self.tree_file.read_exact(&mut compressed)?; + let data = if flag[0] == COMPRESSION_FLAG_LZ4 { + let compressed_len = length as usize - 1; + let mut compressed = vec![0u8; compressed_len]; + cursor.read_exact(&mut compressed)?; + lz4_flex::decompress_size_prepended(&compressed).map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, format!("LZ4 decompression failed: {e}")) + })? + } else { + let payload_len = length as usize - 1; + let mut data = vec![0u8; payload_len]; + cursor.read_exact(&mut data)?; + data + }; + crate::utils::binary_deserialize(&data) + } else if let Some(tree_file) = &mut self.tree_file { + tree_file.seek(SeekFrom::Start(offset))?; - lz4_flex::decompress_size_prepended(&compressed).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("LZ4 decompression failed: {e}"), - ) - })? + // Read compression flag + let mut flag = [0u8; 1]; + tree_file.read_exact(&mut flag)?; + + let data = if flag[0] == COMPRESSION_FLAG_LZ4 { + // Compressed: [flag:1][lz4_payload_with_prepended_size] + let compressed_len = length as usize - 1; + let mut compressed = vec![0u8; compressed_len]; + tree_file.read_exact(&mut compressed)?; + + lz4_flex::decompress_size_prepended(&compressed).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("LZ4 decompression failed: {e}"), + ) + })? + } else { + // Uncompressed: [flag:1][payload] + let payload_len = length as usize - 1; + let mut data = vec![0u8; payload_len]; + tree_file.read_exact(&mut data)?; + data + }; + + crate::utils::binary_deserialize(&data) } else { - // Uncompressed: [flag:1][payload] - let payload_len = length as usize - 1; - let mut data = vec![0u8; payload_len]; - self.tree_file.read_exact(&mut data)?; - data - }; - - crate::utils::binary_deserialize(&data) + Err(io::Error::other("No data source available")) + } } /// Read a value from a packed block at the given index. fn read_value_packed(&mut self, block_offset: u64, value_index: u16) -> io::Result { + if let Some(mmap) = &self.mmap { + let start = usize::try_from(block_offset).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + if start >= mmap.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Packed block offset out of range")); + } + let end = (start + PAGE_SIZE_USIZE).min(mmap.len()); + let block_buffer = &mmap[start..end]; + if block_buffer.len() < 4 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Packed block truncated")); + } + // Read count (first 4 bytes) + let count = u32::from_le_bytes(block_buffer[0..4].try_into().map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, format!("Invalid count: {e}")) + })?); + + if u32::from(value_index) >= count { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Value index {value_index} out of bounds (count: {count})"), + )); + } + + let mut pos = 4; + // Skip to target value + for i in 0..=value_index { + if pos + 4 > block_buffer.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Packed block corrupted")); + } + let len = u32::from_le_bytes(block_buffer[pos..pos + 4].try_into().map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, format!("Invalid length: {e}")) + })?) as usize; + pos += 4; + + if i == value_index { + if pos + len > block_buffer.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Packed block corrupted")); + } + let value_data = &block_buffer[pos..pos + len]; + return crate::utils::binary_deserialize(value_data); + } + pos += len; + } + return Err(io::Error::other("Value not found")); + } // Try cache first if !self.block_cache.contains_key(&block_offset) { // Miss - read from disk - self.tree_file.seek(SeekFrom::Start(block_offset))?; - let mut buf = vec![0u8; PAGE_SIZE_USIZE]; - self.tree_file.read_exact(&mut buf)?; + if let Some(tree_file) = &mut self.tree_file { + tree_file.seek(SeekFrom::Start(block_offset))?; + let mut buf = vec![0u8; PAGE_SIZE_USIZE]; + tree_file.read_exact(&mut buf)?; - // Update cache - if self.block_cache.len() >= CACHE_CAPACITY { - self.block_cache.shift_remove_index(0); // FIFO eviction + // Update cache + if self.block_cache.len() >= CACHE_CAPACITY { + self.block_cache.shift_remove_index(0); // FIFO eviction + } + self.block_cache.insert(block_offset, buf); + } else { + return Err(io::Error::other("No data source available")); } - self.block_cache.insert(block_offset, buf); } let block_buffer = self.block_cache.get(&block_offset).unwrap(); @@ -526,19 +635,19 @@ mod tests { // Write entries with value locations let mut writer = SortedIndexWriter::::new(&path).unwrap(); writer.push( - &"apple".to_string(), - &1u32, - ValueLocation::Single { offset: 100, length: 50 } + &"apple".to_string(), + &1u32, + ValueLocation::Single { offset: 100, length: 50 }, ).unwrap(); writer.push( - &"banana".to_string(), - &2u32, - ValueLocation::Packed { block_offset: 200, index: 3, length: 75 } + &"banana".to_string(), + &2u32, + ValueLocation::Packed { block_offset: 200, index: 3, length: 75 }, ).unwrap(); writer.push( - &"cherry".to_string(), - &3u32, - ValueLocation::Single { offset: 300, length: 100 } + &"cherry".to_string(), + &3u32, + ValueLocation::Single { offset: 300, length: 100 }, ).unwrap(); let count = writer.finish().unwrap(); assert_eq!(count, 3); @@ -594,7 +703,7 @@ mod tests { // Note: collect() consumes the reader, so we check is_empty() and remaining first let remaining = reader.remaining; assert_eq!(remaining, 0); - + let entries: Vec<_> = reader.collect(); assert!(entries.is_empty()); } diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index 8576b7184..b3c890c34 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -1,9 +1,8 @@ // Import the new MediaQuality struct -use shared::utils::is_blank_optional_string; use crate::model::MediaQuality; use crate::model::{ApiProxyServerInfo, AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, StrmTargetOutput}; -use crate::repository::storage::{ensure_target_storage_path}; +use crate::repository::storage::ensure_target_storage_path; use crate::repository::storage_const; use crate::utils::{async_file_reader, async_file_writer, normalize_string_path, truncate_filename, IO_BUFFER_SIZE}; @@ -12,14 +11,16 @@ use filetime::{set_file_times, FileTime}; use log::{error, trace}; use regex::Regex; use serde::Serialize; -use shared::error::{TuliproxError, info_err_res}; -use shared::model::{ClusterFlags, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle, UUIDType}; -use shared::utils::{ arc_str_serde, extract_extension_from_url, hash_bytes, hash_string_as_hex, truncate_string, ExportStyleConfig, CONSTANTS}; +use shared::error::{info_err_res, TuliproxError}; +use shared::model::{ClusterFlags, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle}; +use shared::utils::{arc_str_option_serde, arc_str_serde, extract_extension_from_url, hash_bytes, + hash_string_as_hex, is_blank_optional_arc_str, truncate_string, ExportStyleConfig, CONSTANTS}; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs::{create_dir_all, remove_dir, remove_file, File}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; +use shared::model::UUIDType; /// 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. @@ -84,7 +85,7 @@ fn extract_match(name: &str, pattern: &Regex) -> (String, Option) { fn style_rename_year<'a>( name: &'a str, style: &ExportStyleConfig, - release_date: Option<&'a String>, + release_date: Option<&Arc>, ) -> (std::borrow::Cow<'a, str>, Option) { let mut years = Vec::new(); @@ -132,18 +133,20 @@ pub fn strm_get_file_paths(file_prefix: &str, target_path: &Path) -> PathBuf { struct StrmItemInfo { #[serde(with = "arc_str_serde")] group: Arc, - title: String, + #[serde(with = "arc_str_serde")] + title: Arc, item_type: PlaylistItemType, #[serde(default, skip_serializing_if = "Option::is_none")] provider_id: Option, virtual_id: u32, #[serde(with = "arc_str_serde")] input_name: Arc, - url: String, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] - series_name: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] - release_date: Option, + #[serde(with = "arc_str_serde")] + url: Arc, + #[serde(with = "arc_str_option_serde", skip_serializing_if = "is_blank_optional_arc_str")] + series_name: Option>, + #[serde(with = "arc_str_option_serde", skip_serializing_if = "is_blank_optional_arc_str")] + release_date: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] season: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -209,7 +212,7 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo { input_name, url, series_name, - release_date: release_date.as_deref().map(ToString::to_string), + release_date, season, episode, added: added.as_ref().map_or_else(|| Some(0), |a| a.parse::().ok()), @@ -753,7 +756,7 @@ pub async fn write_strm_playlist( // create content let url = get_strm_url(target_force_redirect, user_and_server_info.as_ref(), &strm_file.strm_info); let mut content = target_output.strm_props.as_ref().map_or_else(Vec::new, std::clone::Clone::clone); - content.push(url); + content.push(url.to_string()); let content_text = content.join("\r\n"); let content_as_bytes = content_text.as_bytes(); let content_hash = hash_bytes(content_as_bytes); @@ -922,7 +925,7 @@ fn get_strm_url( target_force_redirect: Option<&ClusterFlags>, user_and_server_info: Option<&(ProxyUserCredentials, ApiProxyServerInfo)>, str_item_info: &StrmItemInfo, -) -> String { +) -> Arc { let Some((user, server_info)) = user_and_server_info else { return str_item_info.url.clone(); }; let redirect = user.proxy.is_redirect(str_item_info.item_type) || target_force_redirect.is_some_and(|f| f.has_cluster(str_item_info.item_type)); @@ -940,7 +943,7 @@ fn get_strm_url( | PlaylistItemType::LocalVideo => Some("movie"), _ => None, } { - let url = str_item_info.url.as_str(); + let url = &str_item_info.url; let ext = extract_extension_from_url(url) .map_or_else(String::new, std::string::ToString::to_string); format!( @@ -949,7 +952,7 @@ fn get_strm_url( user.username, user.password, str_item_info.virtual_id - ) + ).into() } else { str_item_info.url.clone() } diff --git a/backend/src/repository/target_id_mapping.rs b/backend/src/repository/target_id_mapping.rs index b5ccd705b..017167213 100644 --- a/backend/src/repository/target_id_mapping.rs +++ b/backend/src/repository/target_id_mapping.rs @@ -1,15 +1,16 @@ +use crate::repository::bplustree::{BPlusTree, BPlusTreeMetadata, BPlusTreeUpdate}; +use chrono::Local; +use log::error; +use serde::{Deserialize, Serialize}; +use shared::error::TuliproxError; +use shared::info_err; +use shared::model::PlaylistItemType; +use shared::model::UUIDType; use std::cmp::max; use std::collections::BTreeMap; use std::io::Error; use std::path::{Path, PathBuf}; -use chrono::Local; -use log::error; -use serde::{Deserialize, Serialize}; - -use shared::model::{PlaylistItemType, UUIDType}; -use crate::repository::bplustree::BPlusTree; - // TODO make configurable const EXPIRATION_DURATION: i64 = 86400; @@ -38,106 +39,307 @@ impl VirtualIdRecord { } } + +/// Helper to get UUID index path from primary path +fn get_uuid_index_path(path: &Path) -> PathBuf { + path.with_extension("uuid.db") +} + +/// Ensure B+tree file exists, creating empty if needed +fn ensure_tree_file(path: &Path) -> std::io::Result<()> +where + K: Ord + Serialize + for<'de> Deserialize<'de> + Clone, + V: Serialize + for<'de> Deserialize<'de> + Clone, +{ + if !path.exists() { + BPlusTree::::new().store(path)?; + } + Ok(()) +} + + +/// Dual-mode `TargetIdMapping` supporting both memory-cached and disk-only operations. +/// +/// - `use_memory_cache=true`: Loads UUID index into RAM for O(1) lookups, writes to both memory and disk +/// - `use_memory_cache=false`: Uses disk-based B+tree queries (O(log n)), writes to disk only pub struct TargetIdMapping { - dirty: bool, virtual_id_counter: u32, - by_virtual_id: BPlusTree, - by_uuid: BTreeMap, + use_memory_cache: bool, + // Disk-based handles + disk_by_virtual_id: BPlusTreeUpdate, + disk_by_uuid: BPlusTreeUpdate, + // In-memory cache (only populated when use_memory_cache=true) + mem_by_uuid: Option>, + // Batch buffers for efficient disk writes + pending_virtual_id_upserts: Vec<(u32, VirtualIdRecord)>, + pending_uuid_upserts: Vec<(UUIDType, u32)>, path: PathBuf, } + impl TargetIdMapping { - pub fn new(path: &Path) -> Self { - let tree_virtual_id: BPlusTree = BPlusTree::::load(path).unwrap_or_else(|_| BPlusTree::::new()); - let mut tree_uuid = BTreeMap::new(); + /// Create a new `TargetIdMapping` with dual-mode support. + /// + /// - `use_memory_cache=true`: Full memory mode with O(1) lookups + /// - `use_memory_cache=false`: Disk-only mode with O(log n) lookups + pub fn new(path: &Path, use_memory_cache: bool) -> Result { + let uuid_index_path = get_uuid_index_path(path); + + let uuid_index_existed = uuid_index_path.exists(); + + // Ensure both tree files exist + ensure_tree_file::(path) + .map_err(|e| info_err!("Failed to create primary tree at {}: {e}", path.display()))?; + ensure_tree_file::(&uuid_index_path) + .map_err(|e| info_err!("Failed to create UUID index at {}: {e}", uuid_index_path.display()))?; + + // Open disk-based update handles + let mut disk_by_virtual_id = match BPlusTreeUpdate::::try_new(path) { + Ok(tree) => tree, + Err(e) => { + error!("Failed to open primary tree at {}: {e}", path.display()); + // Create fresh and try again + let _ = BPlusTree::::new().store(path); + BPlusTreeUpdate::try_new(path).map_err(|_| info_err!("Failed to create primary tree after retry"))? + } + }; + + let mut disk_by_uuid = match BPlusTreeUpdate::::try_new(&uuid_index_path) { + Ok(tree) => tree, + Err(e) => { + error!("Failed to open UUID index at {}: {e}", uuid_index_path.display()); + // Create fresh and try again + let _ = BPlusTree::::new().store(&uuid_index_path); + BPlusTreeUpdate::try_new(&uuid_index_path).map_err(|_| info_err!("Failed to create UUID index after retry"))? + } + }; + let mut virtual_id_counter: u32 = 0; - tree_virtual_id.traverse(|keys, values| { - match keys.iter().max() { - None => {} - Some(max_value) => { - virtual_id_counter = max(virtual_id_counter, *max_value); - } - } - for v in values { - tree_uuid.insert(v.uuid, v.virtual_id); - } - }); - Self { - dirty: false, - virtual_id_counter, - by_virtual_id: tree_virtual_id, - by_uuid: tree_uuid, - path: path.to_path_buf(), + let mut meta_found = false; + + // Try load from header metadata (using disk handle to avoid full tree load) + if let Ok(BPlusTreeMetadata::TargetIdMapping(val)) = disk_by_virtual_id.get_metadata() { + virtual_id_counter = val; + meta_found = true; } + + let mut uuid_index_entries: Vec<(UUIDType, u32)> = Vec::new(); + let needs_uuid_rebuild = !uuid_index_existed; + + // Determines if we MUST traverse the tree + // We traverse if: + // 1. We need to populate memory cache (use_memory_cache) + // 2. We need to rebuild UUID index (needs_uuid_rebuild) + // 3. We didn't find metadata (so we need to find max id manually) + let needs_traversal = use_memory_cache || needs_uuid_rebuild || !meta_found; + + let mem_by_uuid = if needs_traversal { + // Load primary tree only if traversal is needed + let tree: BPlusTree = BPlusTree::load(path) + .map_err(|e| { + error!("Failed to load primary tree at {}, starting fresh: {e}", path.display()); + e + }) + .unwrap_or_else(|_| BPlusTree::new()); + + // Traverse the primary tree to get UUID mappings and max ID + let mut uuid_map = BTreeMap::new(); + tree.traverse(|keys, values| { + if let Some(max_key) = keys.iter().max() { + virtual_id_counter = max(virtual_id_counter, *max_key); + } + for v in values { + uuid_map.insert(v.uuid, v.virtual_id); + if needs_uuid_rebuild { + uuid_index_entries.push((v.uuid, v.virtual_id)); + } + } + }); + + if use_memory_cache { + Some(uuid_map) + } else { + None + } + } else { + // Fast path! No traversal needed + None + }; + + // Rebuild UUID index if it was missing + if needs_uuid_rebuild && !uuid_index_entries.is_empty() { + let batch: Vec<(&UUIDType, &u32)> = uuid_index_entries + .iter() + .map(|(k, v)| (k, v)) + .collect(); + if let Err(e) = disk_by_uuid.upsert_batch(&batch) { + error!("Failed to rebuild UUID index: {e}"); + } + } + + Ok(Self { + virtual_id_counter, + use_memory_cache, + disk_by_virtual_id, + disk_by_uuid, + mem_by_uuid, + pending_virtual_id_upserts: Vec::new(), + pending_uuid_upserts: Vec::new(), + path: path.to_path_buf(), + }) } - // pub fn get_virtual_id(&mut self, uuid: UUIDType, provider_id: u32, item_type: PlaylistItemType, parent_virtual_id: u32) -> u32 { - // match self.by_uuid.get(&uuid) { - // None => { - // self.dirty = true; - // self.virtual_id_counter += 1; - // let record = VirtualIdRecord::new(provider_id, self.virtual_id_counter, item_type, parent_virtual_id, uuid); - // self.by_virtual_id.insert(self.virtual_id_counter, record); - // self.virtual_id_counter - // } - // Some(virtual_id) => *virtual_id - // } - // } - pub fn get_and_update_virtual_id(&mut self, uuid: &UUIDType, provider_id: u32, item_type: PlaylistItemType, parent_virtual_id: u32) -> u32 { - match self.by_uuid.get(uuid) { + + // Lookup existing virtual_id + let existing_virtual_id = if self.use_memory_cache { + self.mem_by_uuid.as_ref().and_then(|m| m.get(uuid).copied()) + } else { + self.disk_by_uuid.query(uuid).ok().flatten() + }; + + match existing_virtual_id { None => { - self.dirty = true; + // New entry: allocate new virtual_id self.virtual_id_counter += 1; let virtual_id = self.virtual_id_counter; let record = VirtualIdRecord::new(provider_id, virtual_id, item_type, parent_virtual_id, *uuid); - self.by_virtual_id.insert(virtual_id, record); - self.virtual_id_counter + + // Buffer for disk write + self.pending_virtual_id_upserts.push((virtual_id, record)); + self.pending_uuid_upserts.push((*uuid, virtual_id)); + + // Update memory cache if enabled + if let Some(ref mut mem_map) = self.mem_by_uuid { + mem_map.insert(*uuid, virtual_id); + } + + virtual_id } Some(virtual_id) => { - if let Some(record) = self.by_virtual_id.query(virtual_id) { - if record.provider_id == provider_id && (record.item_type != item_type || record.parent_virtual_id != parent_virtual_id) { - let new_record = VirtualIdRecord::new(provider_id, *virtual_id, item_type, parent_virtual_id, *uuid); - self.by_virtual_id.insert(*virtual_id, new_record); - self.dirty = true; + // Existing entry: check if update needed + // For update checks, we use the primary tree + let needs_update = match self.disk_by_virtual_id.query(&virtual_id) { + Ok(Some(record)) => { + record.provider_id == provider_id && + (record.item_type != item_type || record.parent_virtual_id != parent_virtual_id) } + Ok(None) => false, + Err(e) => { + error!("Failed to query record for virtual_id {virtual_id}: {e}"); + false + } + }; + + if needs_update { + let new_record = VirtualIdRecord::new(provider_id, virtual_id, item_type, parent_virtual_id, *uuid); + self.pending_virtual_id_upserts.push((virtual_id, new_record)); } - *virtual_id + + virtual_id } } } pub fn persist(&mut self) -> Result<(), Error> { - if self.dirty { - self.by_virtual_id.store(&self.path)?; + if self.has_pending_changes() { + // Flush pending virtual_id upserts + if !self.pending_virtual_id_upserts.is_empty() { + let batch: Vec<(&u32, &VirtualIdRecord)> = self.pending_virtual_id_upserts + .iter() + .map(|(k, v)| (k, v)) + .collect(); + self.disk_by_virtual_id.upsert_batch(&batch)?; + self.pending_virtual_id_upserts.clear(); + } + + // Flush pending UUID index upserts + if !self.pending_uuid_upserts.is_empty() { + let batch: Vec<(&UUIDType, &u32)> = self.pending_uuid_upserts + .iter() + .map(|(k, v)| (k, v)) + .collect(); + self.disk_by_uuid.upsert_batch(&batch)?; + self.pending_uuid_upserts.clear(); + } + + // Persist virtual_id_counter via B+Tree header metadata + self.disk_by_virtual_id + .set_metadata(&BPlusTreeMetadata::TargetIdMapping(self.virtual_id_counter)) + .map_err(|e| { + error!("Failed to write virtual_id_counter to tree header at {}: {e}", self.path.display()); + e + })?; } - self.dirty = false; Ok(()) } + + /// Check if there are pending changes + pub fn has_pending_changes(&self) -> bool { + !self.pending_virtual_id_upserts.is_empty() || !self.pending_uuid_upserts.is_empty() + } } impl Drop for TargetIdMapping { fn drop(&mut self) { - if let Err(err) = self.persist() { - error!("Failed to persist target id mapping {} err:{err}", &self.path.display()); + if self.has_pending_changes() { + if let Err(err) = self.persist() { + error!("Failed to persist target id mapping {} err:{err}", &self.path.display()); + } } } } #[cfg(test)] mod tests { - // use std::path::PathBuf; - // use crate::repository::bplustree::BPlusTree; - // use crate::repository::target_id_mapping::{VirtualIdRecord}; + use super::*; + use shared::model::PlaylistItemType; + use tempfile::tempdir; - // #[test] - // fn test_id_mapping() { - // let path = PathBuf::from("../m3u-test/settings/m3u-silver/data/xt_m3u/id_mapping.db"); - // let mapping = BPlusTree::::load(&path); - // mapping.unwrap().traverse(|keys, values| { - // for (key, value) in keys.iter().zip(values.iter()) { - // println!("{key:?} {value:?}\n"); - // } - // }); - // } + #[test] + fn test_disk_only_mode() -> Result<(), TuliproxError> { + let dir = tempdir().map_err(|_| info_err!("Failed to create temp dir"))?; + let path = dir.path().join("id_mapping.db"); + + // Create mapping in disk-only mode + let uuid1 = UUIDType::default(); + { + let mut mapping = TargetIdMapping::new(&path, false)?; + let vid1 = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Live, 0); + assert_eq!(vid1, 1); + mapping.persist().map_err(|_| info_err!("Failed to persist mapping"))?; + } + + // Reopen and verify persistence + { + let mut mapping = TargetIdMapping::new(&path, false)?; + let vid1_again = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Live, 0); + assert_eq!(vid1_again, 1); // Should get same virtual_id + } + + Ok(()) + } + + #[test] + fn test_memory_cache_mode() -> Result<(), TuliproxError> { + let dir = tempdir().map_err(|_| info_err!("Failed to create temp dir"))?; + let path = dir.path().join("id_mapping_mem.db"); + + let uuid1 = UUIDType::default(); + { + let mut mapping = TargetIdMapping::new(&path, true)?; + let vid1 = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Video, 0); + assert_eq!(vid1, 1); + mapping.persist().map_err(|err| info_err!("{err}"))?; + } + + // Reopen with memory cache and verify + { + let mut mapping = TargetIdMapping::new(&path, true)?; + let vid1_again = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Video, 0); + assert_eq!(vid1_again, 1); + } + + Ok(()) + } } diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index 8c746a0aa..4df00cea8 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -259,13 +259,16 @@ async fn save_xtream_user_bouquet_for_target(config: &Config, target_name: &str, if let Some(bouquet_categories) = bouquet { if let Some(xtream_categories) = xtream_get_playlist_categories(config, target_name, cluster).await { let filtered: Vec = xtream_categories.iter().filter(|p| bouquet_categories.contains(&p.name)).cloned().collect(); + if filtered.is_empty() { + if tokio::fs::try_exists(&bouquet_path).await.is_ok_and(|r| r) { + tokio::fs::remove_file(bouquet_path).await?; + } + } else { json_write_documents_to_file(&bouquet_path, &filtered).await.map_err(|err| Error::other(format!("Failed to write xtream bouquet file: {err}")))?; + } } } - if bouquet_path.exists() { - tokio::fs::remove_file(bouquet_path).await?; - } Ok(()) } @@ -381,6 +384,8 @@ pub(crate) async fn user_get_series_bouquet(cfg: &Config, username: &str, target /// # Returns /// * `Some(HashSet)` - Set of category IDs or names to include /// * `None` - No filtering applied +/// +/// TODO xtream converts ids to u32 again, separate m3u and xtream handling pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_id: Option, target: TargetType, cluster: XtreamCluster) -> Option> { if let Some(cid) = category_id { if cid > 0 { @@ -402,7 +407,7 @@ pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_i // xtream filter has PlaylistXtreamCategory serde_json::from_str::>(&bouquet_categories) .ok() - .map(|v| v.into_iter().map(|c| c.id).collect()) + .map(|v| v.into_iter().map(|c| c.id.to_string()).collect()) } else { // m3u filter has only group names serde_json::from_str::>(&bouquet_categories).ok() diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index a5fe6e674..3d53344d3 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -1,10 +1,10 @@ use crate::api::model::AppState; -use crate::model::PlaylistXtreamCategory; +use crate::model::{ConfigInput, PlaylistXtreamCategory}; use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{Config, ConfigTarget}; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; use crate::repository::playlist_scratch::PlaylistScratch; -use crate::repository::storage::{get_file_path_for_db_index, get_target_id_mapping_file, get_target_storage_path}; +use crate::repository::storage::{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; @@ -17,10 +17,10 @@ use indexmap::IndexMap; use log::error; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use shared::error::{info_err_res, info_err, notify_err, string_to_io_error, TuliproxError}; +use shared::error::{info_err_res, notify_err, string_to_io_error, TuliproxError}; use shared::model::xtream_const::XTREAM_CLUSTER; use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemType, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem}; -use shared::utils::{arc_str_serde, get_u32_from_serde_value, intern, StringInterner}; +use shared::utils::{arc_str_serde, get_u32_from_serde_value, Internable}; use std::collections::HashMap; use std::fs::File; use std::io::{Error, ErrorKind}; @@ -174,7 +174,7 @@ pub type CategoryKey = (XtreamCluster, Arc); // Because interner is not thread safe we can't use it currently for interning. // We leave the argument for later optimizations. -async fn load_old_category_ids(path: &Path, _interner: &mut StringInterner) -> (u32, HashMap) { +async fn load_old_category_ids(path: &Path) -> (u32, HashMap) { let old_path = path.to_path_buf(); tokio::task::spawn_blocking(move || { let mut result: HashMap = HashMap::new(); @@ -195,7 +195,7 @@ async fn load_old_category_ids(path: &Path, _interner: &mut StringInterner) -> ( if let Some(category_id) = entry.get(crate::model::XC_TAG_CATEGORY_ID).and_then(get_u32_from_serde_value) { if let Value::Object(item) = entry { if let Some(category_name) = get_map_item_as_str(&item, crate::model::XC_TAG_CATEGORY_NAME) { - result.insert((cluster, /*interner.*/intern(&category_name)), category_id); + result.insert((cluster, /*interner.*/category_name.intern()), category_id); max_id = max_id.max(category_id); } } @@ -242,7 +242,6 @@ pub async fn xtream_write_playlist( app_cfg: &Arc, target: &ConfigTarget, playlist: &mut [PlaylistGroup], - interner: &mut StringInterner, ) -> Result<(), TuliproxError> { let path = { let config = app_cfg.config.load(); @@ -256,7 +255,7 @@ pub async fn xtream_write_playlist( let mut series_col = Vec::with_capacity(50_000); let mut vod_col = Vec::with_capacity(50_000); - let categories = create_categories(playlist, &path, interner).await; + let categories = create_categories(playlist, &path).await; { for (xtream_cluster, category) in categories { match xtream_cluster { @@ -320,15 +319,15 @@ pub async fn xtream_write_playlist( Ok(()) } -async fn create_categories(playlist: &mut [PlaylistGroup], path: &Path, interner: &mut StringInterner) -> Vec<(XtreamCluster, CategoryEntry)> { +async fn create_categories(playlist: &mut [PlaylistGroup], path: &Path) -> Vec<(XtreamCluster, CategoryEntry)> { // preserve category_ids - let (max_cat_id, existing_cat_ids) = load_old_category_ids(path, interner).await; + let (max_cat_id, existing_cat_ids) = load_old_category_ids(path).await; let mut cat_id_counter = max_cat_id; let mut new_categories: IndexMap = IndexMap::new(); let mut last_cluster: Option = None; - let mut last_group = intern(""); + let mut last_group = "".intern(); let mut last_category_id: u32 = 0; for plg in playlist.iter_mut() { @@ -578,45 +577,50 @@ pub async fn xtream_load_rewrite_playlist( XtreamPlaylistJsonIterator::new(cluster, config, target, category_id, user).await } -pub async fn iter_raw_xtream_playlist(app_config: &AppConfig, target: &ConfigTarget, cluster: XtreamCluster) -> Option<(FileReadGuard, impl Iterator)> { +pub async fn iter_raw_xtream_target_playlist(app_config: &AppConfig, target: &ConfigTarget, cluster: XtreamCluster) -> Option<(FileReadGuard, Box + Send>)> { let config = app_config.config.load(); - if let Some(storage_path) = xtream_get_storage_path(&config, target.name.as_str()) { - let xtream_path = xtream_get_file_path(&storage_path, cluster); - if !xtream_path.exists() { - return None; - } - let file_lock = app_config.file_locks.read_lock(&xtream_path).await; - match BPlusTreeQuery::::try_new(&xtream_path) - .map_err(|err| info_err!("Could not open BPlusTreeQuery {xtream_path:?} - {err}")) { - Ok(mut query) => { - let index_path = get_file_path_for_db_index(&xtream_path); - let items: Vec = if index_path.exists() { - match query.disk_iter_sorted::() { - Ok(iter) => iter.filter_map(Result::ok).map(|(_, v)| v).collect(), - Err(err) => { - error!("Sorted index error {}: {err}", xtream_path.display()); - // Re-open query for fallback - match BPlusTreeQuery::::try_new(&xtream_path) { - Ok(mut query) => query.iter().map(|(_, v)| v).collect(), - Err(fallback_err) => { - error!("Fallback query also failed {}: {fallback_err}", xtream_path.display()); - Vec::new() - } - } + 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<(FileReadGuard, Box + Send>)> { + let config = app_config.config.load(); + let working_dir = &config.working_dir; + let storage_path = get_input_storage_path(&input.name, working_dir).ok()?; + let xtream_path = xtream_get_file_path(&storage_path, cluster); + + iter_raw_xtream_playlist::(app_config, &xtream_path).await +} + +async fn iter_raw_xtream_playlist(app_config: &AppConfig, xtream_path: &Path) -> Option<(FileReadGuard, Box + Send>)> +where + ItemKey: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Send + 'static, + SortKey: for<'de> Deserialize<'de> + Send + 'static, +{ + let file_lock = app_config.file_locks.read_lock(xtream_path).await; + if !tokio::fs::try_exists(xtream_path).await.unwrap_or(false) { + return None; + } + + let iter: Box + Send> = { + match BPlusTreeQuery::::try_new(xtream_path) { + Ok(tree) => match tree.disk_iter_sorted::() { + Ok(sorted_iter) => Box::new(sorted_iter.filter_map(Result::ok).map(|(_, v)| v)), + Err(_) => { + match BPlusTreeQuery::::try_new(xtream_path) { + Ok(tree) => Box::new(tree.disk_iter().map(|(_, v)| v)), + Err(_) => return None, } } - } else { - query.iter().map(|(_, v)| v).collect() - }; + } + Err(_) => { + return None + } + } + }; - let len = items.len(); - Some((file_lock, items.into_iter().enumerate().map(move |(i, v)| (v, i + 1 < len)))) - } - Err(_) => None - } - } else { - None - } + Some((file_lock, iter)) } pub fn playlist_iter_to_stream(channels: Option<(FileReadGuard, I)>) -> impl Stream> @@ -670,7 +674,7 @@ pub async fn persist_input_xtream_playlist(app_config: &Arc, storage_ // load for cluster in XTREAM_CLUSTER { let xtream_path = xtream_get_file_path(storage_path, cluster); - if let Ok(true) = tokio::fs::try_exists(&xtream_path).await { + if tokio::fs::try_exists(&xtream_path).await.unwrap_or(false) { let file_lock = app_config.file_locks.read_lock(&xtream_path).await; let stored_entries = stored_scratch.get_mut(cluster); if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { @@ -741,7 +745,7 @@ pub async fn persist_input_xtream_playlist(app_config: &Arc, storage_ }; let data = fetched_categories.get_mut(cluster); // if there is no data save only if no file exists! Prevent data loss from failed download attempt - if !data.is_empty() || tokio::fs::try_exists(&col_path).await.is_ok_and(|v| !v) { + if !data.is_empty() || !tokio::fs::try_exists(&col_path).await.unwrap_or(false) { let lock = app_cfg.file_locks.write_lock(&col_path).await; if let Err(err) = json_write_documents_to_file(&col_path, data).await { errors.push(format!("Persisting collection failed: {}: {err}", col_path.display())); @@ -930,7 +934,7 @@ pub async fn load_input_xtream_playlist(app_config: &Arc, storage_pat groups.entry((cluster, cat_id)) .or_insert_with(|| PlaylistGroup { id: cat_id, - title: intern("Unknown"), + title: "Unknown".intern(), channels: Vec::new(), xtream_cluster: cluster, }) diff --git a/backend/src/utils/file/csv_input_reader.rs b/backend/src/utils/file/csv_input_reader.rs index 669ee1342..c23c3c168 100644 --- a/backend/src/utils/file/csv_input_reader.rs +++ b/backend/src/utils/file/csv_input_reader.rs @@ -6,8 +6,9 @@ use std::io; use std::io::{BufRead, Cursor, Error}; use std::path::PathBuf; use url::Url; +use shared::concat_string; use shared::model::{ConfigInputAliasDto, InputType}; -use shared::utils::{get_credentials_from_url, parse_timestamp, trim_last_slash}; +use shared::utils::{get_credentials_from_url, parse_timestamp, trim_last_slash, Internable}; use crate::utils::request::get_local_file_content; const CSV_SEPARATOR: char = ';'; @@ -55,9 +56,9 @@ fn csv_assign_mandatory_fields(alias: &mut ConfigInputAliasDto, input_type: Inpu let username = alias.username.as_deref().unwrap_or_default(); let domain: Vec<&str> = url.domain().unwrap_or_default().split('.').collect(); if domain.len() > 1 { - alias.name = format!("{}_{username}", domain[domain.len() - 2]); + alias.name = concat_string!(domain[domain.len() - 2], "_", username).intern(); } else { - alias.name = username.to_string(); + alias.name = username.intern(); } } } @@ -83,7 +84,7 @@ fn csv_assign_config_input_column(config_input: &mut ConfigInputAliasDto, header config_input.priority = priority; } FIELD_NAME => { - config_input.name = value.to_string(); + config_input.name = value.intern(); } FIELD_USERNAME => { config_input.username = Some(value.to_string()); @@ -142,7 +143,7 @@ pub fn csv_read_inputs_from_reader(batch_input_type: InputType, reader: impl Buf let mut config_input = ConfigInputAliasDto { id: 0, - name: String::new(), + name: "".intern(), url: String::new(), username: None, password: None, diff --git a/backend/src/utils/file/file_lock_manager.rs b/backend/src/utils/file/file_lock_manager.rs index e29a3238e..7bdd74f5b 100644 --- a/backend/src/utils/file/file_lock_manager.rs +++ b/backend/src/utils/file/file_lock_manager.rs @@ -79,6 +79,23 @@ impl FileLockManager { FileWriteGuard::new(guard) } + /// Tries to acquire a write lock using a raw string key instead of a normalized `Path`. + /// + /// Unlike the standard path-based locks, this method does **not** perform any + /// path normalization or conversion. The string is used directly as the lock key, + /// which can be useful for non-file-based identifiers or dynamic keys. + /// + /// Returns immediately with an error if the lock is currently held, rather than + /// waiting for it to become available. + pub async fn try_write_lock_str(&self, text: &str) -> io::Result { + let lock_key = LockKey::Str(text.to_string()); + let file_lock = self.get_or_create_lock(lock_key).await; + match Arc::clone(&file_lock).try_write_owned() { + Ok(lock_guard) => Ok(FileWriteGuard::new(lock_guard)), + Err(_) => Err(str_to_io_error("Failed to acquire write lock")) + } + } + fn get_lock_key_for_path(path: &Path) -> LockKey { let normalized_path = normalize_path(path); diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index a4c664bcc..f1f8a9ee5 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -18,6 +18,7 @@ pub use self::trakt::*; pub use self::telegram::*; pub use self::geoip::*; pub use self::db_viewer::*; +pub use shared::utils::*; #[macro_export] macro_rules! debug_if_enabled { diff --git a/backend/src/utils/network/epg.rs b/backend/src/utils/network/epg.rs index 62d1106c6..21edc47b0 100644 --- a/backend/src/utils/network/epg.rs +++ b/backend/src/utils/network/epg.rs @@ -6,6 +6,7 @@ use log::debug; use shared::error::{TuliproxError, info_err}; use shared::utils::{sanitize_sensitive_info, short_hash}; use std::path::PathBuf; +use shared::concat_string; use crate::processing::processor::playlist::PlaylistProcessingContext; use crate::repository::storage::get_input_storage_path; use crate::repository::storage_const; @@ -17,7 +18,7 @@ pub fn get_input_raw_epg_file_path(url: &str, input: &ConfigInput, working_dir: if let Some(persist_path) = input.persist.as_deref() { if !persist_path.is_empty() { if let Some(path) = prepare_file_path(input.persist.as_deref(), working_dir, "") - .map(|path| add_prefix_to_filename(&path, format!("{file_prefix}_epg_").as_str(), Some("xml"))) { + .map(|path| add_prefix_to_filename(&path, concat_string!(&file_prefix, "_epg_").as_str(), Some("xml"))) { return Ok(path); } } @@ -45,7 +46,7 @@ async fn download_epg_file(url: &str, ctx: &PlaylistProcessingContext, input: &C // Cache miss: file doesn't exist } - let lock_key = persist_file_path.display().to_string(); + let lock_key: std::sync::Arc = persist_file_path.display().to_string().into(); let _input_lock = ctx.get_input_lock(&lock_key).await; if ctx.is_input_downloaded(&lock_key).await { diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index 91bf5eb02..f88b14307 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -637,6 +637,12 @@ pub fn parse_range(range: &str) -> Option<(u64, Option)> { Some((start, end)) } +pub fn is_file_url(url: &str) -> bool { + Url::parse(url) + .map(|u| u.scheme().eq_ignore_ascii_case("file")) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use shared::utils::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info}; diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 6c2f18886..75cfbec71 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -13,9 +13,9 @@ use crate::repository::xtream_repository::{persist_input_vod_info, persists_inpu use crate::utils::request; use chrono::{DateTime, Utc}; use log::{error, info, warn}; -use shared::error::{string_to_io_error, to_io_error, TuliproxError}; +use shared::error::{TuliproxError}; use shared::model::{MsgKind, PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo, XtreamVideoInfoDoc}; -use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info, StringInterner}; +use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info, Internable}; use std::collections::HashMap; use std::io::Error; use std::path::Path; @@ -26,7 +26,8 @@ use crate::utils::request::DynReader; use fs2::FileExt; use std::fs::File; use std::sync::Arc; -use shared::{notify_err, notify_err_res}; + +use shared::{info_err, notify_err, notify_err_res}; const THREE_DAYS_IN_SECS: i64 = 3 * 24 * 60 * 60; @@ -74,22 +75,22 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, target: &ConfigTarget, pli: &XtreamPlaylistItem, info_url: &str, - cluster: XtreamCluster) -> Result { - let xtream_output = target.get_xtream_output().ok_or_else(|| Error::other("Unexpected error, missing xtream output"))?; + cluster: XtreamCluster) -> Result { + let xtream_output = target.get_xtream_output().ok_or_else(|| info_err!("Unexpected error, missing xtream output"))?; let app_config = &app_state.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())); if let Some(content) = pli.get_resolved_info_document(&options) { - return serde_json::to_string(&content).map_err(to_io_error); + return serde_json::to_string(&content).map_err(|err| info_err!("{err}")); } let input_source = InputSource::from(input).with_url(info_url.to_owned()); if let Ok(content) = get_xtream_stream_info_content(client, &input_source, false).await { if content.is_empty() { - return Err(string_to_io_error(format!("Provider returned no response for stream with id: {}/{}/{}", - target.name.replace(' ', "_").as_str(), &cluster, pli.get_virtual_id()))); + return Err(info_err!("Provider returned no response for stream with id: {}/{}/{}", + target.name.replace(' ', "_").as_str(), &cluster, pli.get_virtual_id())); } if let Some(provider_id) = pli.get_provider_id() { match cluster { @@ -136,14 +137,14 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, Ok(info) => { // parse series info let series_stream_props = SeriesStreamProperties::from_info(&info, pli); + if let Ok(storage_path) = get_input_storage_path(&input.name, working_dir) { // update input db if let Err(err) = persists_input_series_info(app_config, &storage_path, cluster, &input.name, provider_id, &series_stream_props).await { error!("Failed to persist series info for input {}: {err}", &input.name); } } - let mut interner = StringInterner::new(); - if let Some(mut episodes) = parse_xtream_series_info(&pli.get_uuid(), &series_stream_props, &group, &series_name, input, &mut interner) { + if let Some(mut episodes) = parse_xtream_series_info(&pli.get_uuid(), &series_stream_props, &group, &series_name, input) { let config = &app_state.app_config.config.load(); match get_target_storage_path(config, target.name.as_str()) { None => { @@ -151,16 +152,17 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, } Some(target_path) => { let mut in_memory_updates = Vec::new(); - let mut provider_series: HashMap> = HashMap::new(); + let mut provider_series: HashMap, Vec> = HashMap::new(); { - let (mut target_id_mapping, _file_lock) = get_target_id_mapping(&app_state.app_config, &target_path).await; + let (mut target_id_mapping, _file_lock) = get_target_id_mapping(&app_state.app_config, &target_path, target.use_memory_cache).await?; + if let Some(parent_id) = pli.get_provider_id() { let category_id = pli.get_category_id().unwrap_or(0); for episode in &mut episodes { episode.header.virtual_id = target_id_mapping.get_and_update_virtual_id(&episode.header.uuid, provider_id, episode.header.item_type, parent_id); episode.header.category_id = category_id; let episode_provider_id = episode.header.get_provider_id().unwrap_or(0); - provider_series.entry(pli.get_uuid().to_string()) + provider_series.entry(pli.get_uuid().intern()) .or_default() .push(ProviderEpisodeKey { provider_id: episode_provider_id, @@ -224,18 +226,18 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, } } - Err(string_to_io_error(format!("Can't find stream with id: {}/{}/{}", - target.name.replace(' ', "_").as_str(), &cluster, pli.get_virtual_id()))) + Err(info_err!("Can't find stream with id: {}/{}/{}", + target.name.replace(' ', "_").as_str(), &cluster, pli.get_virtual_id())) } fn xtream_resolve_stream_info(app_state: &Arc, user: &ProxyUserCredentials, target: &ConfigTarget, xtream_output: &XtreamTargetOutput, - pli: &XtreamPlaylistItem) -> Option> { + pli: &XtreamPlaylistItem) -> Option> { let app_config = &app_state.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())); if let Some(content) = pli.get_resolved_info_document(&options) { - return Some(serde_json::to_string(&content).map_err(to_io_error)); + return Some(serde_json::to_string(&content).map_err(|err| info_err!("Failed to serialize stream info: {err}"))); } None } @@ -487,10 +489,10 @@ pub fn create_vod_info_from_item(target: &ConfigTarget, user: &ProxyUserCredenti doc.info.name.clone_from(name); doc.movie_data.stream_id = stream_id; doc.movie_data.name.clone_from(name); - doc.movie_data.added = added.to_string(); - doc.movie_data.category_id = category_id.to_string(); + doc.movie_data.added = added.intern(); + doc.movie_data.category_id = category_id.intern(); doc.movie_data.category_ids.push(category_id); - doc.movie_data.container_extension = extension; + doc.movie_data.container_extension = extension.intern(); doc.movie_data.custom_sid = None; serde_json::to_string(&doc).unwrap_or(String::new()) @@ -631,7 +633,7 @@ async fn save_xtream_categories_to_file(col_path: &Path, categories: &[XtreamCat let col_path_buf = col_path.to_path_buf(); let cat_entries: Vec = categories.iter().map(|c| CategoryEntry { category_id: c.category_id, - category_name: shared::utils::intern(&c.category_name), + category_name: c.category_name.clone(), parent_id: 0, }).collect(); diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index f2a2ce391..977ec843b 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.42" +version = "3.2.43" edition = "2021" rust-version = "1.89.0" [dependencies] -shared = { version = "3.2.42", path = "../shared" } +shared = { version = "3.2.43", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" @@ -33,6 +33,7 @@ regex = "1.12.2" base64 = "0.22.1" cron = "0.15" fastrand = "2.3.0" +indexmap = "2.12.1" [dependencies.web-sys] version = "0.3" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 3b5ee280d..d5e50e9f2 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -58,7 +58,6 @@ "BUFFER_SIZE": "Buffer Size", "SHARED_BURST_BUFFER_BYTES": "Shared burst buffer (MB)", "ENABLED": "Enabled", - "DISABLED": "Disabled", "SIZE": "Size", "CACHE_DIR": "Cache dir", "NOTIFY_ON": "Notify on", @@ -75,6 +74,8 @@ "DIRECTORY": "Directory", "ORGANIZE_INTO_DIRECTORIES": "Organize Into Directories", "EPISODE_PATTERN": "Episode Pattern", + "API": "Api", + "MAIN": "Main", "MAIN_CONFIG": "Main", "API_CONFIG": "API", "LOG_CONFIG": "Log", @@ -195,6 +196,7 @@ "EDIT": "Edit", "DELETE": "Delete", "ACTIVE": "Active", + "DISABLED": "Disabled", "IGNORE_LOGO": "Ignore logo", "SHARE_LIVE_STREAMS": "Share live", "REMOVE_DUPLICATES": "Remove duplicates", @@ -387,7 +389,10 @@ "BODY_TEMPLATE": "Body Template", "WEBHOOK_URL": "Webhook URL", "AUTO": "Auto", - "API_USER_AGENT": "User Agent" + "API_USER_AGENT": "User Agent", + "ADVANCED": "Advanced", + "TRAKT": "Trakt", + "SEASON": "Season" }, "TITLE": { "USER_BOUQUET_EDITOR": "Playlist Category Selection" diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index e6a0a7a66..63b38cbbc 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -533,5 +533,25 @@ { "keys": [ "VideoLibrary" ], "path": "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zM12 5.5v9l6-4.5z" + }, + { + "keys": [ "Options" ], + "path": "M 11.999906,2 C 6.469904,2 2,6.4699087 2,11.999907 c 0,5.53 4.469904,9.999905 9.999906,9.999905 5.530001,0 9.999907,-4.469905 9.999907,-9.999905 C 21.999813,6.4699087 17.529907,2 11.999906,2 Z m 0,1.999878 c 4.410002,0 8.000029,3.5900297 8.000029,8.000029 0,4.409998 -3.590027,8.000028 -8.000029,8.000028 -4.409998,0 -8.0000282,-3.59003 -8.0000282,-8.000028 0,-4.4099993 3.5900302,-8.000029 8.0000282,-8.000029 z m -4.472595,7.10551 v 1.789038 h 1.7890381 v -1.789038 z m 3.578076,0 v 1.789038 h 1.789038 v -1.789038 z m 3.57756,0 v 1.789038 h 1.789038 v -1.789038 z" + }, + { + "keys": [ "Advanced", "Rocket" ], + "path": "M 21.937636,2 21.867156,2.073804 20.80291,2.1878302 C 18.062974,2.4818921 13.899693,3.2082055 10.390717,6.6042293 9.1339847,6.5797643 7.7103209,6.7338687 6.4086517,7.2669845 5.0027015,7.8428111 3.7202379,8.9408345 3.2550298,10.639434 L 3.0232869,11.48928 6.49943,12.379719 6.6315382,12.813684 5.9263461,13.359091 c -0.3114304,0.241012 -0.6014418,0.645379 -0.4963303,0.964611 0.4820822,1.464129 3.3722308,4.237011 5.0023982,4.782095 0.318745,0.106579 0.714621,-0.217169 0.941364,-0.539134 l 0.512934,-0.729178 0.439499,0.112181 1.046164,3.432599 0.838408,-0.269752 c 1.675927,-0.541228 2.715126,-1.871881 3.227056,-3.302336 0.473959,-1.324355 0.564077,-2.75317 0.483044,-4.007527 3.234561,-3.658347 3.772203,-7.8503652 3.942581,-10.6007606 l 0.06605,-1.0683052 0.07048,-0.073803 -0.06642,0.00738 z M 19.601757,4.5067346 C 19.270801,7.9284136 18.17382,12.187959 12.393372,15.427064 L 9.0164951,12.1989 C 11.9923,6.2778655 16.198274,4.99133 19.601757,4.5067346 Z m -4.9083,2.4709398 c -0.654976,-0.00753 -1.302749,0.2325479 -1.784198,0.7361895 -0.962899,1.0072825 -0.842475,2.6310231 0.177866,3.6064061 1.020342,0.975383 2.648673,1.023518 3.611572,0.01624 0.9629,-1.007283 0.842475,-2.6328684 -0.177866,-3.6082514 C 16.01066,7.2405642 15.348434,6.9852037 14.693457,6.9776744 Z m 0.0015,1.4203476 c 0.286756,0.00565 0.587913,0.122211 0.839146,0.362375 0.502465,0.4803252 0.520776,1.1831605 0.132477,1.589358 -0.388299,0.406196 -1.090212,0.418326 -1.592679,-0.06199 C 13.571432,9.8074335 13.553123,9.1071813 13.94142,8.7009852 14.13557,8.4978867 14.408198,8.3923746 14.694953,8.398022 Z M 8.7582069,8.459279 C 8.2406887,9.1470208 7.7482786,9.9113689 7.289498,10.762316 L 5.4587992,10.294033 C 5.8515306,9.6762833 6.3709104,9.1899043 7.1123657,8.8862319 7.6142475,8.6806798 8.1806174,8.5423885 8.7581829,8.4592789 Z m -1.1343832,5.384331 3.1894163,3.048821 -0.538027,0.763866 c -0.04033,9.28e-4 -0.142939,-0.0057 -0.2952138,-0.0631 C 9.0506641,16.997259 7.4543968,15.816163 6.9614377,14.70748 6.8972622,14.557943 6.8859608,14.455911 6.8850511,14.415587 Z M 5.5783608,15.510462 5.26322,15.745157 c -1.9976954,1.48272 -2.562579,3.791931 -3.1082328,5.833056 L 2,22.157939 2.5730839,22.045758 C 5.8165767,21.409084 7.3740648,20.68256 8.7113177,19.275914 L 9.0430643,18.926824 8.629396,18.686225 C 7.3615243,17.951377 6.4446985,16.937858 5.7846415,15.849589 Z m 10.5627512,0.0063 c -0.05702,0.580725 -0.169669,1.152904 -0.352411,1.663533 -0.269977,0.754377 -0.732716,1.295039 -1.332153,1.715192 l -0.550205,-1.807816 c 0.829425,-0.496633 1.571028,-1.022963 2.234769,-1.570906 z M 5.4399793,16.601646 c 0.6009312,0.899031 1.3946322,1.723037 2.3993505,2.400826 -1.0473448,0.990391 -2.3817924,1.601785 -4.7743458,2.127385 0.4662491,-1.708979 1.0173783,-3.36773 2.3749953,-4.528211 z M 5.0798184,17.795048 3.8742389,20.457508 6.456991,19.20248 6.1470165,18.524226 5.3864718,18.893612 5.7392523,18.112772 Z" + }, + { + "keys": [ "Staged", "Inbox" ], + "path": "M 19,20 H 5 C 3.89,20 3,19.1 3,18 V 6 C 3,4.9 3.89,4 5,4 H 9 V 6 H 5 V 16 H 19 V 6 H 15 V 4 h 4 c 1.1,0 2,0.9 2,2 v 12 c 0,1.1 -0.89,2 -2,2 z M 12,14 8,10 h 3 V 4 h 2 v 6 h 3 z" + }, + { + "keys": [ "Alias" ], + "path": "m 18.460839,3.00001 c -0.9606,0.004 -1.8258,1.29582 -0.9632,2.23063 l 0.3016,0.3267 c -1.267,0.0907 -2.4872,0.41304 -3.6138,1.14529 -0.8417,0.54721 -1.5495,1.25043 -2.1809,2.02092 0.1268,0.17784 0.2535,0.35855 0.3781,0.54721 0.1993,0.30163 0.3948,0.60913 0.5841,0.90628 0.1789,0.28134 0.3557,0.55798 0.5339,0.82915 0.3268,-0.45118 0.6754,-0.88674 1.0677,-1.29173 0.9902,-1.02211 2.1183,-1.44209 3.357,-1.56092 -0.079,0.0947 -0.1549,0.19109 -0.2341,0.28529 -0.4526,0.53777 -0.5289,1.29624 0,1.82533 0.4653,0.46543 1.3719,0.5389 1.8253,0 0.6983,-0.82958 1.3653,-1.68547 2.0636,-2.51505 0.4547,-0.45705 0.6288,-1.21716 0.066,-1.82688 l -2.3231,-2.51689 c -0.2651,-0.28721 -0.5681,-0.4066 -0.8622,-0.40533 z M 3.7938394,4.80711 c -0.1798,0 -0.3627,0.005 -0.5483,0.014 -1.6294,0.0828 -1.6636,2.58328 -0.079,2.58328 0.026,0 0.052,-7.8e-4 0.079,-0.002 0.1789,-0.009 0.3561,-0.0138 0.5322,-0.0138 1.6492,0 3.1669,0.42116 4.3566,1.67286 0.7315,0.7697 1.3087,1.65133 1.8738996,2.54239 0.2681,0.42255 0.5337,0.84679 0.8119,1.26291 0.2392,0.35785 0.4872,0.71333 0.7465,1.05997 0.7391,0.98756 1.5775,1.90122 2.6187,2.57812 1.1266,0.7323 2.3467,1.0546 3.6138,1.14507 l -0.3016,0.32678 c -0.8642,0.93642 0.01,2.23066 0.9677,2.23066 0.2926,0 0.594,-0.11984 0.8576,-0.40531 l 2.3233,-2.51682 c 0.5629,-0.60981 0.3888,-1.36992 -0.066,-1.82688 -0.6981,-0.83002 -1.365,-1.68541 -2.0634,-2.51521 -0.2186,-0.25948 -0.5421,-0.37706 -0.8711,-0.37706 -0.3534,0 -0.7133,0.13613 -0.9542,0.37706 -0.5289,0.5293 -0.4525,1.28765 0,1.82533 0.079,0.0942 0.1556,0.19086 0.2341,0.2855 -1.2387,-0.11866 -2.367,-0.539 -3.357,-1.56102 -0.5633,-0.5814 -1.0385,-1.22432 -1.4847,-1.8891 -0.4324,-0.64414 -0.8371,-1.30904 -1.2664,-1.95935 -0.082,-0.12423 -0.1664,-0.2435 -0.2502,-0.36372 C 9.6415394,6.51829 7.2332394,4.80717 3.7938394,4.80717 Z m 5.8102,7.42417 c -0.4424,0.67295 -0.9108,1.3267 -1.4697,1.9147 -1.3167,1.38513 -3.0351,1.75293 -4.889,1.65873 -1.6645,-0.0847 -1.6564,2.49717 0,2.58142 3.4832,0.17693 5.9452,-1.32799 7.9053996,-3.88999 -0.3228,-0.42077 -0.6211,-0.84458 -0.8941,-1.253 -0.2246,-0.33561 -0.4418996,-0.67869 -0.6525996,-1.01186 z" + }, + { + "keys": [ "Trakt" ], + "path": "m 8.5148718,3.0007766 c -1.0450158,0.0016 -2.0347477,0.00569 -2.8165557,0.014198 -0.9749586,0.010626 -1.7535238,0.525551 -2.41504,1.1480853 -0.6615168,0.6225342 -1.2420491,1.3803092 -1.255398,2.3912657 -0.054734,4.1452564 -0.014494,8.0973684 0,11.3576864 0.00425,0.95513 0.5049474,1.64923 1.0794047,2.298813 0.5744572,0.64958 1.2158261,1.27916 2.248952,1.294359 3.8273691,0.05632 7.1951532,0.05445 11.0869282,0 0.900735,-0.01259 1.655548,-0.434309 2.373765,-0.966147 0.718218,-0.531839 1.46119,-1.152567 1.48026,-2.257537 0.01521,-0.881315 0,-2.321265 0,-2.321265 l -1.917106,0.02014 c 0,0 0.01418,1.445506 0,2.267444 0.0021,-0.123048 -0.217365,0.390857 -0.703974,0.751191 -0.48661,0.360334 -1.179113,0.58793 -1.260351,0.589066 -3.876942,0.05424 -7.2205623,0.05606 -11.0307952,0 0.088118,0.0013 -0.460611,-0.21638 -0.8410046,-0.64652 -0.3803932,-0.430138 -0.5993469,-1.048157 -0.5993021,-1.03813 -0.014538,-3.269123 -0.054357,-7.207571 0,-11.3243374 0.00121,-0.090137 0.2320137,-0.6233247 0.6527937,-1.0193089 0.4207806,-0.3959843 0.9958328,-0.6266544 1.1219997,-0.6280294 3.0843491,-0.033603 9.5518555,0 9.5518555,0 l 0.01023,-1.916777 c 0,0 -3.631619,-0.01899 -6.7666673,-0.014198 z M 20.503229,3.1183256 9.6966363,13.838738 5.6732212,9.8215966 4.7708006,10.725008 12.311113,18.256406 13.214855,17.351674 10.601369,14.742149 21.276215,4.1508427 C 21.059818,3.7752637 20.800627,3.4289481 20.503229,3.1183256 Z m 1.062566,1.6050741 -10.014458,9.9778053 2.182583,2.218905 0.911006,-0.894827 -1.29172,-1.315493 8.594953,-8.5635841 C 21.883198,5.6470001 21.755599,5.1687836 21.565795,4.7233997 Z M 22,7.0080126 14.287986,14.653657 15.187765,15.561031 22,8.8075706 Z M 8.4214271,7.119618 7.516365,8.0220384 l 3.48685,3.4954346 0.905062,-0.90242 z M 7.0359324,8.4862915 6.1325213,9.3897019 9.5969182,12.85641 10.500329,11.952999 Z" } ] \ No newline at end of file diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index da9d850e9..09448ae6b 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -80,7 +80,6 @@ @forward "components/source_editor/source_editor"; @forward "components/source_editor/source_editor_form"; @forward "components/source_editor/source_editor_block"; -@forward "components/source_editor/input_form"; @forward "components/cluster_flags"; @forward "components/titled_card"; @forward "components/filter_input"; diff --git a/frontend/scss/app/components/_form.scss b/frontend/scss/app/components/_form.scss index 646e62dcc..a58f7ff52 100644 --- a/frontend/scss/app/components/_form.scss +++ b/frontend/scss/app/components/_form.scss @@ -35,6 +35,7 @@ } label { + padding-left: 2px; font-weight: bold; white-space: nowrap; vertical-align: middle; diff --git a/frontend/scss/app/components/_input.scss b/frontend/scss/app/components/_input.scss index 7cc02f8e2..c50353133 100644 --- a/frontend/scss/app/components/_input.scss +++ b/frontend/scss/app/components/_input.scss @@ -3,7 +3,7 @@ flex-flow: column; overflow: hidden; box-sizing: border-box; - gap: var(--gap-small); + gap: var(--gap-default); .tp__input-wrapper { display: flex; flex-flow: row nowrap; diff --git a/frontend/scss/app/components/_menu_item.scss b/frontend/scss/app/components/_menu_item.scss index 44b93ba0a..1d0fd01c0 100644 --- a/frontend/scss/app/components/_menu_item.scss +++ b/frontend/scss/app/components/_menu_item.scss @@ -21,6 +21,6 @@ &:hover { background-color: var(--menu-item-hover-background-color); - color: var(--menu-item-hover-color) + color: var(--menu-item-hover-color); } } \ No newline at end of file diff --git a/frontend/scss/app/components/_radio_button_group.scss b/frontend/scss/app/components/_radio_button_group.scss index 116897605..5d83c7538 100644 --- a/frontend/scss/app/components/_radio_button_group.scss +++ b/frontend/scss/app/components/_radio_button_group.scss @@ -1,6 +1,16 @@ .tp__radio-button-group { display: flex; flex-flow: row nowrap; - gap: var(--gap-small); + + button:not(:first-child) { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-right: 0 !important; + } + button:not(:last-child) { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + border-left: 0 !important; + } } \ No newline at end of file diff --git a/frontend/scss/app/components/_sidebar.scss b/frontend/scss/app/components/_sidebar.scss index 36d60986f..64c8cdbf5 100644 --- a/frontend/scss/app/components/_sidebar.scss +++ b/frontend/scss/app/components/_sidebar.scss @@ -61,7 +61,7 @@ .tp__menu-item.active, .tp__icon-button.active { background-color: var(--menu-item-hover-background-color); - color: var(--menu-item-hover-color) + color: var(--menu-item-hover-color); } &__content { diff --git a/frontend/scss/app/components/api_user/_api_user_view.scss b/frontend/scss/app/components/api_user/_api_user_view.scss index fde91d0ac..e31df2035 100644 --- a/frontend/scss/app/components/api_user/_api_user_view.scss +++ b/frontend/scss/app/components/api_user/_api_user_view.scss @@ -72,6 +72,10 @@ .tp__collapse-panel__header { font-size: 1.6rem; + padding: 0; + span { + pointer-events: auto; + } } > :nth-child(1) { @@ -133,4 +137,28 @@ } } + &__section-header { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + gap: var(--gap-default); + box-sizing: border-box; + overflow: hidden; + width: 100%; + + &__toolbar { + display: flex; + flex-flow: row nowrap; + gap: var(--gap-default); + box-sizing: border-box; + .tp__icon-button:hover, + .tp__icon-button:focus-visible { + background-color: var(--action-item-background-color); + } + svg, img { + height: 2rem; + width: 2rem; + } + } + } } \ No newline at end of file diff --git a/frontend/scss/app/components/config/_config_view.scss b/frontend/scss/app/components/config/_config_view.scss index be6a5c76a..666f5f1ad 100644 --- a/frontend/scss/app/components/config/_config_view.scss +++ b/frontend/scss/app/components/config/_config_view.scss @@ -1,5 +1,14 @@ @use "../../../size" as size; +%toolbar-base { + display: flex; + flex-flow: row; + gap: var(--gap-large); + box-sizing: border-box; + width: 100%; + border-top: 1px solid var(--card-border-color); +} + .tp__config-view { display: flex; flex-flow: column; @@ -43,13 +52,7 @@ } &__toolbar { - padding-top: var(--padding-default); - display: flex; - flex-flow: row; - gap: var(--gap-large); - box-sizing: border-box; - width: 100%; - border-top: 1px solid var(--card-border-color); + @extend %toolbar-base; } &__tags { @@ -131,7 +134,6 @@ &__body { display: grid; grid-template-columns: repeat(auto-fit, minmax(size.$config-grid-cell-width, 1fr)); - //max-width: calc(size.$config-max-grid-cells * size.$config-grid-cell-width + 2 * 1rem); gap: var(--gap-large); box-sizing: border-box; width: 100%; @@ -292,4 +294,8 @@ gap: var(--gap-default); box-sizing: border-box; } -} \ No newline at end of file +} + +.tp__form-page__toolbar { + @extend %toolbar-base; +} diff --git a/frontend/scss/app/components/playlist/_playlist_explorer.scss b/frontend/scss/app/components/playlist/_playlist_explorer.scss index 3bf444925..3e8605587 100644 --- a/frontend/scss/app/components/playlist/_playlist_explorer.scss +++ b/frontend/scss/app/components/playlist/_playlist_explorer.scss @@ -1,6 +1,39 @@ $movie-card-height: 24rem; $movie-card-width: 16rem; $live-card-width: 17.5rem; +$episode-card-height: 10rem; +$episode-card-width: 18rem; +$series-backdrop-height: 300px; + +@mixin card-base($height, $width) { + position: relative; + padding: 0; + max-height: $height; + min-height: $height; + max-width: $width; + min-width: $width; + + + .tp__playlist-explorer__channel { + &-logo { + box-sizing: border-box; + height: 100%; + object-fit: cover; + object-position: center; + } + + &-title { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3rem; + background-color: var(--card-background-color); + display: flex; + align-items: center; + } + } +} .tp__playlist-explorer { display: flex; @@ -29,7 +62,7 @@ $live-card-width: 17.5rem; width: 100%; justify-content: space-between; - &-actions { + &-actions { display: flex; flex-flow: row; gap: var(--gap-default); @@ -71,7 +104,7 @@ $live-card-width: 17.5rem; box-sizing: border-box; overflow: hidden; - &-sidebar{ + &-sidebar { .tp__icon-button.active { color: var(--tag-active-color); fill: currentColor; @@ -101,6 +134,7 @@ $live-card-width: 17.5rem; box-sizing: border-box; width: 100%; } + &-video, &-series { display: grid; @@ -109,6 +143,14 @@ $live-card-width: 17.5rem; box-sizing: border-box; width: 100%; } + + &-episodes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax($episode-card-width, 1fr)); + gap: var(--gap-default); + box-sizing: border-box; + width: 100%; + } } } @@ -203,40 +245,16 @@ $live-card-width: 17.5rem; &-logo { box-sizing: border-box; - max-height: 3rem; - min-height: 3rem; - aspect-ratio: 1 / 1; + height: 3rem; + object-fit: cover; + object-position: center; } } } &-video, &-series { - position: relative; - max-height: $movie-card-height; - min-height: $movie-card-height; - max-width: $movie-card-width; - min-width: $movie-card-width; - padding:0; - .tp__playlist-explorer__channel { - &-logo { - box-sizing: border-box; - width: 100%; - height: 100%; - aspect-ratio: 1 / 1; - } - &-title { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 3rem; - background-color: var(--card-background-color); - padding-left: 2rem; - display: flex; - align-items: center; - } - } + @include card-base($movie-card-height, $movie-card-width); .tp__icon-button { position: absolute; z-index: 1; @@ -246,6 +264,78 @@ $live-card-width: 17.5rem; } } + &-episode { + @include card-base($episode-card-height, $episode-card-width); + } } + &__series-info { + display: flex; + flex-flow: column; + gap: var(--gap-default); + box-sizing: border-box; + overflow: hidden; + + &__nowrap { + white-space: nowrap; + } + + &__body { + display: flex; + flex-flow: column; + gap: var(--gap-default); + box-sizing: border-box; + overflow: auto; + + .tp__playlist-explorer__group-list { + overflow: unset; + } + + &-top { + display: flex; + flex-flow: column; + gap: var(--gap-default); + min-height: $series-backdrop-height; + background-size: cover; + background-position: right top; + + font-size: 1.2rem; + box-sizing: border-box; + + &-content { + display: flex; + flex-flow: column; + gap: var(--gap-default); + max-width: 60%; + background-size: cover; + background-position: center; + font-size: 1.2rem; + } + } + } + + &__title { + font-weight: bold; + font-size: 2.5rem; + color: var(--series-info-title-color, white); + } + + &__infos { + display: flex; + flex-flow: row; + gap: var(--gap-larger); + } + + &__season { + color: var(--series-info-title-color, white); + display: flex; + flex-flow: column; + gap: var(--gap-default); + + &-title { + font-weight: bold; + font-size: 2rem; + } + } + } } \ No newline at end of file diff --git a/frontend/scss/app/components/source_editor/_input_form.scss b/frontend/scss/app/components/source_editor/_input_form.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/scss/app/components/source_editor/_source_editor.scss b/frontend/scss/app/components/source_editor/_source_editor.scss index 7f41e3324..ecde03532 100644 --- a/frontend/scss/app/components/source_editor/_source_editor.scss +++ b/frontend/scss/app/components/source_editor/_source_editor.scss @@ -101,7 +101,7 @@ } &__selection-rect { - position:absolute; + position: absolute; display: none; border: 1px dashed var(--selection-area-border-color); background: var(--selection-area-background-color); @@ -109,8 +109,12 @@ pointer-events: none; } + .tp__form-page__toolbar { + padding: var(--padding-default) 0 var(--padding-default) var(--padding-small); + } } + .graph-paper-advanced { background-image: linear-gradient(var(--graph-paper-line-color-1) 1px, transparent 1px), @@ -132,14 +136,3 @@ .clickable { cursor: pointer; } - -.tp__form-page__toolbar { - margin-top: var(--padding-default); - padding-top: var(--padding-default); - display: flex; - flex-flow: row; - gap: var(--gap-large); - box-sizing: border-box; - width: 100%; - border-top: 1px solid var(--card-border-color); -} \ No newline at end of file diff --git a/frontend/scss/app/components/source_editor/_source_editor_form.scss b/frontend/scss/app/components/source_editor/_source_editor_form.scss index 65cdbcfc1..30c49a544 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_form.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_form.scss @@ -1,4 +1,4 @@ -$form-width: 380px; +$form-width: 480px; .tp__source-editor-form-wrapper { position: absolute; @@ -21,12 +21,17 @@ $form-width: 380px; .tp__source-editor-form { + padding: 0 var(--padding-micro); + background-color: var(--card-background-color); + &__body { background-color: var(--card-background-color); border: 1px solid var(--card-border-color); display: flex; flex-flow: column; + width: 100%; + overflow: hidden; &__pages { display: flex; @@ -39,15 +44,40 @@ $form-width: 380px; } } + &__content { + display: flex; + flex-flow: row; + gap: var(--gap-default); + box-sizing: border-box; + overflow: hidden; + } + .tp__form-list__item { display: flex; flex-flow: row nowrap; gap: var(--gap-small); align-items: center; + + &-toolbar { + display: flex; + flex-flow: row nowrap; + } + } + + .tp__form-list__toolbar { + margin-top: var(--margin-default); + } + + &__sidebar { + .tp__icon-button.active { + background-color: var(--menu-item-hover-background-color); + color: var(--menu-item-hover-color); + } + .tp__icon-button { - fill: var(--menu-button-warn-color); - &:hover { - fill: var(--menu-button-warn-hover-color); + .svg-icon { + width: 2rem; + height: 2rem; } } } diff --git a/frontend/src/app/components/api_user/target_playlist.rs b/frontend/src/app/components/api_user/target_playlist.rs index 76e1914b9..07567357d 100644 --- a/frontend/src/app/components/api_user/target_playlist.rs +++ b/frontend/src/app/components/api_user/target_playlist.rs @@ -1,12 +1,15 @@ use std::cell::RefCell; -use crate::app::components::{AppIcon, Card, CollapsePanel}; +use crate::app::components::{AppIcon, Card, CollapsePanel, IconButton, RadioButtonGroup}; use shared::model::{PlaylistClusterBouquetDto, PlaylistClusterCategoriesDto, XtreamCluster}; use std::collections::HashMap; +use std::fmt::{Display, Formatter}; use std::rc::Rc; use std::str::FromStr; use wasm_bindgen::JsCast; use yew::prelude::*; use yew_i18n::use_translation; +use shared::error::TuliproxError; +use shared::info_err_res; use crate::html_if; fn normalize(s: &str) -> String { @@ -39,6 +42,44 @@ macro_rules! create_selection { }; } +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FilterState { + All, + Selected, + Deselected, +} + +impl FilterState { + const ALL: &'static str = "All"; + const SELECTED: &'static str = "Selected"; + const DESELECTED: &'static str = "Deselected"; +} + +impl Display for FilterState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", + match self { + Self::All => Self::ALL, + Self::Selected => Self::SELECTED, + Self::Deselected => Self::DESELECTED, + } + ) + } +} + +impl FromStr for FilterState { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + match s { + Self::ALL => Ok(Self::All), + Self::SELECTED => Ok(Self::Selected), + Self::DESELECTED => Ok(Self::Deselected), + _ => info_err_res!("Unknown FilterState: {s}"), + } + } +} + #[derive(Clone, PartialEq, Default)] pub struct BouquetSelection { pub live: HashMap, @@ -58,6 +99,8 @@ pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { let translate = use_translation(); let bouquet_selection = use_mut_ref(BouquetSelection::default); let playlist_categories = use_state(PlaylistClusterCategoriesDto::default); + let filter_state = use_state(HashMap::::new); + let collapse_state = use_state(HashMap::::new); let force_update = use_state(|| 0); { @@ -107,6 +150,7 @@ pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { let force_update = force_update.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); if let Some(target) = e.target() { if let Ok(element) = target.dyn_into::() { if let Some(cluster) = element.get_attribute("data-cluster") { @@ -137,17 +181,112 @@ pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { }) }; + let handle_selection_change = { + let bouquet_selection = bouquet_selection.clone(); + let on_change = props.on_change.clone(); + let force_update = force_update.clone(); + + Callback::from(move |(cluster, cats, select): (XtreamCluster, Vec, bool)| { + { + let mut selections = bouquet_selection.borrow_mut(); + let map = match cluster { + XtreamCluster::Live => &mut selections.live, + XtreamCluster::Video => &mut selections.vod, + XtreamCluster::Series => &mut selections.series, + }; + for cat in cats { + map.insert(cat, select); + } + } + on_change.emit(bouquet_selection.clone()); + force_update.set(*force_update + 1); + }) + }; + let render_category_cluster = |cluster: XtreamCluster, cats: Option<&Vec>, selections: &HashMap| { if let Some(c) = cats { + let cats_clone = c.clone(); + let cluster_clone = cluster; + let handler = handle_selection_change.clone(); + let current_filter = *filter_state.get(&cluster).unwrap_or(&FilterState::All); + + let select_all = { + let cats = cats_clone.clone(); + let handler = handler.clone(); + Callback::from(move |(_, event): (String, MouseEvent)| { + event.stop_propagation(); + handler.emit((cluster_clone, cats.clone(), true)); + }) + }; + + let deselect_all = { + let cats = cats_clone.clone(); + let handler = handler.clone(); + Callback::from(move |(_, event): (String, MouseEvent)| { + event.stop_propagation(); + handler.emit((cluster_clone, cats.clone(), false)); + }) + }; + + let filter_state_handle = filter_state.clone(); + let filter_state_selections = Rc::new(vec![filter_state.get(&cluster).cloned().unwrap_or(FilterState::All).to_string()]); + let title_content = if *collapse_state.get(&cluster).unwrap_or(&true) { + html! { +

+ } + } else { + html! { +
+ {translate.t( match cluster { + XtreamCluster::Live => "LABEL.LIVE", + XtreamCluster::Video => "LABEL.MOVIE", + XtreamCluster::Series => "LABEL.SERIES" + })} +
+ } + }; + + let collapse_state = collapse_state.clone(); html_if!(!c.is_empty(), { - "LABEL.LIVE", - XtreamCluster::Video => "LABEL.MOVIE", - XtreamCluster::Series => "LABEL.SERIES" +
- { for c.iter().map(|cat| { + { for c.iter().filter(|cat| { + let selected = *selections.get(*cat).unwrap_or(&false); + match current_filter { + FilterState::All => true, + FilterState::Selected => selected, + FilterState::Deselected => !selected, + } + }).map(|cat| { let selected = *selections.get(cat).unwrap_or(&false); html! {
Html { let click = props.onclick.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); if let Some(target) = e.target() { if let Ok(element) = target.dyn_into::() { if let Some(data_name) = element.get_attribute("data-name") { diff --git a/frontend/src/app/components/collapse_panel.rs b/frontend/src/app/components/collapse_panel.rs index 0358378f2..ee5abc006 100644 --- a/frontend/src/app/components/collapse_panel.rs +++ b/frontend/src/app/components/collapse_panel.rs @@ -12,6 +12,8 @@ pub struct CollapsePanelProps { pub children: Children, #[prop_or_default] pub class: String, + #[prop_or_default] + pub on_state_change: Callback, } #[function_component] @@ -21,7 +23,12 @@ pub fn CollapsePanel(props: &CollapsePanelProps) -> Html { let toggle = { let expanded = expanded.clone(); - Callback::from(move |_| expanded.set(!*expanded)) + let on_state_change = props.on_state_change.clone(); + Callback::from(move |_| { + let new_state = !*expanded; + expanded.set(new_state); + on_state_change.emit(new_state); + }) }; html! { diff --git a/frontend/src/app/components/config/macros.rs b/frontend/src/app/components/config/macros.rs index 5134803c5..a624aa748 100644 --- a/frontend/src/app/components/config/macros.rs +++ b/frontend/src/app/components/config/macros.rs @@ -185,9 +185,9 @@ macro_rules! edit_field_text { hidden={$hidden} name={stringify!($field)} autocomplete={true} - value={instance.form.$field.clone()} + value={instance.form.$field.to_string()} on_change={Callback::from(move |value: String| { - instance.dispatch($action(value)); + instance.dispatch($action(value.into())); })} />
@@ -498,7 +498,7 @@ macro_rules! generate_form_reducer { $( $action_name::$set_name(v) => { let mut new_data = self.$data_field.clone(); - new_data.$field_name = v; + new_data.$field_name = v.into(); if !modified { modified = true; } new_data }, diff --git a/frontend/src/app/components/dashboard/stats_view.rs b/frontend/src/app/components/dashboard/stats_view.rs index 3830451a0..1713120d6 100644 --- a/frontend/src/app/components/dashboard/stats_view.rs +++ b/frontend/src/app/components/dashboard/stats_view.rs @@ -25,7 +25,7 @@ pub fn StatsView() -> Html { html! { diff --git a/frontend/src/app/components/dashboard/streams_table.rs b/frontend/src/app/components/dashboard/streams_table.rs index 65c691687..671abbf87 100644 --- a/frontend/src/app/components/dashboard/streams_table.rs +++ b/frontend/src/app/components/dashboard/streams_table.rs @@ -180,7 +180,7 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { } } - "USERNAME" => html! {dto.username.as_str()}, + "USERNAME" => html! {&dto.username}, "STREAM_ID" => html! { <> { dto.channel.virtual_id.to_string() } {" ("} @@ -188,13 +188,13 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { {")"} }, "CLUSTER" => html! { render_cluster(&dto.channel) }, - "CHANNEL" => html! {dto.channel.title.as_str()}, + "CHANNEL" => html! {&dto.channel.title}, "GROUP" => html! {&*dto.channel.group}, "CLIENT_IP" => html! { strip_port(&dto.client_ip)}, "COUNTRY" => html! { dto.country.as_ref().map_or_else(String::new, |c| t_safe(&translate, &format!("COUNTRY.{c}")).unwrap_or_else(||c.to_string())) }, - "PROVIDER" => html! {dto.provider.as_str()}, + "PROVIDER" => html! {&dto.provider}, "SHARED" => html! { }, - "USER_AGENT" => html! { {dto.user_agent.as_str()} }, + "USER_AGENT" => html! { {&dto.user_agent} }, "DURATION" => html! { {format_duration(current_time_secs() - dto.ts)} }, _ => html! {""}, } @@ -268,7 +268,7 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { } StreamsTableAction::CopyLinkProviderUrl => { if let Some(dto) = &*selected_dto { - copy_to_clipboard.emit(dto.channel.url.clone()); + copy_to_clipboard.emit(dto.channel.url.to_string()); } } StreamsTableAction::CopyLinkTuliproxWebPlayerUrl => { diff --git a/frontend/src/app/components/drop_down_icon_button.rs b/frontend/src/app/components/drop_down_icon_button.rs index 2b167f229..9e88707c4 100644 --- a/frontend/src/app/components/drop_down_icon_button.rs +++ b/frontend/src/app/components/drop_down_icon_button.rs @@ -71,6 +71,7 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html { let set_is_open = popup_is_open.clone(); Callback::from(move |(_name, event): (String, MouseEvent)| { event.prevent_default(); + event.stop_propagation(); if let Some(button) = button_ref.cast::() { set_anchor_ref.set(Some(button)); set_is_open.set(true); @@ -86,6 +87,7 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html { let set_is_open = popup_is_open.clone(); Callback::from(move |(id, e): (String, MouseEvent)| { e.prevent_default(); + e.stop_propagation(); if selections.current().contains(&id) { selections.remove(&id); } else { diff --git a/frontend/src/app/components/filter/filter_input.rs b/frontend/src/app/components/filter/filter_input.rs index 4ccb2e9cb..f0575b1a1 100644 --- a/frontend/src/app/components/filter/filter_input.rs +++ b/frontend/src/app/components/filter/filter_input.rs @@ -78,6 +78,7 @@ pub fn FilterInput(props: &FilterInputProps) -> Html { let dialog_actions = dialog_actions.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); let original_filter = (*filter_state).clone(); let original_templates = (*templates_state).clone(); let current_filter = (*filter_state).clone(); diff --git a/frontend/src/app/components/hide_content.rs b/frontend/src/app/components/hide_content.rs index cc11d42db..5ba3cd55e 100644 --- a/frontend/src/app/components/hide_content.rs +++ b/frontend/src/app/components/hide_content.rs @@ -17,6 +17,7 @@ pub fn HideContent(props: &HideContentProps) -> Html { let hidden = hidden.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); hidden.set(!*hidden); }) }; diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index 66cb14700..79044ae26 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -6,6 +6,7 @@ use shared::model::{AppConfigDto, ConfigInputDto, LibraryScanSummaryStatus, Play use std::collections::HashMap; use std::future; use std::rc::Rc; +use std::sync::Arc; use yew::prelude::*; use yew::suspense::use_future; use yew_i18n::use_translation; @@ -105,7 +106,7 @@ pub fn Home() -> Html { if let Some(cfg) = config_ctx.as_ref() { let mut sources = vec![]; // Create a map for a faster lookup of global inputs by name - let inputs_map: HashMap = cfg.sources.inputs.iter().map(|i| (i.name.clone(), i)).collect(); + let inputs_map: HashMap, &ConfigInputDto> = cfg.sources.inputs.iter().map(|i| (i.name.clone(), i)).collect(); for source in &cfg.sources.sources { let mut inputs = vec![]; diff --git a/frontend/src/app/components/icon_button.rs b/frontend/src/app/components/icon_button.rs index a37188816..882618946 100644 --- a/frontend/src/app/components/icon_button.rs +++ b/frontend/src/app/components/icon_button.rs @@ -10,6 +10,8 @@ pub struct IconButtonProps { #[prop_or_default] pub class: String, #[prop_or_default] + pub hint: String, + #[prop_or_default] pub button_ref: Option, } @@ -21,12 +23,13 @@ pub fn IconButton(props: &IconButtonProps) -> Html { let name = props.name.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); click.emit((name.clone(), e)); }) }; html! { - } diff --git a/frontend/src/app/components/key_value_editor.rs b/frontend/src/app/components/key_value_editor.rs index c1cef7ef8..ca28284af 100644 --- a/frontend/src/app/components/key_value_editor.rs +++ b/frontend/src/app/components/key_value_editor.rs @@ -96,6 +96,7 @@ pub fn KeyValueEditor(props: &KeyValueEditorProps) -> Html { Callback::from(move |e: KeyboardEvent| { if e.key() == "Enter" { e.prevent_default(); + e.stop_propagation(); let key = (*new_key).trim().to_string(); let value = (*new_value).trim().to_string(); if !key.is_empty() && !value.is_empty() && !entry_state.iter().any(|kv| kv.key == key) { diff --git a/frontend/src/app/components/login.rs b/frontend/src/app/components/login.rs index 018fc98be..66fbb3bd1 100644 --- a/frontend/src/app/components/login.rs +++ b/frontend/src/app/components/login.rs @@ -61,6 +61,7 @@ pub fn Login() -> Html { Callback::from(move |e: KeyboardEvent| { if e.key() == "Enter" { e.prevent_default(); + e.stop_propagation(); authenticator.run(); } }) diff --git a/frontend/src/app/components/menu_item.rs b/frontend/src/app/components/menu_item.rs index c98680368..5397a2cac 100644 --- a/frontend/src/app/components/menu_item.rs +++ b/frontend/src/app/components/menu_item.rs @@ -23,6 +23,7 @@ pub fn MenuItem(props: &MenuItemProps) -> Html { let name = props.name.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); click.emit((name.clone(), e)); }) }; diff --git a/frontend/src/app/components/number_input.rs b/frontend/src/app/components/number_input.rs index fa0bde0f7..37ced43a8 100644 --- a/frontend/src/app/components/number_input.rs +++ b/frontend/src/app/components/number_input.rs @@ -87,6 +87,7 @@ pub fn NumberInput(props: &NumberInputProps) -> Html { if !allowed { e.prevent_default(); + e.stop_propagation(); } }) }; diff --git a/frontend/src/app/components/playlist/epg_source_selector.rs b/frontend/src/app/components/playlist/epg_source_selector.rs index 2a8c22595..abee05582 100644 --- a/frontend/src/app/components/playlist/epg_source_selector.rs +++ b/frontend/src/app/components/playlist/epg_source_selector.rs @@ -138,7 +138,7 @@ pub fn EpgSourceSelector(props: &EpgSourceSelectorProps) -> Html { }; if let Some((name, id)) = result { html! { - } } else { diff --git a/frontend/src/app/components/playlist/input_table.rs b/frontend/src/app/components/playlist/input_table.rs index e50d99c88..883422cf2 100644 --- a/frontend/src/app/components/playlist/input_table.rs +++ b/frontend/src/app/components/playlist/input_table.rs @@ -114,7 +114,7 @@ pub fn InputTable(props: &InputTableProps) -> Html { 1 => html! { }, - 2 => html! { dto.name.as_str() }, + 2 => html! { dto.name.as_ref() }, 3 => html! { }, 4 => html! { if matches!(dto.input_type, InputType::XtreamBatch | InputType::M3uBatch) { @@ -165,7 +165,7 @@ pub fn InputTable(props: &InputTableProps) -> Html { }, - 2 => html! { alias.name.as_str() }, + 2 => html! { alias.name.as_ref() }, 4 => html! { alias.url.as_str() }, 5 => alias.username.as_ref().map_or_else(|| html!{}, |u| html!{u}), 6 => alias.password.as_ref().map_or_else(|| html!{}, |pwd| html! { }), @@ -210,7 +210,7 @@ pub fn InputTable(props: &InputTableProps) -> Html { if result == DialogResult::Ok { if let (Some(on_delete), Some(selected)) = (on_delete, &*selected_dto) { if let InputRow::Input(dto) = &**selected { - on_delete.emit(dto.name.clone()); + on_delete.emit(dto.name.to_string()); } } } diff --git a/frontend/src/app/components/playlist/playlist_explorer.rs b/frontend/src/app/components/playlist/playlist_explorer.rs index 68f7d010f..06d32b255 100644 --- a/frontend/src/app/components/playlist/playlist_explorer.rs +++ b/frontend/src/app/components/playlist/playlist_explorer.rs @@ -1,25 +1,34 @@ +use crate::app::components::menu_item::MenuItem; +use crate::app::components::popup_menu::PopupMenu; +use crate::app::components::{AppIcon, IconButton, NoContent, Panel, Search}; +use crate::app::context::PlaylistExplorerContext; +use crate::hooks::use_service_context; +use crate::html_if; +use crate::model::{BusyStatus, EventMessage}; +use crate::services::DialogService; +use shared::error::{info_err_res, TuliproxError}; +use shared::model::{CommonPlaylistItem, PlaylistRequest, SearchRequest, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, StreamProperties, UiPlaylistGroup, VirtualId, XtreamCluster}; +use std::collections::HashMap; use std::fmt::Display; use std::rc::Rc; use std::str::FromStr; use wasm_bindgen::JsCast; use yew::platform::spawn_local; -use crate::app::context::PlaylistExplorerContext; use yew::prelude::*; use yew_hooks::use_clipboard; use yew_i18n::use_translation; -use shared::error::{TuliproxError, info_err_res}; -use shared::model::{CommonPlaylistItem, PlaylistRequest, SearchRequest, UiPlaylistGroup, XtreamCluster}; -use crate::app::components::{AppIcon, IconButton, NoContent, Panel, Search}; -use crate::app::components::menu_item::MenuItem; -use crate::app::components::popup_menu::PopupMenu; -use crate::hooks::use_service_context; -use crate::html_if; -use crate::model::{BusyStatus, EventMessage}; -use crate::services::DialogService; const COPY_LINK_TULIPROX_VIRTUAL_ID: &str = "copy_link_tuliprox_virtual_id"; const COPY_LINK_TULIPROX_WEBPLAYER_URL: &str = "copy_link_tuliprox_webplayer_url"; const COPY_LINK_PROVIDER_URL: &str = "copy_link_provider_url"; +const BACKGROUND_GRADIENT: &str = "linear-gradient(to right, rgba(0, 0, 0, 0.90) 30%, rgba(0, 0, 0, 0.75) 50%, rgba(0, 0, 0, 0.55) 60%, rgba(0, 0, 0, 0.35) 70%, rgba(0, 0, 0, 0.0) 85%)"; + +#[derive(Clone)] +struct ChannelSelection { + virtual_id: VirtualId, + cluster: XtreamCluster, + url: String, +} #[allow(clippy::enum_variant_names)] #[derive(Debug, Clone, Eq, PartialEq)] @@ -58,6 +67,7 @@ impl FromStr for ExplorerAction { enum ExplorerLevel { Categories, Group(Rc), + SeriesInfo(Rc, Rc, Option>), } #[function_component] @@ -68,7 +78,7 @@ pub fn PlaylistExplorer() -> Html { let service_ctx = use_service_context(); let current_item = use_state(|| ExplorerLevel::Categories); let playlist = use_state(|| (*context.playlist).clone()); - let selected_channel = use_state(|| None::>); + let selected_channel = use_state(|| None::); let popup_anchor_ref = use_state(|| None::); let popup_is_open = use_state(|| false); let clipboard = use_clipboard(); @@ -76,14 +86,13 @@ pub fn PlaylistExplorer() -> Html { let handle_cluster_change = { let cluster_vis = cluster_visible.clone(); - Callback::from(move |(name, _event) : (String, MouseEvent)| { + Callback::from(move |(name, _event): (String, MouseEvent)| { if let Ok(xc) = XtreamCluster::from_str(name.as_str()) { cluster_vis.set(xc); } }) }; - let handle_popup_close = { let set_is_open = popup_is_open.clone(); Callback::from(move |()| { @@ -96,14 +105,80 @@ pub fn PlaylistExplorer() -> Html { let set_anchor_ref = popup_anchor_ref.clone(); let set_is_open = popup_is_open.clone(); Callback::from(move |(dto, event): (Rc, MouseEvent)| { + event.prevent_default(); + event.stop_propagation(); if let Some(target) = event.target_dyn_into::() { - set_selected_channel.set(Some(dto.clone())); + set_selected_channel.set(Some(ChannelSelection { + virtual_id: dto.virtual_id, + cluster: dto.xtream_cluster.unwrap_or_default(), + url: dto.url.to_string(), + })); set_anchor_ref.set(Some(target)); set_is_open.set(true); } }) }; + let handle_episode_popup_onclick = { + let set_selected_channel = selected_channel.clone(); + let set_anchor_ref = popup_anchor_ref.clone(); + let set_is_open = popup_is_open.clone(); + Callback::from(move |(dto, event): (ChannelSelection, MouseEvent)| { + event.prevent_default(); + event.stop_propagation(); + if let Some(target) = event.target_dyn_into::() { + set_selected_channel.set(Some(dto)); + set_anchor_ref.set(Some(target)); + set_is_open.set(true); + } + }) + }; + + let load_series_info = { + let set_current_item = current_item.clone(); + let services = service_ctx.clone(); + let ctx = context.clone(); + + move |group: Rc, dto: Rc| { + if let Some(StreamProperties::Series(series_props)) = dto.additional_properties.as_ref() { + if series_props.details.as_ref().is_none_or(|d| d.episodes.as_ref().is_none_or(|e| e.is_empty())) { + let set_current_item = set_current_item.clone(); + let services = services.clone(); + let ctx = ctx.clone(); + services.event.broadcast(EventMessage::Busy(BusyStatus::Show)); + spawn_local(async move { + let mut handled = false; + if let Some(playlist_request) = ctx.playlist_request.as_ref() { + if let Some(props) = services.playlist.get_series_info(&dto, playlist_request).await { + handled = true; + set_current_item.set(ExplorerLevel::SeriesInfo(group.clone(), dto.clone(), Some(Box::new(props)))); + } + } + if !handled { + set_current_item.set(ExplorerLevel::SeriesInfo(group, dto, None)); + } + services.event.broadcast(EventMessage::Busy(BusyStatus::Hide)); + }); + } else { + set_current_item.set(ExplorerLevel::SeriesInfo(group, dto, None)); + }; + } else { + set_current_item.set(ExplorerLevel::SeriesInfo(group, dto, None)); + } + } + }; + + let handle_series_onclick = { + let set_current_item = current_item.clone(); + Callback::from(move |(dto, event): (Rc, MouseEvent)| { + event.prevent_default(); + event.stop_propagation(); + if let ExplorerLevel::Group(ref group) = *set_current_item { + load_series_info(group.clone(), dto.clone()); + } + }) + }; + { let set_playlist = playlist.clone(); let set_current_item = current_item.clone(); @@ -159,7 +234,7 @@ pub fn PlaylistExplorer() -> Html { let copy_to_clipboard = copy_to_clipboard.clone(); let services = services.clone(); let virtual_id = dto.virtual_id; - let cluster = dto.xtream_cluster.unwrap_or_default(); + let cluster = dto.cluster; let translate_clone = translate_clone.clone(); let target_id = *target_id; spawn_local(async move { @@ -180,7 +255,7 @@ pub fn PlaylistExplorer() -> Html { } ExplorerAction::CopyLinkProviderUrl => { if let Some(dto) = &*selected_channel { - copy_to_clipboard.emit(dto.url.clone()); + copy_to_clipboard.emit(dto.url.to_string()); } } } @@ -197,6 +272,9 @@ pub fn PlaylistExplorer() -> Html { ExplorerLevel::Group(_) => { current_item.set(ExplorerLevel::Categories); } + ExplorerLevel::SeriesInfo(ref group, _, _) => { + current_item.set(ExplorerLevel::Group(group.clone())); + } } }) }; @@ -305,11 +383,11 @@ pub fn PlaylistExplorer() -> Html { }; let render_channel_logo = |chan: &Rc| { - let logo = if chan.logo.is_empty() { chan.logo_small.as_str() } else { chan.logo.as_str() }; + let logo = if chan.logo.is_empty() { &chan.logo_small } else { &chan.logo }; if logo.is_empty() { html! {} } else { - html! { () { @@ -329,8 +407,15 @@ pub fn PlaylistExplorer() -> Html { group.channels.iter().map(|chan| { let chan_clone = chan.clone(); let popup_onclick = handle_popup_onclick.clone(); + let chan_click = if group.xtream_cluster == XtreamCluster::Series { + let chan_clone = chan.clone(); + let series_click = handle_series_onclick.clone(); + Some(Callback::from(move |event: MouseEvent| series_click.emit((chan_clone.clone(), event)))) + } else { + None + }; html! { - + + {e.title.clone()} + + + }}) + } +
+ + } + ) + } + } else { + Html::default() + }; + + html! { +
+
+ { series_html } +
+
+ {episodes_html} +
+
+ } + }; + html! {
@@ -356,6 +565,7 @@ pub fn PlaylistExplorer() -> Html { match *current_item { ExplorerLevel::Categories => html!{} , ExplorerLevel::Group(ref group) => html!{ {&group.title} }, + ExplorerLevel::SeriesInfo(_, ref pli, _) => html!{ {&pli.title} }, } }
@@ -369,6 +579,7 @@ pub fn PlaylistExplorer() -> Html { match *current_item { ExplorerLevel::Categories => html!{render_categories()} , ExplorerLevel::Group(ref group) => html!{ render_group(group) }, + ExplorerLevel::SeriesInfo(_, ref pli, ref props) => html!{ render_series_info(pli, props.as_ref()) }, } }
diff --git a/frontend/src/app/components/playlist/playlist_source_selector.rs b/frontend/src/app/components/playlist/playlist_source_selector.rs index 6ff5bb5e6..0ad9430a4 100644 --- a/frontend/src/app/components/playlist/playlist_source_selector.rs +++ b/frontend/src/app/components/playlist/playlist_source_selector.rs @@ -204,7 +204,7 @@ pub fn PlaylistSourceSelector(props: &PlaylistSourceSelectorProps) -> Html { }; if let Some((name, id)) = result { html! { - } } else { diff --git a/frontend/src/app/components/reveal_content.rs b/frontend/src/app/components/reveal_content.rs index 3e46f5a6d..e73344c07 100644 --- a/frontend/src/app/components/reveal_content.rs +++ b/frontend/src/app/components/reveal_content.rs @@ -25,6 +25,7 @@ pub fn RevealContent(props: &RevealContentProps) -> Html { let actions = props.actions.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); let content = content.clone(); let actions = actions.clone(); let dlg = dialog.clone(); diff --git a/frontend/src/app/components/source_editor/alias_item_form.rs b/frontend/src/app/components/source_editor/alias_item_form.rs index 3f50574dc..286cf0e73 100644 --- a/frontend/src/app/components/source_editor/alias_item_form.rs +++ b/frontend/src/app/components/source_editor/alias_item_form.rs @@ -1,8 +1,10 @@ +use std::sync::Arc; use crate::app::components::{Card, TextButton}; use crate::{edit_field_date, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer}; use shared::model::ConfigInputAliasDto; use yew::{function_component, html, use_reducer, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; +use shared::utils::Internable; const LABEL_ALIAS_NAME: &str = "LABEL.ALIAS_NAME"; const LABEL_URL: &str = "LABEL.URL"; @@ -16,7 +18,7 @@ generate_form_reducer!( state: AliasFormState { form: ConfigInputAliasDto }, action_name: AliasFormAction, fields { - Name => name: String, + Name => name: Arc, Url => url: String, Username => username: Option, Password => password: Option, @@ -42,7 +44,7 @@ pub fn AliasItemForm(props: &AliasItemFormProps) -> Html { AliasFormState { form: props.initial.clone().unwrap_or_else(|| ConfigInputAliasDto { id: 0, - name: String::new(), + name: "".intern(), url: String::new(), username: None, password: None, diff --git a/frontend/src/app/components/source_editor/block_view.rs b/frontend/src/app/components/source_editor/block_view.rs index 830114562..fc5a27596 100644 --- a/frontend/src/app/components/source_editor/block_view.rs +++ b/frontend/src/app/components/source_editor/block_view.rs @@ -69,17 +69,17 @@ pub fn BlockView(props: &BlockProps) -> Html { let (dto_title, show_type, is_batch) = match &block.instance { BlockInstance::Input(dto) => { dto.aliases.as_ref().map_or( - (dto.name.clone(), true, false), + (dto.name.to_string(), true, false), |a| { if a.is_empty() { - (dto.name.clone(), true, false) + (dto.name.to_string(), true, false) } else { - (if dto.name.is_empty() {a[0].name.clone()} else {dto.name.clone()}, true, true) + (if dto.name.is_empty() {a[0].name.to_string()} else {dto.name.to_string()}, true, true) } } ) }, - BlockInstance::Target(dto) => (dto.name.clone(), true, false), + BlockInstance::Target(dto) => (dto.name.to_string(), true, false), BlockInstance::Output(_output) => { (translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false, false) } @@ -128,6 +128,7 @@ pub fn BlockView(props: &BlockProps) -> Html { let on_connection_drop = props.on_connection_drop.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); on_connection_drop.emit(to_id) }) }} /> @@ -141,6 +142,7 @@ pub fn BlockView(props: &BlockProps) -> Html { let on_connection_start = props.on_connection_start.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); on_connection_start.emit(from_id); }) }} /> diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index 4109a42ce..ff2c2d89a 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -511,7 +511,10 @@ pub fn SourceEditor() -> Html { }) }; - let handle_drag_over = Callback::from(|e: DragEvent| e.prevent_default()); + let handle_drag_over = Callback::from(|e: DragEvent| { + e.prevent_default(); + e.stop_propagation(); + }); let handle_drag_end = { let cursor_grabbing = cursor_grabbing.clone(); Callback::from(move |e: DragEvent| { diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index d4642a54d..eba0b6c29 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -7,9 +7,12 @@ use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, use std::collections::HashMap; use std::fmt::Display; use std::rc::Rc; +use std::str::FromStr; use web_sys::MouseEvent; -use yew::{classes, function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; +use yew::{function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; +use shared::error::TuliproxError; +use shared::info_err_res; const LABEL_NAME: &str = "LABEL.NAME"; const LABEL_INPUT_TYPE: &str = "LABEL.INPUT_TYPE"; @@ -35,6 +38,12 @@ const LABEL_XTREAM_LIVE_STREAM_USE_PREFIX: &str = "LABEL.LIVE_STREAM_USE_PREFIX" const LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION: &str = "LABEL.LIVE_STREAM_WITHOUT_EXTENSION"; const LABEL_CACHE_DURATION: &str = "LABEL.CACHE_DURATION"; +const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; +const LABEL_OPTIONS: &str = "LABEL.OPTIONS"; +const LABEL_STAGED: &str = "LABEL.STAGED"; +const LABEL_ADVANCED: &str = "LABEL.ADVANCED"; +const LABEL_ALIAS: &str = "LABEL.ALIAS"; + #[derive(Copy, Clone, PartialEq, Eq)] enum InputFormPage { @@ -42,15 +51,40 @@ enum InputFormPage { Options, Staged, Advanced, + Alias +} + +impl InputFormPage { + const MAIN: &str = "Main"; + const OPTIONS: &str = "Options"; + const STAGED: &str = "Staged"; + const ADVANCED: &str = "Advanced"; + const ALIAS: &str = "Alias"; +} + +impl FromStr for InputFormPage { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + match s { + Self::MAIN => Ok(InputFormPage::Main), + Self::OPTIONS => Ok(InputFormPage::Options), + Self::STAGED => Ok(InputFormPage::Staged), + Self::ADVANCED => Ok(InputFormPage::Advanced), + Self::ALIAS => Ok(InputFormPage::Alias), + _ => info_err_res!("Unknown input form page: {s}"), + } + } } impl Display for InputFormPage { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", match *self { - InputFormPage::Main => "Main", - InputFormPage::Options => "Options", - InputFormPage::Staged => "Staged", - InputFormPage::Advanced => "Advanced", + InputFormPage::Main => Self::MAIN, + InputFormPage::Options => Self::OPTIONS, + InputFormPage::Staged => Self::STAGED, + InputFormPage::Advanced => Self::ADVANCED, + InputFormPage::Alias => Self::ALIAS, }) } } @@ -119,11 +153,20 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { .map(ToString::to_string) .collect::>() }); - let view_visible = use_state(|| InputFormPage::Main.to_string()); + let view_visible = use_state(|| InputFormPage::Main); - let on_tab_click = { - let view_visible = view_visible.clone(); - Callback::from(move |page: InputFormPage| view_visible.set(page.to_string())) + // let on_tab_click = { + // let view_visible = view_visible.clone(); + // Callback::from(move |page: InputFormPage| view_visible.set(page)) + // }; + + let handle_menu_click = { + let active_menu = view_visible.clone(); + Callback::from(move |(name, _): (String, _)| { + if let Ok(view_type) = InputFormPage::from_str(&name) { + active_menu.set(view_type); + } + }) }; let input_form_state: UseReducerHandle = @@ -150,6 +193,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { // State for showing item forms let show_epg_form_state = use_state(|| false); let show_alias_form_state = use_state(|| false); + let edit_alias = use_state(|| None::); let staged_input_types = use_memo(staged_input_state.form.input_type, |input_type| { let default_it = input_type; @@ -237,9 +281,21 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let handle_add_alias_item = { let aliases = aliases_state.clone(); let show_alias_form = show_alias_form_state.clone(); + let edit_alias = edit_alias.clone(); Callback::from(move |alias: ConfigInputAliasDto| { let mut items = (*aliases).clone(); - items.push(alias); + if let Some(e) = edit_alias.as_ref() { + if let Some(pos) = items.iter().position(|x| x.name == e.name) { + if let Some(slot) = items.get_mut(pos) { + *slot = alias; + } + } else { + items.push(alias); + } + edit_alias.set(None); + } else { + items.push(alias); + } aliases.set(items); show_alias_form.set(false); }) @@ -247,15 +303,19 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let handle_close_add_alias_item = { let show_alias_form = show_alias_form_state.clone(); + let edit_alias = edit_alias.clone(); Callback::from(move |()| { show_alias_form.set(false); + edit_alias.set(None); }) }; let handle_show_add_alias_item = { let show_alias_form = show_alias_form_state.clone(); + let edit_alias = edit_alias.clone(); Callback::from(move |_| { show_alias_form.set(true); + edit_alias.set(None); }) }; @@ -264,6 +324,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let alias_list = aliases_state.clone(); Callback::from(move |(idx, e): (String, MouseEvent)| { e.prevent_default(); + e.stop_propagation(); if let Ok(index) = idx.parse::() { let mut items = (*alias_list).clone(); if index < items.len() { @@ -274,10 +335,30 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }) }; + let handle_edit_alias_list_item = { + let alias_list = aliases_state.clone(); + let show_alias_form = show_alias_form_state.clone(); + let edit_alias = edit_alias.clone(); + + Callback::from(move |(idx, e): (String, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + if let Ok(index) = idx.parse::() { + let items = (*alias_list).clone(); + if index < items.len() { + let item = items.get(index).cloned(); + edit_alias.set(item); + show_alias_form.set(true); + } + } + }) + }; + let handle_remove_epg_source = { let epg_list = epg_sources_state.clone(); Callback::from(move |(idx, e): (String, MouseEvent)| { e.prevent_default(); + e.stop_propagation(); if let Ok(index) = idx.parse::() { let mut items = (*epg_list).clone(); if index < items.len() { @@ -361,27 +442,19 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let render_input = || { let input_method_selection = Rc::new(vec![input_form_state.form.method.to_string()]); let input_form_state_disp = input_form_state.clone(); - let aliases = aliases_state.clone(); - let show_alias_form = show_alias_form_state.clone(); html! { - if *show_alias_form { - - } else { -
+
{ edit_field_text!(input_form_state, translate.t(LABEL_NAME), name, ConfigInputFormAction::Name) } { edit_field_bool!(input_form_state, translate.t(LABEL_ENABLED), enabled, ConfigInputFormAction::Enabled) } -
+
{ edit_field_text!(input_form_state, translate.t(LABEL_URL), url, ConfigInputFormAction::Url) } -
+
{ edit_field_text_option!(input_form_state, translate.t(LABEL_USERNAME), username, ConfigInputFormAction::Username) } { edit_field_text_option!(input_form_state, translate.t(LABEL_PASSWORD), password, ConfigInputFormAction::Password, true) } - { edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) } - { edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) } + { edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) } + { edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) } { edit_field_date!(input_form_state, translate.t(LABEL_EXP_DATE), exp_date, ConfigInputFormAction::ExpDate) } { edit_field_text_option!(input_form_state, translate.t(LABEL_CACHE_DURATION), cache_duration, ConfigInputFormAction::CacheDuration) } { config_field_child!(translate.t(LABEL_FETCH_METHOD), { @@ -397,9 +470,26 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { selected={input_method_selection} /> }})} -
+
{ edit_field_text_option!(input_form_state, translate.t(LABEL_PERSIST), persist, ConfigInputFormAction::Persist) } - // Aliases Section +
+ } + }; + + let render_alias = || { + let aliases = aliases_state.clone(); + let show_alias_form = show_alias_form_state.clone(); + let edit_alias = edit_alias.clone(); + + html! { + + if *show_alias_form { + + } else { { config_field_child!(translate.t(LABEL_ALIASES), { let aliases_list = aliases.clone(); html! { @@ -409,10 +499,16 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { for (*aliases_list).iter().enumerate().map(|(idx, alias)| { html! {
+
+ +
{&alias.name}{" - "}{&alias.url}
@@ -421,17 +517,19 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }) }
- - +
+ +
+ } })} - } + }
} }; @@ -513,7 +611,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let headers_state = headers_state.clone(); let epg_sources_state = epg_sources_state.clone(); let aliases_state = aliases_state.clone(); - + Callback::from(move |_| { let mut input = input_form_state.data().clone(); @@ -580,37 +678,39 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let render_edit_mode = || { html! { -
-
- { - for [ - InputFormPage::Main, - InputFormPage::Options, - InputFormPage::Staged, - InputFormPage::Advanced - ].iter().map(|page| { - let page_str = page.to_string(); - let active = *view_visible == page_str; - let on_tab_click = { - let on_tab_click = on_tab_click.clone(); - let page = *page; - Callback::from(move |_| on_tab_click.emit(page)) - }; - html! { - - } - }) - } -
-
+
+ //
+ // { + // for [ + // InputFormPage::Main, + // InputFormPage::Options, + // InputFormPage::Staged, + // InputFormPage::Advanced + // ].iter().map(|page| { + // let active = &*view_visible == page; + // let on_tab_click = { + // let on_tab_click = on_tab_click.clone(); + // let page = *page; + // Callback::from(move |_| on_tab_click.emit(page)) + // }; + // html! { + // + // } + // }) + // } + //
+
{render_input()} + + {render_alias()} + {render_options()} @@ -625,6 +725,18 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } }; + let render_sidebar = || { + html! { +
+ + + + + +
+ } + }; + html! {
@@ -637,7 +749,10 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { title={ translate.t("LABEL.OK")} onclick={handle_apply_input}>
+
+ { render_sidebar() } { render_edit_mode() }
+
} } diff --git a/frontend/src/app/components/source_editor/output_strm_form.rs b/frontend/src/app/components/source_editor/output_strm_form.rs index 17f7d0c4d..b76126068 100644 --- a/frontend/src/app/components/source_editor/output_strm_form.rs +++ b/frontend/src/app/components/source_editor/output_strm_form.rs @@ -1,12 +1,15 @@ use crate::app::components::config::HasFormData; use crate::app::components::select::Select; -use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, FilterInput, Panel, SourceEditorContext, TextButton}; +use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, FilterInput, IconButton, Panel, SourceEditorContext, TextButton}; use crate::{config_field_child, edit_field_bool, edit_field_list_option, edit_field_text, edit_field_text_option, generate_form_reducer}; use shared::model::{StrmExportStyle, StrmTargetOutputDto, TargetOutputDto}; use std::fmt::Display; use std::rc::Rc; -use yew::{classes, function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; +use std::str::FromStr; +use yew::{function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; +use shared::error::TuliproxError; +use shared::info_err_res; const LABEL_DIRECTORY: &str = "LABEL.DIRECTORY"; const LABEL_USERNAME: &str = "LABEL.USERNAME"; @@ -18,6 +21,8 @@ const LABEL_STRM_PROPS: &str = "LABEL.STRM_PROPS"; const LABEL_FILTER: &str = "LABEL.FILTER"; const LABEL_ADD_QUALITY_TO_FILENAME: &str = "LABEL.ADD_QUALITY_TO_FILENAME"; const LABEL_ADD_PROPERTY: &str = "LABEL.ADD_PROPERTY"; +const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; +const LABEL_OPTIONS: &str = "LABEL.OPTIONS"; #[derive(Copy, Clone, PartialEq, Eq)] enum StrmFormPage { @@ -25,11 +30,30 @@ enum StrmFormPage { Options, } + +impl StrmFormPage { + const MAIN: &str = "Main"; + const OPTIONS: &str = "Options"; +} + +impl FromStr for StrmFormPage { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + match s { + Self::MAIN => Ok(StrmFormPage::Main), + Self::OPTIONS => Ok(StrmFormPage::Options), + _ => info_err_res!("Unknown strm output form page: {s}"), + } + } +} + + impl Display for StrmFormPage { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", match *self { - StrmFormPage::Main => "Main", - StrmFormPage::Options => "Options", + StrmFormPage::Main => Self::MAIN, + StrmFormPage::Options => Self::OPTIONS, }) } } @@ -67,11 +91,15 @@ pub fn StrmTargetOutputView(props: &StrmTargetOutputViewProps) -> Html { modified: false, }); - let view_visible = use_state(|| StrmFormPage::Main.to_string()); + let view_visible = use_state(|| StrmFormPage::Main); - let on_tab_click = { - let view_visible = view_visible.clone(); - Callback::from(move |page: StrmFormPage| view_visible.set(page.to_string())) + let handle_menu_click = { + let active_menu = view_visible.clone(); + Callback::from(move |(name, _): (String, _)| { + if let Ok(view_type) = StrmFormPage::from_str(&name) { + active_menu.set(view_type); + } + }) }; let export_styles = use_memo(output_form_state.form.style, |style| { @@ -160,32 +188,8 @@ pub fn StrmTargetOutputView(props: &StrmTargetOutputViewProps) -> Html { let render_edit_mode = || { html! { -
-
- { - for [ - StrmFormPage::Main, - StrmFormPage::Options, - ].iter().map(|page| { - let page_str = page.to_string(); - let active = *view_visible == page_str; - let on_tab_click = { - let on_tab_click = on_tab_click.clone(); - let page = *page; - Callback::from(move |_| on_tab_click.emit(page)) - }; - html! { - - } - }) - } -
-
+
+
{render_main()} @@ -197,6 +201,15 @@ pub fn StrmTargetOutputView(props: &StrmTargetOutputViewProps) -> Html { } }; + let render_sidebar = || { + html! { +
+ + +
+ } + }; + let handle_apply = { let source_editor_ctx = source_editor_ctx.clone(); let output_form_state = output_form_state.clone(); @@ -227,7 +240,10 @@ pub fn StrmTargetOutputView(props: &StrmTargetOutputViewProps) -> Html { title={ translate.t("LABEL.OK")} onclick={handle_apply}>
- { render_edit_mode() } +
+ { render_sidebar() } + { render_edit_mode() } +
} } diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index ab6c198ef..a10bed11b 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -4,9 +4,12 @@ use crate::{config_field_child, edit_field_bool, edit_field_number_u16, edit_fie use shared::model::{TargetOutputDto, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto, XtreamTargetOutputDto}; use std::fmt::Display; use std::rc::Rc; +use std::str::FromStr; use web_sys::MouseEvent; -use yew::{classes, function_component, html, use_context, use_effect_with, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; +use yew::{function_component, html, use_context, use_effect_with, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; +use shared::error::TuliproxError; +use shared::info_err_res; const LABEL_SKIP_DIRECT_SOURCE: &str = "LABEL.SKIP_DIRECT_SOURCE"; const LABEL_LIVE: &str = "LABEL.LIVE"; @@ -22,21 +25,50 @@ const LABEL_TRAKT_LISTS: &str = "LABEL.TRAKT_LISTS"; const LABEL_ADD_TRAKT_LIST: &str = "LABEL.ADD_TRAKT_LIST"; const LABEL_API_CONFIGURATION: &str = "LABEL.API_CONFIGURATION"; const LABEL_USER_AGENT: &str = "LABEL.API_USER_AGENT"; +const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; +const LABEL_TRAKT: &str = "LABEL.TRAKT"; +const LABEL_ENABLED: &str = "LABEL.ENABLED"; #[derive(Copy, Clone, PartialEq, Eq)] -enum OutputFormPage { +enum XtreamOutputFormPage { Main, Trakt, } -impl Display for OutputFormPage { +impl XtreamOutputFormPage { + const MAIN: &str = "Main"; + const TRAKT: &str = "Trakt"; +} + +impl FromStr for XtreamOutputFormPage { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + match s { + Self::MAIN => Ok(XtreamOutputFormPage::Main), + Self::TRAKT => Ok(XtreamOutputFormPage::Trakt), + _ => info_err_res!("Unknown xtream output form page: {s}"), + } + } +} + +impl Display for XtreamOutputFormPage { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", match *self { - OutputFormPage::Main => "Main", - OutputFormPage::Trakt => "Trakt", + XtreamOutputFormPage::Main => Self::MAIN, + XtreamOutputFormPage::Trakt => Self::TRAKT, }) } } + +generate_form_reducer!( + state: TraktConfigFormState { form: TraktConfigDto }, + action_name: TraktConfigFormAction, + fields { + Enabled => enabled: bool, + } +); + generate_form_reducer!( state: TraktApiConfigFormState { form: TraktApiConfigDto }, action_name: TraktApiConfigFormAction, @@ -80,6 +112,12 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { modified: false, }); + let trakt_state: UseReducerHandle = + use_reducer(|| TraktConfigFormState { + form: TraktConfigDto::default(), + modified: false, + }); + let trakt_api_state: UseReducerHandle = use_reducer(|| TraktApiConfigFormState { form: TraktApiConfigDto::default(), @@ -92,15 +130,20 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { // State for showing trakt list form let show_trakt_list_form_state = use_state(|| false); - let view_visible = use_state(|| OutputFormPage::Main.to_string()); + let view_visible = use_state(|| XtreamOutputFormPage::Main); - let on_tab_click = { - let view_visible = view_visible.clone(); - Callback::from(move |page: OutputFormPage| view_visible.set(page.to_string())) + let handle_menu_click = { + let active_menu = view_visible.clone(); + Callback::from(move |(name, _): (String, _)| { + if let Ok(view_type) = XtreamOutputFormPage::from_str(&name) { + active_menu.set(view_type); + } + }) }; { let output_form_state = output_form_state.clone(); + let trakt_state = trakt_state.clone(); let trakt_api_state = trakt_api_state.clone(); let trakt_lists_state = trakt_lists_state.clone(); @@ -112,14 +155,17 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { // Load Trakt configuration if let Some(trakt) = &target.trakt { + trakt_state.dispatch(TraktConfigFormAction::SetAll(trakt.clone())); trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(trakt.api.clone())); trakt_lists_state.set(trakt.lists.clone()); } else { + trakt_state.dispatch(TraktConfigFormAction::SetAll(TraktConfigDto::default())); trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default())); trakt_lists_state.set(Vec::new()); } } else { output_form_state.dispatch(XtreamTargetOutputFormAction::SetAll(XtreamTargetOutputDto::default())); + trakt_state.dispatch(TraktConfigFormAction::SetAll(TraktConfigDto::default())); trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default())); trakt_lists_state.set(Vec::new()); } @@ -202,7 +248,8 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let render_trakt = || { let trakt_lists = trakt_lists_state.clone(); - let trakt_api = trakt_api_state.clone(); + let trakt_form = trakt_state.clone(); + let trakt_api_form = trakt_api_state.clone(); let show_trakt_list_form = show_trakt_list_form_state.clone(); html! { @@ -214,12 +261,13 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { /> } else { // Trakt API Configuration + { edit_field_bool!(trakt_form, translate.t(LABEL_ENABLED), enabled, TraktConfigFormAction::Enabled) }

{translate.t(LABEL_API_CONFIGURATION)}

- { edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_KEY), api_key, TraktApiConfigFormAction::ApiKey) } - { edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_VERSION), version, TraktApiConfigFormAction::Version) } - { edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_URL), url, TraktApiConfigFormAction::Url) } - { edit_field_text!(trakt_api, translate.t(LABEL_USER_AGENT), user_agent, TraktApiConfigFormAction::UserAgent) } + { edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_KEY), api_key, TraktApiConfigFormAction::ApiKey) } + { edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_VERSION), version, TraktApiConfigFormAction::Version) } + { edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_URL), url, TraktApiConfigFormAction::Url) } + { edit_field_text!(trakt_api_form, translate.t(LABEL_USER_AGENT), user_agent, TraktApiConfigFormAction::UserAgent) }
// Trakt Lists @@ -279,35 +327,11 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let render_edit_mode = || { html! {
-
- { - for [ - OutputFormPage::Main, - OutputFormPage::Trakt, - ].iter().map(|page| { - let page_str = page.to_string(); - let active = *view_visible == page_str; - let on_tab_click = { - let on_tab_click = on_tab_click.clone(); - let page = *page; - Callback::from(move |_| on_tab_click.emit(page)) - }; - html! { - - } - }) - } -
- + {render_output()} - + {render_trakt()}
@@ -315,9 +339,29 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { } }; + let render_sidebar = || { + let main_class = format!( + "tp__app-sidebar-menu--{}{}", + XtreamOutputFormPage::Main, + if *view_visible == XtreamOutputFormPage::Main { " active" } else { "" } + ); + let trakt_class = format!( + "tp__app-sidebar-menu--{}{}", + XtreamOutputFormPage::Trakt, + if *view_visible == XtreamOutputFormPage::Trakt { " active" } else { "" } + ); + html! { +
+ + +
+ } + }; + let handle_apply_target = { let source_editor_ctx = source_editor_ctx.clone(); let output_form_state = output_form_state.clone(); + let trakt_state = trakt_state.clone(); let trakt_api_state = trakt_api_state.clone(); let trakt_lists_state = trakt_lists_state.clone(); let block_id = props.block_id; @@ -330,6 +374,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { None } else { Some(TraktConfigDto { + enabled: trakt_state.data().enabled, api: trakt_api_state.data().clone(), lists: trakt_lists, }) @@ -348,7 +393,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { html! {
-
+
Html { title={ translate.t("LABEL.OK")} onclick={handle_apply_target}>
- { render_edit_mode() } +
+ { render_sidebar() } + { render_edit_mode() } +
- } + } } diff --git a/frontend/src/app/components/source_editor/target_form.rs b/frontend/src/app/components/source_editor/target_form.rs index f3c783f15..354ac9510 100644 --- a/frontend/src/app/components/source_editor/target_form.rs +++ b/frontend/src/app/components/source_editor/target_form.rs @@ -1,12 +1,15 @@ use crate::app::components::config::HasFormData; use crate::app::components::select::Select; -use crate::app::components::{BlockId, BlockInstance, Card, ClusterFlagsInput, ClusterFlagsInputMode, DropDownOption, DropDownSelection, EditMode, FilterInput, Panel, SourceEditorContext, TextButton}; +use crate::app::components::{BlockId, BlockInstance, Card, ClusterFlagsInput, ClusterFlagsInputMode, DropDownOption, DropDownSelection, EditMode, FilterInput, IconButton, Panel, SourceEditorContext, TextButton}; use crate::{config_field_child, edit_field_bool, edit_field_list_option, edit_field_text, generate_form_reducer}; use shared::model::{ClusterFlags, ConfigTargetDto, ConfigTargetOptions, ProcessingOrder}; use std::fmt::Display; use std::rc::Rc; -use yew::{classes, function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; +use std::str::FromStr; +use yew::{function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; +use shared::error::TuliproxError; +use shared::info_err_res; const LABEL_ENABLED: &str = "LABEL.ENABLED"; const LABEL_NAME: &str = "LABEL.NAME"; @@ -21,6 +24,8 @@ const LABEL_IGNORE_LOGO: &str = "LABEL.IGNORE_LOGO"; const LABEL_SHARE_LIVE_STREAMS: &str = "LABEL.SHARE_LIVE_STREAMS"; const LABEL_REMOVE_DUPLICATES: &str = "LABEL.REMOVE_DUPLICATES"; const LABEL_FORCE_REDIRECT: &str = "LABEL.FORCE_REDIRECT"; +const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; +const LABEL_OPTIONS: &str = "LABEL.OPTIONS"; #[derive(Copy, Clone, PartialEq, Eq)] enum TargetFormPage { @@ -28,6 +33,23 @@ enum TargetFormPage { Options, } +impl TargetFormPage { + const MAIN: &str = "Main"; + const OPTIONS: &str = "Options"; +} + +impl FromStr for TargetFormPage { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + match s { + Self::MAIN => Ok(TargetFormPage::Main), + Self::OPTIONS => Ok(TargetFormPage::Options), + _ => info_err_res!("Unknown target form page: {s}"), + } + } +} + impl Display for TargetFormPage { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match *self { @@ -89,13 +111,19 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { modified: false, }); - let view_visible = use_state(|| TargetFormPage::Main.to_string()); + let view_visible = use_state(|| TargetFormPage::Main); - let on_tab_click = { - let view_visible = view_visible.clone(); - Callback::from(move |page: TargetFormPage| view_visible.set(page.to_string())) + + let handle_menu_click = { + let active_menu = view_visible.clone(); + Callback::from(move |(name, _): (String, _)| { + if let Ok(view_type) = TargetFormPage::from_str(&name) { + active_menu.set(view_type); + } + }) }; + let processing_orders = use_memo(target_form_state.clone(), |target_state: &UseReducerHandle| { let default_po = target_state.form.processing_order; [ @@ -208,32 +236,8 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { let render_edit_mode = || { html! { -
-
- { - for [ - TargetFormPage::Main, - TargetFormPage::Options, - ].iter().map(|page| { - let page_str = page.to_string(); - let active = *view_visible == page_str; - let on_tab_click = { - let on_tab_click = on_tab_click.clone(); - let page = *page; - Callback::from(move |_| on_tab_click.emit(page)) - }; - html! { - - } - }) - } -
-
+
+
{render_target()} @@ -245,6 +249,15 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { } }; + let render_sidebar = || { + html! { +
+ + +
+ } + }; + let handle_apply_target = { let source_editor_ctx = source_editor_ctx.clone(); let target_form_state = target_form_state.clone(); @@ -281,7 +294,10 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { title={ translate.t("LABEL.OK")} onclick={handle_apply_target}>
- { render_edit_mode() } +
+ { render_sidebar() } + { render_edit_mode() } +
} } diff --git a/frontend/src/app/components/tag_list.rs b/frontend/src/app/components/tag_list.rs index 159a599f5..6675024c0 100644 --- a/frontend/src/app/components/tag_list.rs +++ b/frontend/src/app/components/tag_list.rs @@ -70,6 +70,7 @@ pub fn TagList(props: &TagListProps) -> Html { Callback::from(move |e: KeyboardEvent| { if e.key() == "Enter" { e.prevent_default(); + e.stop_propagation(); let val = (*new_tag).trim().to_string(); if !val.is_empty() && !tag_state.iter().any(|t| t.label == val) { let mut updated = (*tag_state).clone(); diff --git a/frontend/src/app/components/text_button.rs b/frontend/src/app/components/text_button.rs index c1596aa9d..b7d29454f 100644 --- a/frontend/src/app/components/text_button.rs +++ b/frontend/src/app/components/text_button.rs @@ -23,12 +23,16 @@ pub fn TextButton(props: &TextButtonProps) -> Html { let name = props.name.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); + e.stop_propagation(); click.emit(name.clone()); }) }; html! { -