diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 8c6c7a2ad..eae9dbf35 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1,5 +1,4 @@ use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}; -use crate::api::model::{tee_stream, UserSession}; use crate::api::model::{ create_channel_unavailable_stream, create_custom_video_stream_response, create_provider_connections_exhausted_stream, create_provider_stream, @@ -8,21 +7,22 @@ use crate::api::model::{ ProviderStreamFactoryOptions, SharedStreamManager, StreamError, ThrottledStream, UserApiRequest, }; +use crate::api::model::{tee_stream, UserSession}; use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy}; use crate::model::{ConfigInput, ResourceRetryConfig}; use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::tools::lru_cache::LRUResourceCache; -use crate::utils::{async_file_reader, async_file_writer, create_new_file_for_write}; use crate::utils::request; +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::BUILD_TIMESTAMP; use arc_swap::ArcSwapOption; -use axum::http::{HeaderMap}; +use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode}; use axum::response::IntoResponse; use chrono::{DateTime, Utc}; use futures::{StreamExt, TryStreamExt}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use log::{debug, error, log_enabled, trace, warn}; +use log::{debug, error, log_enabled, trace, warn}; use reqwest::header::RETRY_AFTER; use serde::Serialize; use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster}; @@ -32,10 +32,13 @@ use shared::utils::{ }; use std::borrow::Cow; use std::collections::HashMap; -use std::path::Path; +use std::io::SeekFrom; +use std::path::{Path, PathBuf}; 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; const CONTENT_TYPE_BIN: &str = "application/cbor"; @@ -122,6 +125,7 @@ pub use try_result_bad_request; pub use try_result_not_found; pub use try_unwrap_body; use crate::auth::Fingerprint; +use crate::utils::request::{content_type_from_ext, parse_range}; pub fn get_server_time() -> String { chrono::offset::Local::now() @@ -142,7 +146,7 @@ pub fn get_build_time() -> Option { pub async fn serve_file(file_path: &Path, mime_type: mime::Mime) -> impl IntoResponse + Send { match tokio::fs::try_exists(file_path).await { Ok(exists) => { - if ! exists { + if !exists { return axum::http::StatusCode::NOT_FOUND.into_response(); } } @@ -371,7 +375,7 @@ 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(provider), url), ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(provider), url), } @@ -1009,6 +1013,101 @@ async fn try_shared_stream_response_if_any( None } +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub async fn local_stream_response( + fingerprint: &Fingerprint, + app_state: &Arc, + pli: StreamChannel, + req_headers: &HeaderMap, + _input: &ConfigInput, + _target: &ConfigTarget, + _user: &ProxyUserCredentials, + connection_permission: UserConnectionPermission, +) -> impl IntoResponse + Send { + if log_enabled!(log::Level::Trace) { + trace!("Try to open stream {}", sanitize_sensitive_info(&pli.url)); + } + + if connection_permission == UserConnectionPermission::Exhausted { + return create_custom_video_stream_response( + app_state, + &fingerprint.addr, + CustomVideoStreamType::UserConnectionsExhausted, + ).await + .into_response(); + } + + let path = PathBuf::from(pli.url.strip_prefix("file://").unwrap_or(&pli.url)); + + 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 file_size = metadata.len(); + + let range = req_headers + .get("range") + .and_then(|v| v.to_str().ok()) + .and_then(parse_range); + + let (start, end) = if let Some((start, end)) = range { + let end = end.unwrap_or(file_size - 1); + (start, end.min(file_size - 1)) + } else { + if file_size == 0 { + // Serve empty file + let body = axum::body::Body::empty(); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::OK; + let headers = response.headers_mut(); + if let Some(ext) = get_file_extension(&pli.url) { + let ct = content_type_from_ext(&ext); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); + } else { + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); + } + headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("0")); + return response.into_response(); + } + (0, file_size - 1) + }; + + let content_length = end - start + 1; + + if start > 0 { + if let Err(_err) = file.seek(SeekFrom::Start(start)).await { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + + let stream = ReaderStream::new(file.take(content_length)); + let body = axum::body::Body::from_stream(stream); + + let mut response = Response::new(body); + + *response.status_mut() = if range.is_some() { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + + let headers = response.headers_mut(); + if let Some(ext) = get_file_extension(&pli.url) { + let ct = content_type_from_ext(&ext); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); + } else { + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); + } + headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from_str(&content_length.to_string()).unwrap_or_else(|_| HeaderValue::from_static("0"))); + + if range.is_some() { + headers.insert(header::CONTENT_RANGE, HeaderValue::from_str(&format!("bytes {start}-{end}/{file_size}")).unwrap_or_else(|_| HeaderValue::from_static("bytes=0-"))); + } + + response +} + + pub fn is_stream_share_enabled(item_type: PlaylistItemType, target: &ConfigTarget) -> bool { (item_type == PlaylistItemType::Live/* || item_type == PlaylistItemType::LiveHls */) && target diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index 1eb765e3b..94562d329 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -146,8 +146,8 @@ pub(in crate::api::endpoints) async fn get_playlist(client: &reqwest::Client, cf Some(input) => { let (result, errors) = match input.input_type { - InputType::M3u | InputType::M3uBatch => m3u::get_m3u_playlist(client, cfg, input, &cfg.working_dir).await, - InputType::Xtream | InputType::XtreamBatch => xtream::get_xtream_playlist(cfg, client, input, &cfg.working_dir).await, + InputType::M3u | InputType::M3uBatch => m3u::get_m3u_playlist(client, cfg, input).await, + InputType::Xtream | InputType::XtreamBatch => xtream::get_xtream_playlist(cfg, client, input).await, InputType::Library => { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({ "error": "Library inputs are not supported on this endpoint"}))).into_response(); } diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 47220ddb0..8932401e0 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -1,4 +1,4 @@ -use crate::api::api_utils::{create_session_fingerprint, try_unwrap_body}; +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, redirect_response, resource_response, separate_number_and_remainder, @@ -130,6 +130,21 @@ async fn m3u_api_stream( true, format!("Cant find input {} for target {target_name}, stream_id {virtual_id}", pli.input_name) ); + + if pli.url.starts_with("file://") { + let connection_permission = user.connection_permission(app_state).await; + return local_stream_response( + fingerprint, + app_state, + pli.to_stream_channel(target.id), + req_headers, + &input, + &target, + &user, + connection_permission, + ).await.into_response(); + } + let cluster = XtreamCluster::try_from(pli.item_type).unwrap_or(XtreamCluster::Live); debug_if_enabled!( diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 7cc51b403..3ca0d624b 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -1,7 +1,7 @@ // https://github.com/tellytv/go.xtream-codes/blob/master/structs.go use crate::api::api_utils; -use crate::api::api_utils::{create_session_fingerprint, try_unwrap_body}; +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, @@ -265,6 +265,20 @@ async fn xtream_player_api_stream( format!( "Cant find input {} for target {target_name}, context {}, stream_id {virtual_id}", pli.input_name, stream_req.context) ); + if pli.url.starts_with("file://") { + let connection_permission = user.connection_permission(app_state).await; + return local_stream_response( + fingerprint, + app_state, + pli.to_stream_channel(target.id), + req_headers, + &input, + &target, + &user, + connection_permission, + ).await.into_response(); + } + let (cluster, item_type) = if stream_req.context == ApiStreamContext::Timeshift { (XtreamCluster::Video, PlaylistItemType::Catchup) } else { @@ -446,12 +460,8 @@ async fn xtream_player_api_stream_with_token( ) ); - let session_key = create_session_fingerprint(&fingerprint.key, "webui", virtual_id); - - let is_hls_request = - pli.item_type == PlaylistItemType::LiveHls || stream_ext.as_deref() == Some(HLS_EXT); - let config = app_state.app_config.config.load(); + let server = config .web_ui .as_ref() @@ -473,6 +483,23 @@ async fn xtream_player_api_stream_with_token( comment: None, }; + if pli.url.starts_with("file://") { + return local_stream_response(fingerprint, + app_state, + pli.to_stream_channel(target.id), + req_headers, + &input, + &target, + &user, + UserConnectionPermission::Allowed, + ).await.into_response(); + } + + let session_key = create_session_fingerprint(&fingerprint.key, "webui", virtual_id); + + let is_hls_request = + pli.item_type == PlaylistItemType::LiveHls || stream_ext.as_deref() == Some(HLS_EXT); + // TODO how should we use fixed provider for hls in multi provider config? // Reverse proxy mode diff --git a/backend/src/library/metadata_iterator.rs b/backend/src/library/metadata_iterator.rs new file mode 100644 index 000000000..1f5166b3f --- /dev/null +++ b/backend/src/library/metadata_iterator.rs @@ -0,0 +1,53 @@ +use crate::library::MetadataCacheEntry; +use log::error; +use std::path::{Path, PathBuf}; + +pub struct MetadataAsyncIter { + paths: Vec, + index: usize, +} + +async fn collect_paths(storage_dir: &Path) -> Vec { + let mut paths = Vec::new(); + if let Ok(mut read_dir) = tokio::fs::read_dir(storage_dir).await { + while let Ok(Some(entry)) = read_dir.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + paths.push(path); + } + } + } + paths +} + +impl MetadataAsyncIter { + pub async fn new(storage_dir: &Path) -> Self { + let paths = collect_paths(storage_dir).await; + Self { + paths, + index: 0, + } + } + + pub async fn next(&mut self) -> Option { + while self.index < self.paths.len() { + let path = &self.paths[self.index]; + self.index += 1; + + match tokio::fs::read_to_string(path).await { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(entry) => return Some(entry), + Err(e) => { + error!("Failed to parse library metadata {}: {}", path.display(), e); + } + } + } + Err(e) => { + error!("Failed to read library metadata {}: {}", path.display(), e); + } + } + } + None + } +} diff --git a/backend/src/library/mod.rs b/backend/src/library/mod.rs index 1fc5794d5..8ed9d6de4 100644 --- a/backend/src/library/mod.rs +++ b/backend/src/library/mod.rs @@ -6,6 +6,7 @@ mod tmdb_client; mod metadata_storage; mod metadata_resolver; mod processor; +mod metadata_iterator; pub use scanner::*; pub use classifier::*; @@ -15,3 +16,4 @@ pub use tmdb_client::*; pub use metadata_storage::*; pub use metadata_resolver::*; pub use processor::*; +pub use metadata_iterator::*; diff --git a/backend/src/library/nfo_reader.rs b/backend/src/library/nfo_reader.rs index 93c99a0be..bff821283 100644 --- a/backend/src/library/nfo_reader.rs +++ b/backend/src/library/nfo_reader.rs @@ -48,12 +48,6 @@ impl NfoReader { /// Parses NFO XML content into `VideoMetadata` fn parse_nfo(content: &str) -> Option { - // let mut reader = Reader::from_str(content); - // reader.config_mut().trim_text(true); - - // let mut buf = Vec::new(); - // let mut current_tag = String::new(); - // Determine if this is a movie or TV show NFO let is_movie = content.contains(" {} }, Err(e) => { - error!("Error processing {}: {}", file.file_path, e); + error!("Error processing {}: {e}", file.file_path); result.errors += 1; } } @@ -117,7 +117,7 @@ impl LibraryProcessor { let metadata = self.resolve_metadata(file).await?; let entry = MetadataCacheEntry { uuid: existing_entry.uuid.clone(), - file_path: file.file_name.clone(), + file_path: file.file_path.clone(), file_size: file.size_bytes, file_modified: file.modified_timestamp, metadata, diff --git a/backend/src/library/scanner.rs b/backend/src/library/scanner.rs index 4b94edefb..c2b0ba23e 100644 --- a/backend/src/library/scanner.rs +++ b/backend/src/library/scanner.rs @@ -1,8 +1,8 @@ +use crate::model::{LibraryConfig, LibraryScanDirectory}; use log::{debug, error, info, trace, warn}; use std::path::{Path, PathBuf}; use tokio::fs; use tokio::io; -use crate::model::{LibraryConfig, LibraryScanDirectory}; /// Represents a discovered video file with its metadata #[derive(Debug, Clone)] @@ -91,12 +91,13 @@ impl LibraryScanner { async fn scan_directory(&self, scan_directory: &LibraryScanDirectory) -> io::Result> { let path = Path::new(&scan_directory.path); - if !path.exists() { + if !fs::try_exists(path).await.unwrap_or(false) { warn!("Directory does not exist: {}", &scan_directory.path); return Ok(Vec::new()); } - if !path.is_dir() { + let dir_metadata = fs::metadata(path).await?; + if !dir_metadata.is_dir() { warn!("Path is not a directory: {}", &scan_directory.path); return Ok(Vec::new()); } @@ -111,13 +112,19 @@ impl LibraryScanner { path: &'a Path, recursive: bool, files: &'a mut Vec, - ) -> std::pin::Pin> + Send + 'a>> { + ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { let mut entries = fs::read_dir(path).await?; while let Some(entry) = entries.next_entry().await? { let entry_path = entry.path(); - let metadata = entry.metadata().await?; + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(err) => { + error!("Failed to read metadata for {}: {err}", entry_path.display()); + continue; + } + }; if metadata.is_dir() { if recursive { @@ -195,7 +202,7 @@ mod tests { api_key: Some(String::new()), rate_limit_ms: 250, cache_duration_days: 0, - language: "".to_string(), + language: String::new(), }, fallback_to_filename: true, formats: vec![], diff --git a/backend/src/model/additional_properties.rs b/backend/src/model/additional_properties.rs new file mode 100644 index 000000000..a784f2d53 --- /dev/null +++ b/backend/src/model/additional_properties.rs @@ -0,0 +1,68 @@ +#[macro_export] +macro_rules! add_str_property_if_exists { + ($vec:expr, $prop:expr, $prop_name:expr) => { + $vec.insert(String::from($prop_name), Value::String($prop.to_string())); + } +} + +#[macro_export] +macro_rules! add_rc_str_property_if_exists { + ($vec:expr, $prop:expr, $prop_name:expr) => { + if let Some(v) = $prop.as_ref() { + $vec.insert(String::from($prop_name), Value::String(v.to_string())); + } + } +} + +#[macro_export] +macro_rules! add_opt_i64_property_if_exists { + ($vec:expr, $prop:expr, $prop_name:expr) => { + if let Some(v) = $prop.as_ref() { + $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from(*v)))); + } + } +} + +#[macro_export] +macro_rules! add_opt_f64_property_if_exists { + ($vec:expr, $prop:expr, $prop_name:expr) => { + $prop.as_ref().map(|v| $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from(*v)).unwrap_or_else(|| serde_json::Number::from(0))))); + } +} + +#[macro_export] +macro_rules! add_f64_property_if_exists { + ($vec:expr, $prop:expr, $prop_name:expr) => { + $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from($prop)).unwrap_or_else(|| serde_json::Number::from(0)))); + } +} + +#[macro_export] +macro_rules! add_i64_property_if_exists { + ($vec:expr, $prop:expr, $prop_name:expr) => { + $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from($prop)))); + } +} + +#[macro_export] +macro_rules! add_to_doc_str_property_if_not_exists { + ($document:expr, $prop_name:expr, $prop_value:expr) => { + match $document.get($prop_name) { + None => { + $document.insert(String::from($prop_name), $prop_value); + } + Some(value) => { if Value::is_null(value) { + $document.insert(String::from($prop_name), $prop_value); + }} + } + } +} + + +pub use add_str_property_if_exists; +pub use add_rc_str_property_if_exists; +pub use add_opt_i64_property_if_exists; +pub use add_opt_f64_property_if_exists; +pub use add_f64_property_if_exists; +pub use add_i64_property_if_exists; +pub use add_to_doc_str_property_if_not_exists; diff --git a/backend/src/model/config/source.rs b/backend/src/model/config/source.rs index 969641fe1..b1b78b325 100644 --- a/backend/src/model/config/source.rs +++ b/backend/src/model/config/source.rs @@ -25,6 +25,11 @@ impl ConfigSource { } None } + + pub fn should_process_for_user_targets(&self, user_targets: &ProcessTargets) -> bool { + user_targets.targets.is_empty() + || self.targets.iter().any(|t| user_targets.targets.contains(&t.id)) + } } macros::try_from_impl!(ConfigSource); diff --git a/backend/src/model/mod.rs b/backend/src/model/mod.rs index 2bfd21fa6..3a106b657 100644 --- a/backend/src/model/mod.rs +++ b/backend/src/model/mod.rs @@ -8,6 +8,7 @@ mod playlist_categories; mod config; mod input_source; pub mod media_properties; +mod additional_properties; pub use self::playlist::*; pub use self::mapping::*; @@ -20,3 +21,4 @@ pub use shared::model::xtream_const::*; pub use self::config::*; pub use self::input_source::*; pub use self::media_properties::*; +pub use self::additional_properties::*; \ No newline at end of file diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index f162bfaa5..e18d531c3 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -1,13 +1,14 @@ use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, XtreamTargetOutput}; use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::{Map, Value}; +use serde_json::{Value}; use shared::model::{xtream_const, PlaylistItem, ProxyUserStatus, XtreamPlaylistItem}; use shared::model::{ClusterFlags, PlaylistEntry, XtreamCluster}; use shared::utils::{deserialize_as_option_string, deserialize_as_string, deserialize_as_string_array, deserialize_number_from_string, get_non_empty_str, opt_string_or_number_u32, string_default_on_null, string_or_number_f64, string_or_number_u32}; use std::iter::FromIterator; use serde_json::value::RawValue; +use crate::model::{add_f64_property_if_exists, add_i64_property_if_exists, add_opt_f64_property_if_exists, add_opt_i64_property_if_exists, add_rc_str_property_if_exists, add_str_property_if_exists, add_to_doc_str_property_if_not_exists}; #[derive(Debug, Default)] pub struct XtreamLoginInfo { @@ -101,61 +102,13 @@ pub struct XtreamStream { } -macro_rules! add_str_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $vec.insert(String::from($prop_name), Value::String($prop.to_string())); - } -} -macro_rules! add_rc_str_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $prop.as_ref().map(|v| $vec.insert(String::from($prop_name), Value::String(v.to_string()))); - } -} - -macro_rules! add_opt_i64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $prop.as_ref().map(|v| $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from(*v))))); - } -} - -macro_rules! add_opt_f64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $prop.as_ref().map(|v| $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from(*v)).unwrap_or_else(|| serde_json::Number::from(0))))); - } -} - -macro_rules! add_f64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from($prop)).unwrap_or_else(|| serde_json::Number::from(0)))); - } -} - -macro_rules! add_i64_property_if_exists { - ($vec:expr, $prop:expr, $prop_name:expr) => { - $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from($prop)))); - } -} - -macro_rules! add_to_doc_str_property_if_not_exists { - ($document:expr, $prop_name:expr, $prop_value:expr) => { - match $document.get($prop_name) { - None => { - $document.insert(String::from($prop_name), $prop_value); - } - Some(value) => { if Value::is_null(value) { - $document.insert(String::from($prop_name), $prop_value); - }} - } - } -} - impl XtreamStream { pub fn get_stream_id(&self) -> u32 { self.stream_id.unwrap_or_else(|| self.series_id.unwrap_or(0)) } pub fn get_additional_properties(&self) -> Option> { - let mut result = Map::new(); + let mut result = serde_json::Map::new(); if let Some(bdpath) = self.backdrop_path.as_ref() { if !bdpath.is_empty() { result.insert(String::from(xtream_const::XC_PROP_BACKDROP_PATH), Value::Array(Vec::from([Value::String(String::from(bdpath.first()?))]))); @@ -194,6 +147,68 @@ impl XtreamStream { } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamMovieInfo { + pub info: XtreamMovieInfoDetails, + pub movie_data: XtreamMovieData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamMovieInfoDetails { + pub kinopoisk_url: Option, + pub tmdb_id: Option, + pub name: Option, + pub o_name: Option, + pub cover_big: Option, + pub movie_image: Option, + pub releasedate: Option, + pub episode_run_time: Option, + pub youtube_trailer: Option, + pub director: Option, + pub actors: Option, + pub cast: Option, + pub description: Option, + pub plot: Option, + pub age: Option, + pub mpaa_rating: Option, + pub rating_count_kinopoisk: u32, + pub country: Option, + pub genre: Option, + #[serde(default)] + pub backdrop_path: Vec, + pub duration_secs: Option, + pub duration: Option, + #[serde(default)] + pub video: Vec, + #[serde(default)] + pub audio: Vec, + #[serde(default)] + pub bitrate: u32, + pub rating: Option, + pub runtime: Option, + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamStreamInfo { + #[serde(flatten)] + pub extra: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamMovieData { + pub stream_id: u32, + pub name: String, + pub added: Option, + pub category_id: Option, + #[serde(default)] + pub category_ids: Vec, + pub container_extension: Option, + pub custom_sid: Option, + pub direct_source: String, +} + + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XtreamSeriesInfoSeason { #[serde(default, deserialize_with = "string_default_on_null")] @@ -410,7 +425,7 @@ pub struct XtreamSeriesInfo { impl XtreamSeriesInfoEpisode { pub fn get_additional_properties(&self, series_info: &XtreamSeriesInfo) -> Option> { - let mut result = Map::new(); + let mut result = serde_json::Map::new(); let info = series_info.info.as_ref(); let bdpath = info.and_then(|i| i.backdrop_path.as_ref()); let bdpath_is_set = bdpath.as_ref().is_some_and(|bdpath| !bdpath.is_empty()); @@ -501,7 +516,7 @@ fn append_mandatory_fields(document: &mut serde_json::Map, fields } } -fn append_prepared_series_properties(add_props: Option<&Map>, document: &mut Map) { +fn append_prepared_series_properties(add_props: Option<&serde_json::Map>, document: &mut serde_json::Map) { if let Some(props) = add_props { match props.get("rating") { Some(value) => { @@ -573,7 +588,7 @@ pub fn xtream_playlistitem_to_document(pli: &XtreamPlaylistItem, url: &str, opti } } - let props = pli.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::>(add_props).ok()); + let props = pli.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::>(add_props).ok()); if let Some(ref add_props) = props { for (field_name, field_value) in add_props { @@ -605,7 +620,7 @@ pub fn xtream_playlistitem_to_document(pli: &XtreamPlaylistItem, url: &str, opti Value::Object(document) } -pub fn rewrite_doc_urls(resource_url: Option<&String>, document: &mut Map, fields: &[&str], field_prefix: &str) { +pub fn rewrite_doc_urls(resource_url: Option<&String>, document: &mut serde_json::Map, fields: &[&str], field_prefix: &str) { if let Some(rewrite_url) = resource_url { if let Some(bdpath) = document.get(xtream_const::XC_PROP_BACKDROP_PATH) { match bdpath { diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs new file mode 100644 index 000000000..cd899c114 --- /dev/null +++ b/backend/src/processing/processor/library.rs @@ -0,0 +1,180 @@ +use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; +use crate::model::{XtreamMovieData, XtreamMovieInfo, XtreamMovieInfoDetails}; +use crate::model::{AppConfig, ConfigInput}; +use serde_json::value::RawValue; +use shared::error::TuliproxError; +use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; +use shared::utils::string_to_uuid_type; +use std::sync::Arc; +use std::path::Path; + +pub async fn get_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &Arc) -> (Vec, Vec) { + let config = &*app_config.config.load(); + let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) }; + if !library_config.enabled { return (vec![], vec![]); } + + let storage_path = std::path::PathBuf::from(&library_config.metadata.path); + let mut metadata_iter = MetadataAsyncIter::new(&storage_path).await; + let mut group_movies = PlaylistGroup { + id: 0, + title: library_config.playlist.movie_category.clone(), + channels: vec![], + xtream_cluster: XtreamCluster::Video, + }; + let mut group_series = PlaylistGroup { + id: 0, + title: library_config.playlist.series_category.clone(), + channels: vec![], + xtream_cluster: XtreamCluster::Series, + }; + while let Some(entry) = metadata_iter.next().await { + match entry.metadata { + MediaMetadata::Movie(_) => { + let pli = to_playlist_item(&entry, &input.name, &library_config.playlist.movie_category); + group_movies.channels.push(pli); + }, + MediaMetadata::Series(_) => { + let pli = to_playlist_item(&entry, &input.name, &library_config.playlist.series_category); + group_series.channels.push(pli); + }, + } + } + + let mut groups = vec![]; + if !group_movies.channels.is_empty() { + groups.push(group_movies); + } + if !group_series.channels.is_empty() { + groups.push(group_series); + } + + (groups, vec![]) +} + +fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &str) -> PlaylistItem { + let metadata = &entry.metadata; + + let (xtream_cluster, item_type, additional_props): (XtreamCluster, PlaylistItemType, Option>) = { + match metadata { + MediaMetadata::Movie(_) => { + let add_props = metadata_cache_entry_to_xtream_movie_info(entry).and_then(|info| { + let raw = serde_json::to_string(&info).ok()?; + RawValue::from_string(raw).ok() + }); + (XtreamCluster::Video, PlaylistItemType::Video, add_props) + } + MediaMetadata::Series(_s) => { + (XtreamCluster::Series, PlaylistItemType::SeriesInfo, None) + } + } + }; + + PlaylistItem { + header: PlaylistItemHeader { + uuid: string_to_uuid_type(&entry.uuid), + id: String::new(), + virtual_id: 0, + name: metadata.title().to_string(), + chno: String::new(), + logo: String::new(), + logo_small: String::new(), + group: group_name.to_string(), + title: metadata.title().to_string(), + parent_code: String::new(), + audio_track: String::new(), + time_shift: String::new(), + rec: String::new(), + url: format!("file://{}", entry.file_path), + epg_channel_id: None, + xtream_cluster, + additional_properties: additional_props, + item_type, + category_id: 0, + input_name: input_name.to_string(), + }, + } +} + +pub fn metadata_cache_entry_to_xtream_movie_info( + entry: &MetadataCacheEntry, +) -> Option { + let movie = match &entry.metadata { + MediaMetadata::Movie(m) => m, + MediaMetadata::Series(_) => return None, + }; + + let container_extension = Path::new(&entry.file_path) + .extension() + .and_then(|s| s.to_str()) + .map(ToString::to_string); + + let actor_names = movie.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::>().join(", ")); + + let info = XtreamMovieInfoDetails { + kinopoisk_url: movie.tmdb_id.map(|id| format!("https://www.themoviedb.org/movie/{id}")), + tmdb_id: movie.tmdb_id.map(|id|id.to_string()), + + name: Some(movie.title.clone()), + o_name: movie.original_title.clone(), + + cover_big: movie.poster.clone(), + movie_image: movie.poster.clone(), + + releasedate: movie.year.map(|y| format!("{y}-01-01")), + episode_run_time: movie.runtime, + + youtube_trailer: None, + director: movie.directors.as_ref().map(|d| d.join(", ")), + actors: actor_names.clone(), + cast: actor_names, + genre: movie.genres.as_ref().map(|g| g.join(", ")), + + description: movie.plot.clone(), + plot: movie.plot.clone(), + + age: None, + mpaa_rating: movie.mpaa.clone(), + + rating_count_kinopoisk: 0, + + country: None, + + backdrop_path: movie + .fanart + .as_ref() + .map(|f| vec![f.clone()]) + .unwrap_or_default(), + + duration_secs: movie.runtime.map(|r| (r * 60).to_string()), + duration: movie.runtime.map(|r| { + let h = r / 60; + let m = r % 60; + format!("{h:02}:{m:02}:00") + }), + + video: Vec::new(), + audio: Vec::new(), + + bitrate: 0, + + rating: movie.rating.map(|r| format!("{r:.2}")), + runtime: movie.runtime.map(|r| (r * 60).to_string()), + status: None, + }; + + let movie_data = XtreamMovieData { + stream_id: 0, + name: movie.title.clone(), + + added: Some(entry.file_modified.to_string()), + + category_id: None, + category_ids: Vec::new(), + + container_extension, + custom_sid: None, + direct_source: String::new(), + }; + + Some(XtreamMovieInfo { info, movie_data }) +} diff --git a/backend/src/processing/processor/mod.rs b/backend/src/processing/processor/mod.rs index 042ac9a26..760e45bea 100644 --- a/backend/src/processing/processor/mod.rs +++ b/backend/src/processing/processor/mod.rs @@ -6,6 +6,7 @@ mod xtream_series; pub mod epg; mod sort; pub mod trakt; +mod library; #[macro_export] macro_rules! handle_error { diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index d824ace2d..b461fda78 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -34,6 +34,7 @@ use shared::foundation::filter::{get_field_value, set_field_value, Filter, Value use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistUpdateState, ProcessingOrder, UUIDType, XtreamCluster}; use shared::utils::{default_as_default, hash_bytes}; use std::time::Instant; +use crate::processing::processor::library; fn is_valid(pli: &PlaylistItem, filter: &Filter) -> bool { let provider = ValueProvider { pli }; @@ -304,20 +305,21 @@ fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bo (!user_targets.enabled && target.enabled) || (user_targets.enabled && user_targets.has_target(target.id)) } -async fn playlist_download_from_input(client: &reqwest::Client, config: &Arc, input: &Arc) -> (Vec, Vec) { - let working_dir = &config.working_dir; +async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc, input: &Arc) -> (Vec, Vec) { + let config = &*app_config.config.load(); match input.input_type { - InputType::M3u => m3u::get_m3u_playlist(client, config, input, working_dir).await, - InputType::Xtream => xtream::get_xtream_playlist(config, client, input, working_dir).await, - InputType::Library | InputType::M3uBatch | InputType::XtreamBatch => (vec![], vec![]), + InputType::M3u => m3u::get_m3u_playlist(client, config, input).await, + InputType::Xtream => xtream::get_xtream_playlist(config, client, input).await, + InputType::M3uBatch | InputType::XtreamBatch => (vec![], vec![]), + InputType::Library => library::get_library_playlist(client, app_config, input).await, } } -async fn process_source(client: &reqwest::Client, cfg: Arc, source_idx: usize, +async fn process_source(client: &reqwest::Client, app_config: Arc, source_idx: usize, user_targets: Arc, event_manager: Option>, playlist_state: Option<&Arc>, ) -> (Vec, Vec, Vec) { - let sources = cfg.sources.load(); + let sources = app_config.sources.load(); let mut errors = vec![]; let mut input_stats = HashMap::::new(); let mut target_stats = Vec::::new(); @@ -327,12 +329,12 @@ async fn process_source(client: &reqwest::Client, cfg: Arc, source_id let mut source_downloaded = false; for input in &source.inputs { if is_input_enabled(input, &user_targets) { - let config = cfg.config.load(); - let working_dir = &config.working_dir; source_downloaded = true; let start_time = Instant::now(); - let (mut playlistgroups, mut error_list) = playlist_download_from_input(client, &config, input).await; + let (mut playlistgroups, mut error_list) = playlist_download_from_input(client, &app_config, input).await; let (tvguide, mut tvguide_errors) = if error_list.is_empty() { + let working_dir = &app_config.config.load().working_dir; + epg::get_xmltv(client, input, working_dir).await } else { (None, vec![]) @@ -370,7 +372,7 @@ async fn process_source(client: &reqwest::Client, cfg: Arc, source_id for target in &source.targets { let event_manager_clone = event_manager_clone.clone(); if is_target_enabled(target, &user_targets) { - match process_playlist_for_target(&cfg, client, &mut source_playlists, target, &mut input_stats, &mut errors, event_manager_clone, playlist_state).await { + match process_playlist_for_target(&app_config, client, &mut source_playlists, target, &mut input_stats, &mut errors, event_manager_clone, playlist_state).await { Ok(()) => { target_stats.push(TargetStats::success(&target.name)); } @@ -415,7 +417,11 @@ async fn process_sources(client: &reqwest::Client, config: &Arc, user } let errors = Arc::new(Mutex::>::new(vec![])); let stats = Arc::new(Mutex::>::new(vec![])); - for (index, _) in sources.sources.iter().enumerate() { + for (index, source) in sources.sources.iter().enumerate() { + if !source.should_process_for_user_targets(&user_targets) { + continue; + } + // We're using the file lock this way on purpose let source_lock_path = PathBuf::from(format!("source_{index}")); let Ok(update_lock) = config.file_locks.try_write_lock(&source_lock_path).await else { diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index 8a82efcfb..97fc07295 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -490,11 +490,11 @@ mod tests { api_proxy: Arc::new(ArcSwapAny::default()), paths: Arc::new(ArcSwap::from(Arc::new(ConfigPaths { config_path: temp_dir().to_string_lossy().to_string(), - config_file_path: "".to_string(), - sources_file_path: "".to_string(), + config_file_path: String::new(), + sources_file_path: String::new(), mapping_file_path: None, - api_proxy_file_path: "".to_string(), - library_file_path: "".to_string(), + api_proxy_file_path: String::new(), + library_file_path: String::new(), custom_stream_response_path: None, }))), file_locks: Arc::new(FileLockManager::default()), diff --git a/backend/src/utils/file/file_utils.rs b/backend/src/utils/file/file_utils.rs index 1601ce315..2341f2022 100644 --- a/backend/src/utils/file/file_utils.rs +++ b/backend/src/utils/file/file_utils.rs @@ -1,17 +1,17 @@ +use crate::utils::debug_if_enabled; +use log::{debug, error}; +use path_clean::PathClean; +use shared::error::str_to_io_error; +use shared::utils::{API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, LIBRARY_FILE, MAPPING_FILE, SOURCE_FILE, USER_FILE}; use std::borrow::Cow; use std::collections::HashSet; use std::fs::{File, OpenOptions}; +use std::io::Read; use std::path::{Path, PathBuf}; use std::{env, fs}; -use std::io::Read; -use shared::error::str_to_io_error; -use crate::utils::debug_if_enabled; -use shared::utils::{API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, MAPPING_FILE, SOURCE_FILE, USER_FILE, LIBRARY_FILE}; -use log::{debug, error}; -use path_clean::PathClean; use tokio::fs as tokio_fs; -pub const IO_BUFFER_SIZE: usize = 256*1024; // 256kb +pub const IO_BUFFER_SIZE: usize = 256 * 1024; // 256kb pub fn file_writer(w: W) -> std::io::BufWriter where @@ -369,8 +369,8 @@ pub fn truncate_filename(path: &Path, max_len: usize) -> PathBuf { if max_len > ext_len { let name_len = max_len - ext_len; let name_without_ext = path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default(); + .and_then(|s| s.to_str()) + .unwrap_or_default(); let truncated = name_without_ext.chars().take(name_len).collect::(); format!("{truncated}.{ext}") } else { @@ -395,6 +395,13 @@ pub fn normalize_string_path(path: &str) -> String { .to_string() } +pub fn get_file_extension(path: &str) -> Option { + Path::new(path) + .extension() + .and_then(|s| s.to_str()) + .map(ToString::to_string) +} + #[cfg(test)] mod tests { use super::normalize_string_path; diff --git a/backend/src/utils/network/m3u.rs b/backend/src/utils/network/m3u.rs index dd12c7f42..058c69185 100644 --- a/backend/src/utils/network/m3u.rs +++ b/backend/src/utils/network/m3u.rs @@ -6,7 +6,8 @@ use crate::processing::parser::m3u; use crate::utils::prepare_file_path; use crate::utils::request; -pub async fn get_m3u_playlist(client: &reqwest::Client, cfg: &Arc, input: &Arc, working_dir: &str) -> (Vec, Vec) { +pub async fn get_m3u_playlist(client: &reqwest::Client, cfg: &Arc, input: &Arc) -> (Vec, Vec) { + let working_dir = &cfg.working_dir; let input_source: InputSource = { match input.staged.as_ref() { None => input.as_ref().into(), diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index 7cc61c9b7..1af42a66e 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -54,6 +54,18 @@ pub fn classify_content_type(headers: &[(String, String)]) -> MimeCategory { }) } +pub fn content_type_from_ext(ext: &str) -> &'static str { + match ext.to_ascii_lowercase().as_str() { + "mp4" => "video/mp4", + "mkv" => "video/x-matroska", + "avi" => "video/x-msvideo", + "mov" => "video/quicktime", + "webm" => "video/webm", + "ts" => "video/mp2t", + _ => "application/octet-stream", + } +} + pub async fn get_input_epg_content_as_file(client: &reqwest::Client, input: &ConfigInput, working_dir: &str, url_str: &str, persist_filepath: Option) -> Result { debug_if_enabled!("getting input epg content working_dir: {}, url: {}", working_dir, sanitize_sensitive_info(url_str)); if url_str.parse::().is_ok() { @@ -606,6 +618,21 @@ pub fn create_client(cfg: &AppConfig) -> reqwest::ClientBuilder { client } +pub fn parse_range(range: &str) -> Option<(u64, Option)> { + // expect: "bytes=START-END" + if !range.starts_with("bytes=") { + return None; + } + + let range = &range[6..]; + let mut parts = range.split('-'); + + let start = parts.next()?.parse().ok()?; + let end = parts.next().and_then(|s| s.parse().ok()); + + Some((start, end)) +} + #[cfg(test)] mod tests { use shared::utils::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info}; @@ -640,3 +667,4 @@ mod tests { assert_eq!(get_base_url_from_str(url).unwrap(), expected); } } + diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 3c713b27b..736cbbe16 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -202,7 +202,7 @@ pub async fn notify_account_expire(exp_date: Option, cfg: &Config, client: } } -pub async fn get_xtream_playlist(cfg: &Arc, client: &reqwest::Client, input: &Arc, working_dir: &str) -> (Vec, Vec) { +pub async fn get_xtream_playlist(cfg: &Arc, client: &reqwest::Client, input: &Arc) -> (Vec, Vec) { let input_source: InputSource = { match input.staged.as_ref() { None => input.as_ref().into(), @@ -226,6 +226,8 @@ pub async fn get_xtream_playlist(cfg: &Arc, client: &reqwest::Client, in let mut playlist_groups: Vec = Vec::with_capacity(128); let skip_cluster = get_skip_cluster(input); + let working_dir = &cfg.working_dir; + let mut errors = vec![]; for (xtream_cluster, category, stream) in &ACTIONS { if !skip_cluster.contains(xtream_cluster) { diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index d9464eafb..97b6f4280 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -17,7 +17,8 @@ const LABEL_EDIT: &str = "LABEL.EDIT"; const LABEL_VIEW: &str = "LABEL.VIEW"; const LABEL_SAVE: &str = "LABEL.SAVE"; const LABEL_UPDATE_GEOIP: &str = "LABEL.UPDATE_GEOIP_DB"; -const LABEL_UPDATE_LOCAL_LIBRARY: &str = "LABEL.UPDATE_LOCAL_LIBRARY"; + +const ACTION_UPDATE_GEO_IP: &str = "update_geo_ip"; macro_rules! collect_modified { ($forms:expr, [ $($field:ident),+ $(,)? ]) => {{ @@ -210,20 +211,11 @@ pub fn ConfigView() -> Html { let services = services.clone(); let translate = translate.clone(); wasm_bindgen_futures::spawn_local(async move { - match name.as_str() { - "update_geo_ip" => { - match services.config.update_geoip().await { - Ok(_) => services.toastr.success(translate.t("MESSAGES.DOWNLOAD.GEOIP.SUCCESS")), - Err(_err) => services.toastr.error(translate.t("MESSAGES.DOWNLOAD.GEOIP.FAIL")), - } + if name.as_str() == ACTION_UPDATE_GEO_IP { + match services.config.update_geoip().await { + Ok(_) => services.toastr.success(translate.t("MESSAGES.DOWNLOAD.GEOIP.SUCCESS")), + Err(_err) => services.toastr.error(translate.t("MESSAGES.DOWNLOAD.GEOIP.FAIL")), } - "update_library" => { - match services.config.update_library().await { - Ok(_) => services.toastr.success(translate.t("MESSAGES.LIBRARY_UPDATE.SUCCESS")), - Err(_err) => services.toastr.error(translate.t("MESSAGES.LIBRARY_UPDATE.FAIL")), - } - } - _ => {} } }); }) @@ -235,7 +227,6 @@ pub fn ConfigView() -> Html { }; let geo_ip_enabled = config_ctx.config.as_ref().is_some_and(|c| c.config.is_geoip_enabled()); - let library_enabled = config_ctx.config.as_ref().is_some_and(|c| c.config.is_library_enabled()); html! { context={context}> @@ -244,17 +235,11 @@ pub fn ConfigView() -> Html {

{ translate.t(LABEL_CONFIG) }

{html_if!(geo_ip_enabled, { - })} - {html_if!(library_enabled, { - - })}
Html { let translate = use_translation(); let playlist_ctx = use_context::().expect("Playlist context not found"); + let config_ctx = use_context::().expect("Config context not found"); let services_ctx = use_service_context(); let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.UPDATE")])); let selected_targets = use_list::>(vec![]); @@ -22,7 +28,6 @@ pub fn PlaylistUpdateView() -> Html { }) }; - let handle_target_select = { let selected_targets = selected_targets.clone(); Callback::from(move |target: Rc| { @@ -58,12 +63,39 @@ pub fn PlaylistUpdateView() -> Html { }) }; + let handle_update_content = { + let services = services_ctx.clone(); + let translate = translate.clone(); + Callback::from(move |name: String| { + let services = services.clone(); + let translate = translate.clone(); + wasm_bindgen_futures::spawn_local(async move { + if name.as_str() == ACTION_UPDATE_LIBRARY { + match services.config.update_library().await { + Ok(_) => services.toastr.success(translate.t("MESSAGES.LIBRARY_UPDATE.SUCCESS")), + Err(_err) => services.toastr.error(translate.t("MESSAGES.LIBRARY_UPDATE.FAIL")), + } + } + }); + }) + }; + + let library_enabled = config_ctx.config.as_ref().is_some_and(|c| c.config.is_library_enabled()); + html! {

{ translate.t("LABEL.UPDATE")}

- + {html_if!(library_enabled, { + + })} +
+ diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 216075d94..78f489c7a 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -281,6 +281,7 @@ pub struct ConfigInputDto { pub input_type: InputType, #[serde(default)] pub headers: HashMap, + #[serde(default)] pub url: String, #[serde(skip_serializing_if = "Option::is_none")] pub epg: Option, diff --git a/shared/src/model/config/macros.rs b/shared/src/model/config/macros.rs index 9736012ea..6a85fa602 100644 --- a/shared/src/model/config/macros.rs +++ b/shared/src/model/config/macros.rs @@ -1,13 +1,16 @@ #[macro_export] macro_rules! check_input_credentials { ($this:ident, $input_type:expr, $definition:expr ) => { - $this.url = $this.url.trim().to_string(); - if $this.url.is_empty() { - return Err(info_err!("url for input is mandatory".to_string())); - } - $this.username = $crate::utils::get_trimmed_string(&$this.username); - $this.password = $crate::utils::get_trimmed_string(&$this.password); + if !matches!($input_type, InputType::Library) { + $this.url = $this.url.trim().to_string(); + if $this.url.is_empty() { + return Err(info_err!("url for input is mandatory".to_string())); + } + + $this.username = $crate::utils::get_trimmed_string(&$this.username); + $this.password = $crate::utils::get_trimmed_string(&$this.password); + } match $input_type { InputType::M3u => { if $this.username.is_some() || $this.password.is_some() { diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index a7049997d..8c46006e7 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -463,8 +463,8 @@ impl XtreamPlaylistItem { group: self.group.clone(), title: self.title.clone(), parent_code: self.parent_code.clone(), - audio_track: "".to_string(), - time_shift: "".to_string(), + audio_track: String::new(), + time_shift: String::new(), rec: self.rec.clone(), url: self.url.clone(), input_name: self.input_name.clone(), diff --git a/shared/src/utils/hash_utils.rs b/shared/src/utils/hash_utils.rs index e1daa2732..ab727a035 100644 --- a/shared/src/utils/hash_utils.rs +++ b/shared/src/utils/hash_utils.rs @@ -3,6 +3,15 @@ use base64::engine::general_purpose; use std::fmt::Write; use crate::model::{PlaylistItemType, UUIDType}; +#[inline] +pub fn string_to_uuid_type(text: &str) -> UUIDType { + let mut bytes = [0u8; 32]; + let s_bytes = text.as_bytes(); + let len = s_bytes.len().min(32); + bytes[..len].copy_from_slice(&s_bytes[..len]); + bytes +} + #[inline] pub fn hash_bytes(bytes: &[u8]) -> UUIDType { blake3::hash(bytes).into()