From a571f863bbbfbd9046b82f9645fb218632e32c93 Mon Sep 17 00:00:00 2001 From: euzu Date: Tue, 16 Dec 2025 19:59:53 +0100 Subject: [PATCH] Integrate local movie file into playlist --- backend/src/api/api_utils.rs | 3 + .../src/api/endpoints/api_playlist_utils.rs | 7 +- backend/src/api/endpoints/m3u_api.rs | 2 +- backend/src/api/endpoints/xtream_api.rs | 119 ++++++++++-------- backend/src/model/additional_properties.rs | 68 ---------- backend/src/model/mod.rs | 4 +- backend/src/model/xtream.rs | 67 +++++++++- backend/src/processing/processor/library.rs | 17 ++- backend/src/processing/processor/trakt.rs | 7 +- .../src/processing/processor/xtream_series.rs | 6 +- .../src/repository/m3u_playlist_iterator.rs | 4 +- backend/src/repository/m3u_repository.rs | 2 +- backend/src/repository/strm_repository.rs | 48 ++++--- backend/src/repository/xtream_repository.rs | 55 ++++---- .../app/components/dashboard/streams_table.rs | 7 +- shared/src/foundation/filter.rs | 8 +- shared/src/model/playlist.rs | 46 +++++-- 17 files changed, 270 insertions(+), 200 deletions(-) delete mode 100644 backend/src/model/additional_properties.rs diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index eae9dbf35..82b95b48e 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -695,6 +695,9 @@ fn is_throttled_stream(item_type: PlaylistItemType, throttle_kbps: usize) -> boo | PlaylistItemType::Series | PlaylistItemType::SeriesInfo | PlaylistItemType::Catchup + | PlaylistItemType::LocalVideo + | PlaylistItemType::LocalSeries + | PlaylistItemType::LocalSeriesInfo ) } diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index 94562d329..188e89fda 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -59,11 +59,14 @@ fn group_playlist_items_by_cluster(params: Option<(utils::FileReadGuard, live.push(item); } PlaylistItemType::Catchup - | PlaylistItemType::Video => { + | PlaylistItemType::Video + | PlaylistItemType::LocalVideo => { video.push(item); } PlaylistItemType::Series - | PlaylistItemType::SeriesInfo => { + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeries + | PlaylistItemType::LocalSeriesInfo => { series.push(item); } } diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 8932401e0..d6dde442e 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -131,7 +131,7 @@ async fn m3u_api_stream( format!("Cant find input {} for target {target_name}, stream_id {virtual_id}", pli.input_name) ); - if pli.url.starts_with("file://") { + if matches!(pli.item_type, PlaylistItemType::LocalVideo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo) { let connection_permission = user.connection_permission(app_state).await; return local_stream_response( fingerprint, diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 3ca0d624b..050de3f8c 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -7,7 +7,7 @@ use crate::api::api_utils::{ is_seek_request, redirect_response, resource_response, separate_number_and_remainder, serve_file, stream_response, RedirectParams, }; -use crate::api::api_utils::{redirect, try_result_not_found, try_option_bad_request, try_result_bad_request}; +use crate::api::api_utils::{redirect, try_option_bad_request, try_result_bad_request, try_result_not_found}; 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; @@ -15,14 +15,15 @@ use crate::api::model::UserApiRequest; use crate::api::model::XtreamAuthorizationResponse; use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType}; use crate::auth::Fingerprint; -use crate::model::{InputSource, ProxyUserCredentials}; use crate::model::{AppConfig, ConfigTarget}; use crate::model::{Config, ConfigInput}; +use crate::model::{InputSource, ProxyUserCredentials}; use crate::repository::playlist_repository::get_target_id_mapping; use crate::repository::storage::get_target_storage_path; +use crate::repository::target_id_mapping::VirtualIdRecord; use crate::repository::{storage_const, user_repository, xtream_repository}; -use crate::utils::{debug_if_enabled, trace_if_enabled}; use crate::utils::xtream::create_vod_info_from_item; +use crate::utils::{debug_if_enabled, trace_if_enabled}; use crate::utils::{request, xtream}; use axum::http::HeaderMap; use axum::response::IntoResponse; @@ -46,7 +47,6 @@ use std::fmt::{Display, Formatter}; use std::path::Path; use std::str::FromStr; use std::sync::Arc; -use crate::repository::target_id_mapping::VirtualIdRecord; #[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)] pub enum ApiStreamContext { @@ -235,7 +235,7 @@ async fn xtream_player_api_stream( format!("Could not find any user for xc stream {}", stream_req.username) ); - let _guard = app_state.app_config.file_locks.write_lock_str(&user.username).await; + let _guard = app_state.app_config.file_locks.write_lock_str(&user.username).await; if user.permission_denied(app_state) { return create_custom_video_stream_response(app_state, &fingerprint.addr, CustomVideoStreamType::UserAccountExpired).await.into_response(); @@ -265,7 +265,7 @@ 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://") { + if matches!(pli.item_type, PlaylistItemType::LocalVideo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo) { let connection_permission = user.connection_permission(app_state).await; return local_stream_response( fingerprint, @@ -280,9 +280,9 @@ async fn xtream_player_api_stream( } let (cluster, item_type) = if stream_req.context == ApiStreamContext::Timeshift { - (XtreamCluster::Video, PlaylistItemType::Catchup) + (XtreamCluster::Video, PlaylistItemType::Catchup) } else { - (pli.xtream_cluster, pli.item_type) + (pli.xtream_cluster, pli.item_type) }; debug_if_enabled!( @@ -299,7 +299,7 @@ async fn xtream_player_api_stream( app_state, &fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted, ).await - .into_response(); + .into_response(); } if app_state @@ -311,7 +311,7 @@ async fn xtream_player_api_stream( app_state, &fingerprint.addr, CustomVideoStreamType::ProviderConnectionsExhausted, ).await - .into_response(); + .into_response(); } let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type); @@ -327,8 +327,8 @@ async fn xtream_player_api_stream( &input, &user, ) - .await - .into_response(); + .await + .into_response(); } session.stream_url.as_str() @@ -342,7 +342,7 @@ async fn xtream_player_api_stream( app_state, &fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted, ).await - .into_response(); + .into_response(); } let context = stream_req.context; @@ -399,8 +399,8 @@ async fn xtream_player_api_stream( req_headers, connection_permission, ) - .await - .into_response(); + .await + .into_response(); } let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type); @@ -417,8 +417,8 @@ async fn xtream_player_api_stream( &user, connection_permission, ) - .await - .into_response() + .await + .into_response() } #[allow(clippy::too_many_lines)] @@ -483,15 +483,15 @@ async fn xtream_player_api_stream_with_token( comment: None, }; - if pli.url.starts_with("file://") { + if matches!(pli.item_type, PlaylistItemType::LocalVideo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo) { return local_stream_response(fingerprint, - app_state, - pli.to_stream_channel(target.id), - req_headers, - &input, - &target, - &user, - UserConnectionPermission::Allowed, + app_state, + pli.to_stream_channel(target.id), + req_headers, + &input, + &target, + &user, + UserConnectionPermission::Allowed, ).await.into_response(); } @@ -515,8 +515,8 @@ async fn xtream_player_api_stream_with_token( req_headers, UserConnectionPermission::Allowed, ) - .await - .into_response(); + .await + .into_response(); } let extension = stream_ext.unwrap_or_else(|| { @@ -560,8 +560,8 @@ async fn xtream_player_api_stream_with_token( &user, UserConnectionPermission::Allowed, ) - .await - .into_response() + .await + .into_response() } else { axum::http::StatusCode::BAD_REQUEST.into_response() } @@ -892,19 +892,19 @@ async fn xtream_player_api_timeshift_stream( &api_form_req.username, &api_req.username, ) - .to_string(); + .to_string(); let password = get_non_empty( ×hift_request.password, &api_form_req.password, &api_req.password, ) - .to_string(); + .to_string(); let stream_id = get_non_empty( ×hift_request.stream_id, &api_req.stream_id, &api_form_req.stream_id, ) - .to_string(); + .to_string(); let duration = get_non_empty( ×hift_request.duration, ×hift_request.duration, @@ -934,8 +934,8 @@ async fn xtream_player_api_timeshift_stream( &action_path, ), /*&addr*/ ) - .await - .into_response() + .await + .into_response() } async fn xtream_player_api_timeshift_query_stream( @@ -975,8 +975,8 @@ async fn xtream_player_api_timeshift_query_stream( &action_path, ), ) - .await - .into_response() + .await + .into_response() } async fn xtream_get_stream_info_response( @@ -997,6 +997,18 @@ async fn xtream_get_stream_info_response( target, Some(cluster), ).await { + if matches!(pli.item_type, PlaylistItemType::LocalVideo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo) { + if let Some(additional_properties) = pli.additional_properties.as_ref() { + return 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(additional_properties.clone()))); + } + } + if pli.provider_id > 0 { let input_name = &pli.input_name; if let Some(input) = app_state.app_config.get_input_by_name(input_name.as_str()) { @@ -1016,7 +1028,7 @@ async fn xtream_get_stream_info_response( info_url.as_str(), cluster, ) - .await + .await { return try_unwrap_body!(axum::response::Response::builder() .status(axum::http::StatusCode::OK) @@ -1082,12 +1094,11 @@ async fn xtream_get_short_epg( target, None, ).await { - let config = &app_state.app_config.config.load(); 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.clone()).await; } } } @@ -1122,7 +1133,7 @@ async fn xtream_get_short_epg( None, None, ) - .await + .await { Ok((content, _)) => ( axum::http::StatusCode::OK, @@ -1192,7 +1203,7 @@ async fn xtream_player_api_handle_content_action( TargetType::Xtream, XtreamCluster::Live, ) - .await + .await } crate::model::XC_ACTION_GET_VOD_CATEGORIES => { user_repository::user_get_bouquet_filter( @@ -1202,7 +1213,7 @@ async fn xtream_player_api_handle_content_action( TargetType::Xtream, XtreamCluster::Video, ) - .await + .await } crate::model::XC_ACTION_GET_SERIES_CATEGORIES => { user_repository::user_get_bouquet_filter( @@ -1212,7 +1223,7 @@ async fn xtream_player_api_handle_content_action( TargetType::Xtream, XtreamCluster::Series, ) - .await + .await } _ => None, }; @@ -1222,7 +1233,7 @@ async fn xtream_player_api_handle_content_action( &file_path, &HashMap::from([(crate::model::XC_TAG_CATEGORY_ID, flt)]), ) - .into_response(), + .into_response(), ); } return Some( @@ -1440,8 +1451,8 @@ async fn xtream_player_api( api_req.stream_id.trim(), api_req.limit.trim(), ) - .await - .into_response(); + .await + .into_response(); } crate::model::XC_ACTION_GET_CATCHUP_TABLE => { skip_json_response_if_flag_set!( @@ -1468,7 +1479,7 @@ async fn xtream_player_api( category_id, &user, ) - .await + .await { return response.into_response(); } @@ -1553,8 +1564,8 @@ async fn xtream_player_api( } fn xtream_create_content_stream( - xtream_iter: impl Iterator, -) -> impl Stream> { + xtream_iter: impl Iterator, +) -> impl Stream> { stream::once(async { Ok::(Bytes::from("[")) }).chain( stream::iter(xtream_iter.map(move |(line, has_next)| { Ok::(Bytes::from(if has_next { @@ -1563,9 +1574,9 @@ fn xtream_create_content_stream( line.clone() })) })) - .chain(stream::once(async { - Ok::(Bytes::from("]")) - })), + .chain(stream::once(async { + Ok::(Bytes::from("]")) + })), ) } @@ -1644,8 +1655,8 @@ async fn xtream_player_token_stream( target_id, ApiStreamRequest::from_access_token(ctxt, &token, &stream_id, ""), ) - .await - .into_response() + .await + .into_response() } pub fn xtream_api_register() -> axum::Router> { diff --git a/backend/src/model/additional_properties.rs b/backend/src/model/additional_properties.rs deleted file mode 100644 index a784f2d53..000000000 --- a/backend/src/model/additional_properties.rs +++ /dev/null @@ -1,68 +0,0 @@ -#[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/mod.rs b/backend/src/model/mod.rs index 3a106b657..68090e34b 100644 --- a/backend/src/model/mod.rs +++ b/backend/src/model/mod.rs @@ -8,7 +8,6 @@ mod playlist_categories; mod config; mod input_source; pub mod media_properties; -mod additional_properties; pub use self::playlist::*; pub use self::mapping::*; @@ -20,5 +19,4 @@ pub use self::playlist_categories::*; 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 +pub use self::media_properties::*; \ No newline at end of file diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index e18d531c3..1b88e2af9 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -8,7 +8,59 @@ use shared::utils::{deserialize_as_option_string, deserialize_as_string, deseria 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}; + +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) => { + if let Some(v) = $prop.as_ref() { + $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) => { + if let Some(v) = $prop.as_ref() { + $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); + }} + } + } +} #[derive(Debug, Default)] pub struct XtreamLoginInfo { @@ -108,6 +160,7 @@ impl XtreamStream { } pub fn get_additional_properties(&self) -> Option> { + let mut result = serde_json::Map::new(); if let Some(bdpath) = self.backdrop_path.as_ref() { if !bdpath.is_empty() { @@ -200,7 +253,7 @@ pub struct XtreamMovieData { pub stream_id: u32, pub name: String, pub added: Option, - pub category_id: Option, + pub category_id: u32, #[serde(default)] pub category_ids: Vec, pub container_extension: Option, @@ -591,7 +644,15 @@ 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()); if let Some(ref add_props) = props { - for (field_name, field_value) in add_props { + let doc = if pli.xtream_cluster == XtreamCluster::Video { + match add_props.get("info") { + Some(Value::Object(info_doc)) => info_doc, + _ => add_props, + } + } else { + add_props + }; + for (field_name, field_value) in doc { if !document.contains_key(field_name) { document.insert(field_name.clone(), field_value.to_owned()); } diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index cd899c114..7a198da79 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -61,10 +61,10 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s let raw = serde_json::to_string(&info).ok()?; RawValue::from_string(raw).ok() }); - (XtreamCluster::Video, PlaylistItemType::Video, add_props) + (XtreamCluster::Video, PlaylistItemType::LocalVideo, add_props) } MediaMetadata::Series(_s) => { - (XtreamCluster::Series, PlaylistItemType::SeriesInfo, None) + (XtreamCluster::Series, PlaylistItemType::LocalSeriesInfo, None) } } }; @@ -142,7 +142,14 @@ pub fn metadata_cache_entry_to_xtream_movie_info( backdrop_path: movie .fanart .as_ref() + .filter(|s| !s.is_empty()) .map(|f| vec![f.clone()]) + .or_else(|| { + movie.poster + .as_ref() + .filter(|s| !s.is_empty()) + .map(|p| vec![p.clone()]) + }) .unwrap_or_default(), duration_secs: movie.runtime.map(|r| (r * 60).to_string()), @@ -159,7 +166,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info( rating: movie.rating.map(|r| format!("{r:.2}")), runtime: movie.runtime.map(|r| (r * 60).to_string()), - status: None, + status: Some("Released".to_string()), }; let movie_data = XtreamMovieData { @@ -168,8 +175,8 @@ pub fn metadata_cache_entry_to_xtream_movie_info( added: Some(entry.file_modified.to_string()), - category_id: None, - category_ids: Vec::new(), + category_id: 0, + category_ids: vec![0], container_extension, custom_sid: None, diff --git a/backend/src/processing/processor/trakt.rs b/backend/src/processing/processor/trakt.rs index b2083a318..6ed1eff13 100644 --- a/backend/src/processing/processor/trakt.rs +++ b/backend/src/processing/processor/trakt.rs @@ -46,7 +46,12 @@ fn extract_tmdb_id_from_playlist_item(item: &PlaylistItem) -> Option { .get("tmdb_id") .and_then(get_u32_from_serde_value) .filter(|&id| id != 0) - .or_else(|| props.get("tmdb").and_then(get_u32_from_serde_value)); + .or_else(|| { + props.get("info") + .and_then(|info| info.get("tmdb_id")) + .and_then(get_u32_from_serde_value) + .filter(|&id| id != 0) + }); } } None diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index cfa7f2da4..e6fba65c0 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -67,7 +67,7 @@ async fn playlist_resolve_series_info(cfg: &AppConfig, client: &reqwest::Client, let series_info_count = fpl.playlistgroups.iter() .filter(|&plg| plg.xtream_cluster == XtreamCluster::Series) .flat_map(|plg| &plg.channels) - .filter(|&pli| pli.header.item_type == PlaylistItemType::SeriesInfo).count(); + .filter(|&pli| matches!(pli.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)).count(); info!("Found {series_info_count} series info to resolve"); @@ -80,7 +80,7 @@ async fn playlist_resolve_series_info(cfg: &AppConfig, client: &reqwest::Client, continue; } for pli in &mut plg.channels { - if pli.header.item_type != PlaylistItemType::SeriesInfo { + if !matches!(pli.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) { continue; } let (should_update, provider_id, ts) = should_update_series_info(pli, &processed_info_ids); @@ -179,7 +179,7 @@ async fn process_series_info( for pli in plg .channels .iter_mut() - .filter(|pli| pli.header.item_type == PlaylistItemType::SeriesInfo) + .filter(|pli| matches!(pli.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)) { let Some(provider_id) = pli.header.get_provider_id() else { continue; }; let Ok(content) = info_reader.get(&provider_id) else { continue; }; diff --git a/backend/src/repository/m3u_playlist_iterator.rs b/backend/src/repository/m3u_playlist_iterator.rs index dd24600f0..402b8e046 100644 --- a/backend/src/repository/m3u_playlist_iterator.rs +++ b/backend/src/repository/m3u_playlist_iterator.rs @@ -76,8 +76,8 @@ impl M3uPlaylistIterator { | PlaylistItemType::LiveUnknown | PlaylistItemType::LiveHls | PlaylistItemType::LiveDash => "live", - PlaylistItemType::Video => "movie", - PlaylistItemType::Series | PlaylistItemType::SeriesInfo => "series", + PlaylistItemType::Video | PlaylistItemType::LocalVideo => "movie", + PlaylistItemType::Series | PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo => "series", } } else { "" diff --git a/backend/src/repository/m3u_repository.rs b/backend/src/repository/m3u_repository.rs index a4c5f7a5d..8315a9664 100644 --- a/backend/src/repository/m3u_repository.rs +++ b/backend/src/repository/m3u_repository.rs @@ -93,7 +93,7 @@ pub async fn m3u_write_playlist( new_playlist .iter() .flat_map(|pg| &pg.channels) - .filter(|&pli| pli.header.item_type != PlaylistItemType::SeriesInfo) + .filter(|&pli| !matches!(pli.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)) .map(PlaylistItem::to_m3u) .collect::>(), ); diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index 01ce25600..b2d62d089 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -213,7 +213,8 @@ async fn get_tmdb_value( { let file_lock = cfg.file_locks.read_lock(&tmdb_path).await; match item_type { - PlaylistItemType::Series => { + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => { if let Ok(tree) = BPlusTree::::load(&tmdb_path) { @@ -227,7 +228,8 @@ async fn get_tmdb_value( return tmdb_id; } } - PlaylistItemType::Video => { + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => { if let Ok(tree) = BPlusTree::::load(&tmdb_path) { @@ -289,7 +291,8 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo { let input_name = header.input_name.clone(); let url = header.url.clone(); let (series_name, release_date, added, season, episode, tmdb_id) = match header.item_type { - PlaylistItemType::Series => { + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => { let series_name = match header.get_field("name") { Some(name) if !name.is_empty() => Some(name.to_string()), _ => header.get_additional_property_as_str("series_name"), @@ -306,7 +309,8 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo { .or_else(|| header.get_additional_property_as_u32("tmdb")); (series_name, release_date, added, season, episode, tmdb_id) } - PlaylistItemType::Video => { + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => { let name = header.get_field("name").map(|v| v.to_string()); let release_date = header.get_additional_property_as_str("release_date"); let added = header.get_additional_property_as_u64("added"); @@ -413,9 +417,7 @@ async fn cleanup_strm_output_directory( fn filter_strm_item(pli: &PlaylistItem) -> bool { let item_type = pli.header.item_type; - item_type == PlaylistItemType::Live - || item_type == PlaylistItemType::Video - || item_type == PlaylistItemType::Series + matches!(item_type, PlaylistItemType::Live | PlaylistItemType::Video | PlaylistItemType::LocalVideo | PlaylistItemType::Series | PlaylistItemType::LocalSeries) } fn get_relative_path_str(full_path: &Path, root_path: &Path) -> String { @@ -447,7 +449,8 @@ fn format_for_kodi( let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video => { + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => { let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb={tmdb_id}}}") } else { String::new() }; let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); let sanitized_title = sanitize_for_filename(name.trim(), false); @@ -465,7 +468,8 @@ fn format_for_kodi( } (dir_path, final_filename) } - PlaylistItemType::Series => { + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => { let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb={tmdb_id}}}") } else { String::new() }; let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); @@ -509,7 +513,8 @@ fn format_for_plex( let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video => { + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => { let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb-{tmdb_id}}}") } else { String::new() }; let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); let sanitized_title = sanitize_for_filename(name.trim(), false); @@ -527,7 +532,8 @@ fn format_for_plex( } (dir_path, final_filename) } - PlaylistItemType::Series => { + PlaylistItemType::Series + | PlaylistItemType::LocalSeries=> { let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb-{tmdb_id}}}") } else { String::new() }; let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); @@ -572,7 +578,8 @@ fn format_for_emby( let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video => { + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => { // Emby prefers the ID in the filename. let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid={tmdb_id}]") } else { String::new() }; let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); @@ -591,7 +598,8 @@ fn format_for_emby( } (dir_path, final_filename) } - PlaylistItemType::Series => { + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => { // For series, the ID goes in the folder name. let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid={tmdb_id}]") } else { String::new() }; let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); @@ -637,7 +645,8 @@ fn format_for_jellyfin( let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video => { + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => { let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid-{tmdb_id}]") } else { String::new() }; let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); let sanitized_title = sanitize_for_filename(name.trim(), false); @@ -655,7 +664,8 @@ fn format_for_jellyfin( } (dir_path, final_filename) } - PlaylistItemType::Series => { + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => { let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid-{tmdb_id}]") } else { String::new() }; let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); @@ -1066,9 +1076,13 @@ fn get_strm_url( } if let Some(stream_type) = match str_item_info.item_type { - PlaylistItemType::Series => Some("series"), PlaylistItemType::Live => Some("live"), - PlaylistItemType::Video => Some("movie"), + PlaylistItemType::Series + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeries + | PlaylistItemType::LocalSeriesInfo => Some("series"), + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => Some("movie"), _ => None, } { let url = str_item_info.url.as_str(); diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index e3952ff8a..bee1153e0 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -96,9 +96,12 @@ pub fn xtream_get_info_file_paths( pub fn xtream_get_record_file_path(storage_path: &Path, item_type: PlaylistItemType) -> Option { match item_type { - PlaylistItemType::Video => Some(storage_path.join(format!("{}.{}", storage_const::FILE_VOD_INFO_RECORD, storage_const::FILE_SUFFIX_DB))), - PlaylistItemType::SeriesInfo => Some(storage_path.join(format!("{}.{}", storage_const::FILE_SERIES_INFO_RECORD, storage_const::FILE_SUFFIX_DB))), - PlaylistItemType::Series => Some(storage_path.join(format!("{}.{}", storage_const::FILE_SERIES_EPISODE_RECORD, storage_const::FILE_SUFFIX_DB))), + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => Some(storage_path.join(format!("{}.{}", storage_const::FILE_VOD_INFO_RECORD, storage_const::FILE_SUFFIX_DB))), + PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeriesInfo => Some(storage_path.join(format!("{}.{}", storage_const::FILE_SERIES_INFO_RECORD, storage_const::FILE_SUFFIX_DB))), + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => Some(storage_path.join(format!("{}.{}", storage_const::FILE_SERIES_EPISODE_RECORD, storage_const::FILE_SUFFIX_DB))), _ => None, } } @@ -375,12 +378,14 @@ async fn xtream_get_item_for_stream_id_from_memory( Some(xtream_storage) => { let mapping = xtream_storage.id_mapping.query(&virtual_id).ok_or_else(|| str_to_io_error(&format!("Could not find mapping for target {} and id {}", target.name, virtual_id)))?.clone(); let result = match mapping.item_type { - PlaylistItemType::SeriesInfo => { + PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeriesInfo => { Ok(xtream_storage.series.query(&mapping.virtual_id) .ok_or_else(|| str_to_io_error(&format!("Failed to read xtream item for id {virtual_id}")))? .clone()) } - PlaylistItemType::Series => { + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => { if let Some(item) = xtream_storage.series.query(&mapping.parent_virtual_id) { let mut xc_item = item.clone(); xc_item.provider_id = mapping.provider_id; @@ -455,27 +460,29 @@ pub async fn xtream_get_item_for_stream_id( let mut target_id_mapping = BPlusTreeQuery::::try_new(&target_id_mapping_file).map_err(|err| str_to_io_error(&format!("Could not load id mapping for target {} err:{err}", target.name)))?; let mapping = target_id_mapping.query(&virtual_id).ok_or_else(|| str_to_io_error(&format!("Could not find mapping for target {} and id {}", target.name, virtual_id)))?; let result = match mapping.item_type { - PlaylistItemType::SeriesInfo => { + PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeriesInfo => { xtream_read_series_item_for_stream_id(app_config, virtual_id, &storage_path).await } - PlaylistItemType::Series => { - if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path).await { - item.provider_id = mapping.provider_id; - item.item_type = PlaylistItemType::Series; - item.virtual_id = mapping.virtual_id; - Ok(item) - } else { - xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series).await - } - } - PlaylistItemType::Catchup => { - let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; - let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster).await?; - item.provider_id = mapping.provider_id; - item.item_type = PlaylistItemType::Catchup; - item.virtual_id = mapping.virtual_id; - Ok(item) - } + PlaylistItemType::Series + | PlaylistItemType::LocalSeries => { + if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path).await { + item.provider_id = mapping.provider_id; + item.item_type = PlaylistItemType::Series; + item.virtual_id = mapping.virtual_id; + Ok(item) + } else { + xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series).await + } + } + PlaylistItemType::Catchup => { + let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; + let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster).await?; + item.provider_id = mapping.provider_id; + item.item_type = PlaylistItemType::Catchup; + item.virtual_id = mapping.virtual_id; + Ok(item) + } _ => { let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster).await diff --git a/frontend/src/app/components/dashboard/streams_table.rs b/frontend/src/app/components/dashboard/streams_table.rs index 6f9aa6638..37f85a169 100644 --- a/frontend/src/app/components/dashboard/streams_table.rs +++ b/frontend/src/app/components/dashboard/streams_table.rs @@ -149,9 +149,12 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { match channel.item_type { PlaylistItemType::LiveUnknown | PlaylistItemType::Live => LIVE, - PlaylistItemType::Video => MOVIE, + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => MOVIE, PlaylistItemType::Series - | PlaylistItemType::SeriesInfo => SERIES, + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeries + | PlaylistItemType::LocalSeriesInfo => SERIES, PlaylistItemType::Catchup => CATCHUP, PlaylistItemType::LiveHls => HLS, PlaylistItemType::LiveDash => DASH diff --git a/shared/src/foundation/filter.rs b/shared/src/foundation/filter.rs index 9cb0b1d2e..64c8b6ab4 100644 --- a/shared/src/foundation/filter.rs +++ b/shared/src/foundation/filter.rs @@ -230,8 +230,12 @@ impl std::fmt::Display for Filter { Self::TypeComparison(field, item_type) => { write!(f, "{} = {}", field, match item_type { PlaylistItemType::Live => Self::LIVE, - PlaylistItemType::Video => Self::MOVIE, - PlaylistItemType::Series | PlaylistItemType::SeriesInfo => Self::SERIES, // yes series-info is handled as series in filter + PlaylistItemType::Video + | PlaylistItemType::LocalVideo => Self::MOVIE, + PlaylistItemType::Series + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeries + | PlaylistItemType::LocalSeriesInfo => Self::SERIES, // yes series-info is handled as series in filter _ => Self::UNSUPPORTED }) } diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 8c46006e7..9a943c489 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -62,8 +62,8 @@ impl TryFrom for XtreamCluster { fn try_from(item_type: PlaylistItemType) -> Result { match item_type { PlaylistItemType::Live | PlaylistItemType::LiveHls | PlaylistItemType::LiveDash | PlaylistItemType::LiveUnknown => Ok(Self::Live), - PlaylistItemType::Catchup | PlaylistItemType::Video => Ok(Self::Video), - PlaylistItemType::Series | PlaylistItemType::SeriesInfo => Ok(Self::Series), + PlaylistItemType::Catchup | PlaylistItemType::Video | PlaylistItemType::LocalVideo => Ok(Self::Video), + PlaylistItemType::Series | PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeries | PlaylistItemType::LocalSeriesInfo => Ok(Self::Series), } } } @@ -80,6 +80,9 @@ pub enum PlaylistItemType { LiveUnknown = 6, // No Provider id LiveHls = 7, // m3u8 entry LiveDash = 8, // mpd + LocalVideo = 9, + LocalSeries = 10, + LocalSeriesInfo = 11, } impl From for PlaylistItemType { @@ -100,8 +103,11 @@ impl FromStr for PlaylistItemType { match s { "Live" => Ok(PlaylistItemType::Live), "Video" => Ok(PlaylistItemType::Video), + "LocalVideo" => Ok(PlaylistItemType::LocalVideo), "Series" => Ok(PlaylistItemType::Series), "SeriesInfo" => Ok(PlaylistItemType::SeriesInfo), + "LocalSeries" => Ok(PlaylistItemType::LocalSeries), + "LocalSeriesInfo" => Ok(PlaylistItemType::LocalSeriesInfo), "Catchup" => Ok(PlaylistItemType::Catchup), "LiveUnknown" => Ok(PlaylistItemType::LiveUnknown), "LiveHls" => Ok(PlaylistItemType::LiveHls), @@ -123,9 +129,9 @@ impl Display for PlaylistItemType { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", match self { Self::Live | Self::LiveHls | Self::LiveDash | Self::LiveUnknown => Self::LIVE, - Self::Video => Self::VIDEO, - Self::Series => Self::SERIES, - Self::SeriesInfo => Self::SERIES_INFO, + Self::Video | Self::LocalVideo=> Self::VIDEO, + Self::Series | Self::LocalSeries => Self::SERIES, + Self::SeriesInfo | Self::LocalSeriesInfo => Self::SERIES_INFO, Self::Catchup => Self::CATCHUP, }) } @@ -196,10 +202,20 @@ impl PlaylistItemHeader { let raw = self.additional_properties.as_ref()?; let value: Value = serde_json::from_str(raw.get()).ok()?; - match value { - Value::Object(map) => map.get(field).cloned(), - _ => None, - } + let map = match value { + Value::Object(map) => map, + _ => return None, + }; + + map.get(field) + .cloned() + // fallback: info[field] + .or_else(|| { + map.get("info") + .and_then(|info| info.as_object()) + .and_then(|info_map| info_map.get(field)) + .cloned() + }) } pub fn get_additional_property_as_u32(&self, field: &str) -> Option { @@ -549,9 +565,15 @@ macro_rules! generate_field_accessor_impl_for_xtream_playlist_item { return match props { Some(doc) => { return if field == xtream_const::XC_PROP_COVER { - doc.get(&field).and_then(|value| value.as_str()).map(|s| Cow::::Owned(s.to_owned())) + doc.get(&field) + .or_else(|| doc.get("info").and_then(|d| d.get(&field))) + .and_then(|value| value.as_str()) + .map(|s| Cow::::Owned(s.to_owned())) } else { - get_backdrop_path_value(&field, doc.get(xtream_const::XC_PROP_BACKDROP_PATH)).map(|s| Cow::::Owned(s.to_string())) + doc.get(xtream_const::XC_PROP_BACKDROP_PATH) + .or_else(|| doc.get("info").and_then(|d| d.get(xtream_const::XC_PROP_BACKDROP_PATH))) + .and_then(|value| get_backdrop_path_value(&field, Some(value))) + .map(|s| Cow::::Owned(s.into_owned())) } } _=> None, @@ -610,7 +632,7 @@ impl PlaylistItem { let provider_id = header.id.parse::().unwrap_or_default(); let mut additional_properties: Option = None; - if header.xtream_cluster != XtreamCluster::Live { + if header.xtream_cluster != XtreamCluster::Live && header.item_type != PlaylistItemType::LocalVideo { let add_ext = match header.get_additional_property("container_extension") { None => true, Some(ext) => ext.as_str().is_none_or(str::is_empty),