diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 21adb5926..4f6963860 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1441,8 +1441,9 @@ pub async fn local_stream_response( req_headers: &HeaderMap, _input: &ConfigInput, _target: &ConfigTarget, - _user: &ProxyUserCredentials, + user: &ProxyUserCredentials, connection_permission: UserConnectionPermission, + playback_session_token: Option<&str>, check_path: bool, ) -> impl IntoResponse + Send { if log_enabled!(log::Level::Trace) { @@ -1450,12 +1451,24 @@ pub async fn local_stream_response( } if connection_permission == UserConnectionPermission::Exhausted { - return create_custom_video_stream_response( - app_state, - &fingerprint.addr, - CustomVideoStreamType::UserConnectionsExhausted, - ) - .into_response(); + let allow_session_reopen = if let Some(session_token) = playback_session_token { + user.max_connections > 0 + && app_state + .active_users + .connection_permission_for_session(&user.username, user.max_connections, session_token) + .await + != UserConnectionPermission::Exhausted + } else { + false + }; + if !allow_session_reopen { + return create_custom_video_stream_response( + app_state, + &fingerprint.addr, + CustomVideoStreamType::UserConnectionsExhausted, + ) + .into_response(); + } } let path = PathBuf::from(pli.url.strip_prefix("file://").unwrap_or(&pli.url)); @@ -1531,11 +1544,35 @@ pub async fn local_stream_response( } } - let stream = ReaderStream::new(file.take(content_length)); - let body_stream = - prepare_body_stream::<_>(app_state, pli.item_type, stream.map_err(|err| StreamError::Stream(err.to_string()))); + let stream = ReaderStream::new(file.take(content_length)) + .map_err(|err| StreamError::Stream(err.to_string())) + .boxed(); + let throttle_kbps = usize::try_from(get_stream_throttle(app_state)).unwrap_or_default(); + let stream = if is_throttled_stream(pli.item_type, throttle_kbps) { + info!("Stream throttling active: {}", human_readable_kbps(u64::try_from(throttle_kbps).unwrap_or_default())); + ThrottledStream::new(stream, throttle_kbps).boxed() + } else { + stream + }; + let mut grace_period_options = app_state.get_grace_options(); + if connection_permission != UserConnectionPermission::GracePeriod { + grace_period_options.period_millis = 0; + } + let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { + stream_details: StreamDetails::from_stream(stream, grace_period_options), + app_state, + user, + connection_permission, + fingerprint, + stream_channel: pli.clone(), + session_token: playback_session_token, + req_headers, + meter_uid: 0, + meter_stream: false, + }) + .await; - let mut response = Response::new(body_stream); + let mut response = Response::new(axum::body::Body::from_stream(stream)); *response.status_mut() = if range.is_some() { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; @@ -1557,6 +1594,7 @@ pub async fn local_stream_response( } } + mark_response_as_uncompressed(&mut response); response } @@ -2054,10 +2092,24 @@ pub fn empty_json_response_as_array() -> axum::http::Result AppConfig { + let input = Arc::new(ConfigInput { + id: 1, + name: "local_media".intern(), + input_type: InputType::Library, + headers: HashMap::default(), + url: "file:///tmp".to_string(), + enabled: true, + priority: 0, + max_connections: 1, + method: InputFetchMethod::default(), + aliases: None, + ..ConfigInput::default() + }); + let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; + + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(sources)), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + } + } + + fn create_test_app_state() -> Arc { + let app_cfg = Arc::new(create_test_app_config()); + let event_manager = Arc::new(EventManager::new()); + let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); + let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); + active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); + + let geoip = Arc::new(ArcSwapOption::::default()); + let config = app_cfg.config.load(); + let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + let connection_manager = + Arc::new(ConnectionManager::new(&active_users, &active_provider, &shared_stream_manager, &event_manager)); + + let tokens = CancelTokens::default(); + let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); + let (manual_update_sender, _) = mpsc::channel::>(1); + + Arc::new(AppState { + forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets { + enabled: false, + inputs: Vec::new(), + targets: Vec::new(), + target_names: Vec::new(), + })), + app_config: app_cfg, + http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + downloads: Arc::new(crate::api::model::DownloadQueue::new()), + cache: Arc::new(ArcSwapOption::default()), + shared_stream_manager, + active_users, + active_provider, + connection_manager, + event_manager, + cancel_tokens: Arc::new(ArcSwap::from_pointee(tokens)), + playlists: Arc::new(PlaylistStorageState::new()), + geoip, + update_guard: crate::api::model::UpdateGuard::new(), + metadata_manager, + manual_update_sender, + }) + } + + fn create_test_fingerprint(addr: std::net::SocketAddr) -> Fingerprint { + Fingerprint::new(format!("fp-{addr}"), addr.ip().to_string(), addr) + } + + fn create_test_local_channel(url: &str) -> StreamChannel { + StreamChannel { + target_id: 1, + virtual_id: 41, + provider_id: 0, + item_type: PlaylistItemType::LocalVideo, + cluster: XtreamCluster::Video, + group: "Local Movies".intern(), + title: "Local Test".intern(), + url: url.into(), + shared: false, + technical: None, + } + } + + #[tokio::test] + async fn local_stream_response_registers_active_local_stream() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let addr = "127.0.0.1:55123".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let user = ProxyUserCredentials::default(); + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + watch: None, + use_memory_cache: false, + }; + + let _response = local_stream_response( + &fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + None, + false, + ) + .await + .into_response(); + + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1, "local file streaming should register an active stream"); + assert_eq!(active_streams[0].channel.item_type, PlaylistItemType::LocalVideo); + } + + #[tokio::test] + async fn local_stream_response_disables_response_compression() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let addr = "127.0.0.1:55124".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let user = ProxyUserCredentials::default(); + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + watch: None, + use_memory_cache: false, + }; + + let response = local_stream_response( + &fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + None, + false, + ) + .await + .into_response(); + + assert!(!should_compress_response(&response)); + } + + #[tokio::test] + async fn local_stream_response_reuses_stable_playback_session_token_across_reopens() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let user = ProxyUserCredentials::default(); + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + watch: None, + use_memory_cache: false, + }; + let playback_session_token = "local-playback-token"; + + let first_fingerprint = create_test_fingerprint("127.0.0.1:55125".parse().unwrap_or_else(|_| unreachable!())); + let second_fingerprint = create_test_fingerprint("127.0.0.1:55126".parse().unwrap_or_else(|_| unreachable!())); + + let _first_response = local_stream_response( + &first_fingerprint, + &app_state, + channel.clone(), + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + Some(playback_session_token), + false, + ) + .await + .into_response(); + + let _second_response = local_stream_response( + &second_fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + Some(playback_session_token), + false, + ) + .await + .into_response(); + + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1, "stable playback token should reuse the tracked local connection"); + assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token)); + assert_eq!(active_streams[0].addr, second_fingerprint.addr); + } + + #[tokio::test] + async fn local_stream_response_allows_exhausted_reopen_for_same_playback_session_token() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let mut user = ProxyUserCredentials::default(); + user.username = "user1".to_string(); + user.max_connections = 1; + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + watch: None, + use_memory_cache: false, + }; + let playback_session_token = "local-playback-token"; + + let first_fingerprint = create_test_fingerprint("127.0.0.1:55127".parse().unwrap_or_else(|_| unreachable!())); + let second_fingerprint = create_test_fingerprint("127.0.0.1:55128".parse().unwrap_or_else(|_| unreachable!())); + + let _first_response = local_stream_response( + &first_fingerprint, + &app_state, + channel.clone(), + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + Some(playback_session_token), + false, + ) + .await + .into_response(); + + let second_response = local_stream_response( + &second_fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Exhausted, + Some(playback_session_token), + false, + ) + .await + .into_response(); + + assert_eq!(second_response.status(), StatusCode::OK); + + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1); + assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token)); + assert_eq!(active_streams[0].addr, second_fingerprint.addr); + } } diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index 1a28ca4f9..86ee38c67 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -451,6 +451,7 @@ async fn hls_api_stream( &target, &user, connection_permission, + Some(&session.token), false, ) .await diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index b48f126f7..566e9d6a6 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -130,6 +130,7 @@ async fn m3u_api_stream( if pli.item_type.is_local() { let connection_permission = user.connection_permission(app_state).await; + let playback_session_token = create_session_fingerprint(fingerprint, &user.username, virtual_id); return local_stream_response( fingerprint, app_state, @@ -139,6 +140,7 @@ async fn m3u_api_stream( &target, &user, connection_permission, + Some(playback_session_token.as_str()), true, ) .await diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 4ad0418b6..0c93e0b9a 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -52,7 +52,7 @@ use shared::{ UserConnectionPermission, XtreamCluster, XtreamPlaylistItem, }, utils::{ - deserialize_as_string, extract_extension_from_url, generate_playlist_uuid, sanitize_sensitive_info, trim_slash, + deserialize_as_string, extract_extension_from_url, generate_provider_playlist_uuid, sanitize_sensitive_info, trim_slash, Internable, HLS_EXT, }, }; @@ -281,6 +281,7 @@ async fn xtream_player_api_stream( if pli.item_type.is_local() { let connection_permission = user.connection_permission(app_state).await; + let playback_session_token = create_session_fingerprint(fingerprint, &user.username, virtual_id); return local_stream_response( fingerprint, app_state, @@ -290,6 +291,7 @@ async fn xtream_player_api_stream( &target, &user, connection_permission, + Some(playback_session_token.as_str()), true, ) .await @@ -511,6 +513,7 @@ async fn xtream_player_api_stream_with_token( let user = create_api_proxy_user(app_state); if pli.item_type.is_local() { + let playback_session_token = create_session_fingerprint(fingerprint, "webui", virtual_id); return local_stream_response( fingerprint, app_state, @@ -520,6 +523,7 @@ async fn xtream_player_api_stream_with_token( &target, &user, UserConnectionPermission::Allowed, + Some(playback_session_token.as_str()), true, ) .await @@ -1046,7 +1050,7 @@ async fn xtream_get_catchup_response( if let Some(cp_id) = epg_list_item.get(crate::model::XC_TAG_ID).and_then(Value::as_str).and_then(|id| id.parse::().ok()) { - let uuid = generate_playlist_uuid(&pli_uuid_str, &cp_id.to_string(), pli.item_type, &pli.input_name); + let uuid = generate_provider_playlist_uuid(&pli_uuid_str, &cp_id.to_string(), pli.item_type); tasks.push((idx, uuid, cp_id)); } } diff --git a/backend/src/api/model/metadata_update_manager.rs b/backend/src/api/model/metadata_update_manager.rs index e8c44181c..2e7c54092 100644 --- a/backend/src/api/model/metadata_update_manager.rs +++ b/backend/src/api/model/metadata_update_manager.rs @@ -24,7 +24,7 @@ use shared::{ InputType, LiveStreamProperties, PlaylistItemType, SeriesStreamProperties, UUIDType, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, }, - utils::generate_playlist_uuid, + utils::generate_provider_playlist_uuid, }; use std::{ cmp::min, @@ -2800,12 +2800,7 @@ impl InputWorker { } } ProviderIdType::Text(provider_id_text) => { - let uuid = generate_playlist_uuid( - input_name, - provider_id_text, - PlaylistItemType::Video, - props.direct_source.as_ref(), - ); + let uuid = generate_provider_playlist_uuid(input_name, provider_id_text, PlaylistItemType::Video); if let Some(virtual_id) = Self::get_cached_uuid_virtual_id(mapping, uuid_virtual_ids, uuid) { virtual_updates.insert(virtual_id, props); } @@ -2839,7 +2834,7 @@ impl InputWorker { } } ProviderIdType::Text(provider_id_text) => { - let uuid = generate_playlist_uuid(input_name, provider_id_text, PlaylistItemType::Series, ""); + let uuid = generate_provider_playlist_uuid(input_name, provider_id_text, PlaylistItemType::SeriesInfo); if let Some(virtual_id) = Self::get_cached_uuid_virtual_id(mapping, uuid_virtual_ids, uuid) { virtual_updates.insert(virtual_id, props); } @@ -2873,12 +2868,7 @@ impl InputWorker { } } ProviderIdType::Text(provider_id_text) => { - let uuid = generate_playlist_uuid( - input_name, - provider_id_text, - PlaylistItemType::Live, - props.direct_source.as_ref(), - ); + let uuid = generate_provider_playlist_uuid(input_name, provider_id_text, PlaylistItemType::Live); if let Some(virtual_id) = Self::get_cached_uuid_virtual_id(mapping, uuid_virtual_ids, uuid) { virtual_updates.insert(virtual_id, props); } @@ -4173,6 +4163,81 @@ mod tests { assert!(InputWorker::task_needs_provider_connection(&task, InputType::Library)); } + #[test] + fn collect_series_virtual_updates_resolves_text_ids_via_series_info_uuid() { + let dir = tempdir().expect("tempdir should be created"); + let mapping_path = dir.path().join("target_id_mapping.db"); + let mut mapping = TargetIdMapping::new(&mapping_path, false).expect("mapping should be created"); + let uuid = generate_provider_playlist_uuid("input_series", "series-text-id", PlaylistItemType::SeriesInfo); + let virtual_id = mapping.get_and_update_virtual_id(&uuid, 0, PlaylistItemType::SeriesInfo, 0); + mapping.persist().expect("mapping should persist"); + + let mut batch = BatchResultCollector::new(); + batch.add_series(ProviderIdType::from("series-text-id"), SeriesStreamProperties::default()); + + let mut provider_virtual_ids = HashMap::new(); + let mut uuid_virtual_ids = HashMap::new(); + let updates = InputWorker::collect_series_virtual_updates( + &mapping, + "input_series", + &batch, + &mut provider_virtual_ids, + &mut uuid_virtual_ids, + ); + + assert!(updates.contains_key(&virtual_id)); + } + + #[test] + fn collect_vod_virtual_updates_resolves_text_ids_via_video_uuid() { + let dir = tempdir().expect("tempdir should be created"); + let mapping_path = dir.path().join("target_id_mapping.db"); + let mut mapping = TargetIdMapping::new(&mapping_path, false).expect("mapping should be created"); + let uuid = generate_provider_playlist_uuid("input_vod", "vod-text-id", PlaylistItemType::Video); + let virtual_id = mapping.get_and_update_virtual_id(&uuid, 0, PlaylistItemType::Video, 0); + mapping.persist().expect("mapping should persist"); + + let mut batch = BatchResultCollector::new(); + batch.add_vod(ProviderIdType::from("vod-text-id"), VideoStreamProperties::default()); + + let mut provider_virtual_ids = HashMap::new(); + let mut uuid_virtual_ids = HashMap::new(); + let updates = InputWorker::collect_vod_virtual_updates( + &mapping, + "input_vod", + &batch, + &mut provider_virtual_ids, + &mut uuid_virtual_ids, + ); + + assert!(updates.contains_key(&virtual_id)); + } + + #[test] + fn collect_live_virtual_updates_resolves_text_ids_via_live_uuid() { + let dir = tempdir().expect("tempdir should be created"); + let mapping_path = dir.path().join("target_id_mapping.db"); + let mut mapping = TargetIdMapping::new(&mapping_path, false).expect("mapping should be created"); + let uuid = generate_provider_playlist_uuid("input_live", "live-text-id", PlaylistItemType::Live); + let virtual_id = mapping.get_and_update_virtual_id(&uuid, 0, PlaylistItemType::Live, 0); + mapping.persist().expect("mapping should persist"); + + let mut batch = BatchResultCollector::new(); + batch.add_live(ProviderIdType::from("live-text-id"), LiveStreamProperties::default()); + + let mut provider_virtual_ids = HashMap::new(); + let mut uuid_virtual_ids = HashMap::new(); + let updates = InputWorker::collect_live_virtual_updates( + &mapping, + "input_live", + &batch, + &mut provider_virtual_ids, + &mut uuid_virtual_ids, + ); + + assert!(updates.contains_key(&virtual_id)); + } + #[test] fn strip_tmdb_reasons_returns_none_for_tmdb_only_resolve_task() { let task = UpdateTask::ResolveVod { diff --git a/backend/src/library/metadata.rs b/backend/src/library/metadata.rs index 8631e645c..863135602 100644 --- a/backend/src/library/metadata.rs +++ b/backend/src/library/metadata.rs @@ -29,6 +29,18 @@ pub struct VideoClipMetadata { pub video_type: String, // "Trailer", "Teaser" } +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TechnicalMetadata { + #[serde(default)] + pub video: Option, + #[serde(default)] + pub audio: Option, + #[serde(default)] + pub duration_secs: Option, + #[serde(default)] + pub bitrate: Option, +} + // Movie metadata #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct MovieMetadata { @@ -74,6 +86,8 @@ pub struct MovieMetadata { pub source: MetadataSource, pub last_updated: i64, pub videos: Option>, + #[serde(default)] + pub technical: Option, } // Series/TV show metadata @@ -163,6 +177,8 @@ pub struct EpisodeMetadata { pub file_path: String, pub file_size: u64, pub file_modified: i64, + #[serde(default)] + pub technical: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/backend/src/library/processor.rs b/backend/src/library/processor.rs index 5da5ecf4a..0fc9f3710 100644 --- a/backend/src/library/processor.rs +++ b/backend/src/library/processor.rs @@ -1,18 +1,23 @@ use crate::api::model::create_http_client; -use crate::library::metadata::{EpisodeMetadata, MediaMetadata, MetadataCacheEntry}; +use crate::library::metadata::{EpisodeMetadata, MediaMetadata, MetadataCacheEntry, SeriesMetadata, TechnicalMetadata}; use crate::library::metadata_resolver::MetadataResolver; use crate::library::metadata_storage::MetadataStorage; use crate::library::scanner::LibraryScanner; use crate::library::{MediaGroup, MediaGrouper, thumbnail::{self, ThumbnailExtractor}}; use crate::model::{AppConfig, LibraryConfig, MetadataUpdateConfig}; -use crate::utils::ffmpeg::FfmpegExecutor; +use crate::utils::ffmpeg::{FfmpegExecutor, ProbeUrlOutcome}; use log::{debug, error, info, warn}; use path_clean::PathClean; use shared::model::{LibraryMetadataFormat, LibraryScanResult}; use std::collections::HashMap; +use std::future::Future; use std::path::PathBuf; +use std::pin::Pin; use std::sync::Arc; +type FfprobeAvailabilityFuture = Pin + Send>>; +type FfprobeAvailabilityChecker = Arc FfprobeAvailabilityFuture + Send + Sync>; + // Action taken when processing a file #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProcessAction { @@ -28,7 +33,9 @@ pub struct LibraryProcessor { resolver: MetadataResolver, storage: MetadataStorage, thumbnail_extractor: Option, + metadata_update_config: Option, app_config: Option>, // Need access to global config for FFprobe settings + ffprobe_availability_checker: FfprobeAvailabilityChecker, } pub fn resolve_metadata_storage_path( @@ -53,6 +60,10 @@ pub fn resolve_metadata_storage_path( } impl LibraryProcessor { + fn default_ffprobe_availability_checker() -> FfprobeAvailabilityChecker { + Arc::new(|| Box::pin(async { FfmpegExecutor::new().check_ffprobe_availability().await })) + } + // Creates a new Library processor from application config pub fn from_app_config(app_config: &AppConfig) -> Option { let Ok(client) = create_http_client(app_config) else { @@ -77,6 +88,22 @@ impl LibraryProcessor { metadata_update_config: Option<&MetadataUpdateConfig>, client: reqwest::Client, storage_dir: &str, + ) -> Self { + Self::new_with_ffprobe_availability_checker( + config, + metadata_update_config, + client, + storage_dir, + Self::default_ffprobe_availability_checker(), + ) + } + + fn new_with_ffprobe_availability_checker( + config: LibraryConfig, + metadata_update_config: Option<&MetadataUpdateConfig>, + client: reqwest::Client, + storage_dir: &str, + ffprobe_availability_checker: FfprobeAvailabilityChecker, ) -> Self { let storage_path = resolve_metadata_storage_path(metadata_update_config, storage_dir); let scanner = LibraryScanner::new(config.clone()); @@ -95,7 +122,9 @@ impl LibraryProcessor { resolver, storage, thumbnail_extractor, + metadata_update_config: metadata_update_config.cloned(), app_config: None, + ffprobe_availability_checker, } } @@ -130,11 +159,7 @@ impl LibraryProcessor { }; // Check global ffprobe config - let ffprobe_enabled = if let Some(app_cfg) = &self.app_config { - app_cfg.is_ffprobe_enabled().await - } else { - false - }; + let ffprobe_enabled = self.is_local_ffprobe_enabled().await; let ffmpeg_available = if self.thumbnail_extractor.is_some() { if let Some(app_cfg) = &self.app_config { @@ -237,7 +262,7 @@ impl LibraryProcessor { group: &MediaGroup, existing_map: &HashMap, force_rescan: bool, - _can_probe: bool, + can_probe: bool, can_extract_thumbnails: bool, ) -> Result { let MediaGroup::Movie { file, .. } = group else { return Err(format!("Expected movie to resolve but got {group}")) }; @@ -251,8 +276,8 @@ impl LibraryProcessor { debug!("File modified, updating metadata: {}", file.file_path); // Reuse existing UUID - let metadata = self.resolve_metadata(group).await?; - //self.enrich_metadata_with_ffprobe(&mut metadata, &file.file_path, can_probe); + let mut metadata = self.resolve_metadata(group).await?; + self.enrich_movie_metadata_with_ffprobe(&mut metadata, &file.file_path, can_probe).await; let entry = MetadataCacheEntry { uuid: existing_entry.uuid.clone(), @@ -267,8 +292,8 @@ impl LibraryProcessor { (entry, ProcessAction::Updated) } else { debug!("New file, resolving metadata: {}", file.file_path); - let metadata = self.resolve_metadata(group).await?; - //self.enrich_metadata_with_ffprobe(&mut metadata, &file.file_path, can_probe); + let mut metadata = self.resolve_metadata(group).await?; + self.enrich_movie_metadata_with_ffprobe(&mut metadata, &file.file_path, can_probe).await; let entry = MetadataCacheEntry::new( file.file_path.clone(), @@ -298,7 +323,7 @@ impl LibraryProcessor { group: &MediaGroup, existing_map: &HashMap, force_rescan: bool, - _can_probe: bool, + can_probe: bool, can_extract_thumbnails: bool, ) -> Result { let MediaGroup::Series { show_key, episodes } = group else { return Err(format!("Expected series to resolve but got {group}")) }; @@ -316,6 +341,7 @@ impl LibraryProcessor { // Build a map of existing per-episode thumbnail state so it can be // carried forward when the series metadata is rebuilt from scratch. let mut existing_ep_thumbs: HashMap<(u32, u32), (Option, i64)> = HashMap::new(); + let mut existing_ep_technical: HashMap)> = HashMap::new(); // Check if file already exists in cache let (mut chache_entry, status) = if let Some(existing_entry) = existing_map.get(&series_file_path) { @@ -336,6 +362,9 @@ impl LibraryProcessor { (ep.season, ep.episode), (ep.thumbnail_id.clone(), ep.file_modified), ); + if !ep.file_path.is_empty() { + existing_ep_technical.insert(ep.file_path.clone(), (ep.file_modified, ep.technical.clone())); + } } } } @@ -367,72 +396,104 @@ impl LibraryProcessor { }; if let (MediaMetadata::Series(ref mut series_metadata), MediaGroup::Series { episodes, .. }) = (&mut chache_entry.metadata, group) { - let series_episodes = series_metadata.episodes.get_or_insert_with(|| { - // No episode list from TMDB/NFO — synthesize stubs from scanned files - // so that file paths, sizes and timestamps get populated below. - episodes.iter().map(|ep| EpisodeMetadata { - title: ep.metadata.title.clone(), - season: ep.season, - episode: ep.episode, - file_path: String::new(), - file_size: 0, - file_modified: 0, - ..EpisodeMetadata::default() - }).collect() - }); + { + let series_episodes = series_metadata.episodes.get_or_insert_with(|| { + // No episode list from TMDB/NFO — synthesize stubs from scanned files + // so that file paths, sizes and timestamps get populated below. + episodes.iter().map(|ep| EpisodeMetadata { + title: ep.metadata.title.clone(), + season: ep.season, + episode: ep.episode, + file_path: String::new(), + file_size: 0, + file_modified: 0, + ..EpisodeMetadata::default() + }).collect() + }); - // maybe we have the same episode as 2 different files - let mut double_episodes = vec![]; - for episode in episodes { - for series_episode in &mut *series_episodes { - if episode.episode == series_episode.episode && episode.season == series_episode.season { - let previous_file_modified = series_episode.file_modified; - if series_episode.file_path.is_empty() { - // Carry forward existing thumbnail state so we don't - // re-extract thumbnails that are already cached. - let prev_mtime = if let Some((ref existing_thumb_id, existing_mtime)) = - existing_ep_thumbs.get(&(episode.season, episode.episode)) - { - series_episode.thumbnail_id.clone_from(existing_thumb_id); - Some(*existing_mtime) + // maybe we have the same episode as 2 different files + let mut double_episodes = vec![]; + for episode in episodes { + let mut matched_existing_episode = false; + for series_episode in &mut *series_episodes { + if episode.episode == series_episode.episode && episode.season == series_episode.season { + matched_existing_episode = true; + let previous_file_modified = series_episode.file_modified; + if series_episode.file_path.is_empty() { + // Carry forward existing thumbnail state so we don't + // re-extract thumbnails that are already cached. + let prev_mtime = if let Some((ref existing_thumb_id, existing_mtime)) = + existing_ep_thumbs.get(&(episode.season, episode.episode)) + { + series_episode.thumbnail_id.clone_from(existing_thumb_id); + Some(*existing_mtime) + } else { + None + }; + series_episode.file_path.clone_from(&episode.file.file_path); + series_episode.file_modified = episode.file.modified_timestamp; + series_episode.file_size = episode.file.size_bytes; + self.update_episode_thumbnail( + series_episode, + &episode.file.file_path, + episode.file.modified_timestamp, + prev_mtime, + can_extract_thumbnails, + ).await; } else { - None - }; - series_episode.file_path.clone_from(&episode.file.file_path); - series_episode.file_modified = episode.file.modified_timestamp; - series_episode.file_size = episode.file.size_bytes; - self.update_episode_thumbnail( - series_episode, - &episode.file.file_path, - episode.file.modified_timestamp, - prev_mtime, - can_extract_thumbnails, - ).await; - } else { - let mut new_episode = series_episode.clone(); - new_episode.file_path.clone_from(&episode.file.file_path); - new_episode.file_modified = episode.file.modified_timestamp; - new_episode.file_size = episode.file.size_bytes; - self.update_episode_thumbnail( - &mut new_episode, - &episode.file.file_path, - episode.file.modified_timestamp, - Some(previous_file_modified), - can_extract_thumbnails, - ).await; - double_episodes.push(new_episode); + let mut new_episode = series_episode.clone(); + new_episode.file_path.clone_from(&episode.file.file_path); + new_episode.file_modified = episode.file.modified_timestamp; + new_episode.file_size = episode.file.size_bytes; + self.update_episode_thumbnail( + &mut new_episode, + &episode.file.file_path, + episode.file.modified_timestamp, + Some(previous_file_modified), + can_extract_thumbnails, + ).await; + double_episodes.push(new_episode); + } } } + + if !matched_existing_episode { + let (thumbnail_id, previous_file_modified) = existing_ep_thumbs + .get(&(episode.season, episode.episode)) + .map_or((None, None), |(existing_thumb_id, existing_mtime)| { + (existing_thumb_id.clone(), Some(*existing_mtime)) + }); + let mut new_episode = EpisodeMetadata { + title: episode.metadata.title.clone(), + season: episode.season, + episode: episode.episode, + file_path: episode.file.file_path.clone(), + file_modified: episode.file.modified_timestamp, + file_size: episode.file.size_bytes, + thumbnail_id, + ..EpisodeMetadata::default() + }; + self.update_episode_thumbnail( + &mut new_episode, + &episode.file.file_path, + episode.file.modified_timestamp, + previous_file_modified, + can_extract_thumbnails, + ).await; + double_episodes.push(new_episode); + } + } + if !double_episodes.is_empty() { + series_episodes.append(&mut double_episodes); + series_episodes.sort_by_key(|episode| (episode.season, episode.episode)); } } - if !double_episodes.is_empty() { - series_episodes.append(&mut double_episodes); - series_episodes.sort_by_key(|episode| (episode.season, episode.episode)); - } - - series_metadata.number_of_episodes = u32::try_from(series_episodes.len()).unwrap_or(0); + self.enrich_series_episode_metadata_with_ffprobe(series_metadata, &existing_ep_technical, can_probe).await; + let episode_count = series_metadata.episodes.as_ref().map_or(0, Vec::len); + series_metadata.number_of_episodes = u32::try_from(episode_count).unwrap_or(0); if series_metadata.number_of_seasons == 0 { - series_metadata.number_of_seasons = unique_season_count(series_episodes); + let season_count = series_metadata.episodes.as_ref().map_or(0, |series_episodes| unique_season_count(series_episodes)); + series_metadata.number_of_seasons = season_count; } } @@ -585,6 +646,89 @@ impl LibraryProcessor { pub async fn get_all_entries(&self) -> Vec { self.storage.load_all().await } + + async fn is_local_ffprobe_enabled(&self) -> bool { + if let Some(app_cfg) = &self.app_config { + return app_cfg.is_ffprobe_enabled().await; + } + + let ffprobe_enabled_in_config = self + .metadata_update_config + .as_ref() + .is_some_and(|config| config.ffprobe.enabled); + if !ffprobe_enabled_in_config { + return false; + } + + (self.ffprobe_availability_checker)().await + } + + async fn enrich_movie_metadata_with_ffprobe(&self, metadata: &mut MediaMetadata, file_path: &str, can_probe: bool) { + if !can_probe { + return; + } + + let MediaMetadata::Movie(movie) = metadata else { + return; + }; + movie.technical = self.probe_local_file(file_path).await; + } + + async fn enrich_series_episode_metadata_with_ffprobe( + &self, + series: &mut SeriesMetadata, + existing_technical: &HashMap)>, + can_probe: bool, + ) { + let Some(episodes) = series.episodes.as_mut() else { + return; + }; + + for episode in episodes { + if episode.file_path.is_empty() { + continue; + } + + if let Some((previous_mtime, previous_technical)) = existing_technical.get(&episode.file_path) { + if *previous_mtime == episode.file_modified { + episode.technical.clone_from(previous_technical); + continue; + } + } + + if can_probe { + episode.technical = self.probe_local_file(&episode.file_path).await; + } + } + } + + async fn probe_local_file(&self, file_path: &str) -> Option { + let ffprobe = self.metadata_update_config.as_ref().map(|config| &config.ffprobe)?; + if !ffprobe.enabled { + return None; + } + + match FfmpegExecutor::new() + .probe_url( + file_path, + None, + ffprobe.analyze_duration_micros, + ffprobe.probe_size_bytes, + ffprobe.timeout.unwrap_or(60), + // Local file probing does not traverse the network, so no proxy config is applied. + None, + ) + .await + { + ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => Some(TechnicalMetadata { + video: raw_video.map(|value| value.to_string()), + audio: raw_audio.map(|value| value.to_string()), + duration_secs: stats.duration_secs, + bitrate: stats.bitrate, + }), + ProbeUrlOutcome::Failed(_) => None, + } + } } fn unique_season_count(episodes: &[EpisodeMetadata]) -> u32 { @@ -598,6 +742,8 @@ fn unique_season_count(episodes: &[EpisodeMetadata]) -> u32 { #[cfg(test)] mod tests { use super::*; + use crate::model::MetadataUpdateConfig; + use shared::model::{FfprobeConfigDto, LibraryConfigDto}; #[test] fn test_scan_result_creation() { @@ -625,4 +771,25 @@ mod tests { assert_eq!(unique_season_count(&episodes), 3); } + + #[tokio::test] + async fn local_ffprobe_enablement_falls_back_to_metadata_update_config_without_app_config() { + let mut processor = LibraryProcessor::new_with_ffprobe_availability_checker( + LibraryConfig::from(&LibraryConfigDto::default()), + Some(&MetadataUpdateConfig::default()), + reqwest::Client::new(), + "/tmp", + Arc::new(|| Box::pin(async { false })), + ); + processor.app_config = None; + + let mut metadata_update = MetadataUpdateConfig::default(); + metadata_update.ffprobe = crate::model::FfprobeConfig::from(&FfprobeConfigDto { + enabled: true, + ..FfprobeConfigDto::default() + }); + processor.metadata_update_config = Some(metadata_update); + + assert!(!processor.is_local_ffprobe_enabled().await); + } } diff --git a/backend/src/library/tmdb.rs b/backend/src/library/tmdb.rs index 3e211ab1e..a57f1d297 100644 --- a/backend/src/library/tmdb.rs +++ b/backend/src/library/tmdb.rs @@ -189,6 +189,7 @@ impl TmdbMovieDetails { .map(TmdbVideo::to_meta_data) .collect::>() }).filter(|v| !v.is_empty()), + technical: None, } } } @@ -235,6 +236,7 @@ impl TmdbSeriesInfoEpisodeDetails { file_path: String::new(), file_size: 0, file_modified: 0, + technical: None, } } } diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 38d5ddc6a..91a0ed4dc 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -10,7 +10,7 @@ use shared::model::{EpisodeStreamProperties, LiveStreamProperties, PlaylistGroup PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem}; -use shared::utils::{generate_playlist_uuid, trim_last_slash, Internable}; +use shared::utils::{generate_provider_playlist_uuid, trim_last_slash, Internable}; use std::collections::HashMap; use std::sync::Arc; use tokio::task::spawn_blocking; @@ -107,7 +107,7 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre PlaylistItem { header: PlaylistItemHeader { - uuid: generate_playlist_uuid(&input.name, &episode_id, PlaylistItemType::Series, &episode_url), + uuid: generate_provider_playlist_uuid(&input.name, &episode_id, PlaylistItemType::Series), id: episode_id.into(), // we use parent_code to track the parent series parent_code: parent_uuid.intern(), @@ -202,7 +202,7 @@ pub async fn parse_xtream(input: &ConfigInput, 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), + uuid: generate_provider_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type), name: Arc::clone(&stream.get_name()), logo: Arc::clone(&stream.get_stream_icon()), group: Arc::clone(category_name), @@ -374,7 +374,7 @@ where 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), + uuid: generate_provider_playlist_uuid(input_name, &stream.get_stream_id().to_string(), item_type), name: stream.get_name(), logo: stream.get_stream_icon(), group: category_name.clone(), diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 33c73491b..6a36c2c1b 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -1,11 +1,11 @@ -use crate::library::{EpisodeMetadata, MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; +use crate::library::{EpisodeMetadata, MediaMetadata, MetadataAsyncIter, MetadataCacheEntry, TechnicalMetadata}; use crate::library::resolve_metadata_storage_path; use crate::model::{AppConfig, ConfigInput}; use shared::concat_string; use shared::error::TuliproxError; use shared::model::UUIDType; use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, normalize_episode_title}; -use shared::utils::{concat_path_leading_slash, generate_playlist_uuid, Internable}; +use shared::utils::{concat_path_leading_slash, generate_local_playlist_uuid, Internable}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -23,6 +23,24 @@ fn episode_thumbnail_url(episode: &EpisodeMetadata, api_base_path: &str) -> Opti .map(|id| concat_path_leading_slash(api_base_path, &format!("library/thumbnail/{id}"))) } +fn duration_secs_to_xtream_duration(duration_secs: u32) -> Arc { + let hours = duration_secs / 3600; + let minutes = (duration_secs % 3600) / 60; + let seconds = duration_secs % 60; + format!("{hours:02}:{minutes:02}:{seconds:02}").into() +} + +fn technical_duration_secs(technical: Option<&TechnicalMetadata>) -> Option { + technical.and_then(|metadata| metadata.duration_secs).filter(|value| *value > 0) +} + +fn technical_bitrate(technical: Option<&TechnicalMetadata>) -> u32 { + technical + .and_then(|metadata| metadata.bitrate) + .filter(|value| *value > 0) + .unwrap_or_default() +} + pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> (Vec, Vec) { let config = &*app_config.config.load(); let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) }; @@ -215,7 +233,7 @@ fn build_series_episode_playlist_item( header: PlaylistItemHeader { id: episode.id.to_string().into(), parent_code: entry.uuid.clone().into(), - uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source), + uuid: generate_local_playlist_uuid(input_name, PlaylistItemType::LocalSeries, &episode.direct_source), logo: logo.clone(), name: episode.title.clone(), group: Arc::clone(group_name), @@ -262,6 +280,9 @@ pub fn metadata_cache_entry_to_xtream_movie_info( .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(", ").intern()); + let technical = movie.technical.as_ref(); + let duration_secs = technical_duration_secs(technical).or_else(|| movie.runtime.map(|runtime| runtime * 60)); + let duration = duration_secs.map(duration_secs_to_xtream_duration); let properties = VideoStreamProperties { name: movie.title.clone().into(), @@ -302,17 +323,12 @@ pub fn metadata_cache_entry_to_xtream_movie_info( .filter(|s| !s.is_empty()) .map(|f| vec![f.clone().into()]) .or_else(|| poster.map(|p| vec![p.into()])), - 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").into() - }), - - video: None, - audio: None, - bitrate: 0, - runtime: movie.runtime.map(|r| (r * 60).to_string().into()), + duration_secs: duration_secs.map(|value| value.to_string().into()), + duration: duration.clone(), + video: technical.and_then(|metadata| metadata.video.as_ref()).map(|value| value.as_str().into()), + audio: technical.and_then(|metadata| metadata.audio.as_ref()).map(|value| value.as_str().into()), + bitrate: technical_bitrate(technical), + runtime: duration_secs.map(|value| value.to_string().into()), status: Some("Released".intern()), }), }; @@ -333,18 +349,21 @@ pub fn metadata_cache_entry_to_xtream_series_info( 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 series_thumbnail = thumbnail_url(entry, api_base_path); + let series_art = series.poster.clone().or(series_thumbnail.clone()); let mut season_data = HashMap::new(); series.seasons.as_ref().iter().for_each(|seasons| seasons.iter().for_each(|season_metadata| { + let season_art = season_metadata.poster_path.clone().or_else(|| series_art.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().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, + cover: season_art.clone().map(Into::into), + cover_tmdb: season_art.clone().map(Into::into), + cover_big: season_art.map(Into::into), duration: Some(String::from("0").into()), }); })); @@ -360,17 +379,19 @@ pub fn metadata_cache_entry_to_xtream_series_info( let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id); let raw_episode_title: Arc = episode.title.clone().into(); let series_title: Arc = series.title.clone().into(); + let technical = episode.technical.as_ref(); + let duration_secs = technical_duration_secs(technical).or_else(|| episode.runtime.map(|runtime| runtime * 60)); let season_entry = season_data.entry(episode.season).or_insert_with(|| { SeriesStreamDetailSeasonProperties { name: concat_string!(&series.title, " ", &episode.season.to_string()).into(), season_number: episode.season, episode_count: 0, - overview: series.poster.clone().map(Into::into), + overview: None, air_date: episode.aired.clone().map(Into::into), - cover: series.poster.clone().map(Into::into), - cover_tmdb: None, - cover_big: None, + cover: series_art.clone().map(Into::into), + cover_tmdb: series_art.clone().map(Into::into), + cover_big: series_art.clone().map(Into::into), duration: None, } }); @@ -390,19 +411,18 @@ pub fn metadata_cache_entry_to_xtream_series_info( series_release_date: None, 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().into(), + duration_secs: duration_secs.unwrap_or_default(), + duration: duration_secs.map(duration_secs_to_xtream_duration).unwrap_or_default(), movie_image: episode .thumb .clone() .or_else(|| episode_thumbnail_url(episode, api_base_path)) + .or_else(|| series_art.clone()) .unwrap_or_default() .into(), - audio: None, - video: None, - bitrate: 0, + audio: technical.and_then(|metadata| metadata.audio.as_ref()).map(|value| value.as_str().into()), + video: technical.and_then(|metadata| metadata.video.as_ref()).map(|value| value.as_str().into()), + bitrate: technical_bitrate(technical), rating: None, } }).collect::>() @@ -422,12 +442,12 @@ pub fn metadata_cache_entry_to_xtream_series_info( .filter(|s| !s.is_empty()) .map(|f| vec![f.clone().into()]) .or_else(|| { - series.poster.as_ref() + series_art.as_ref() .filter(|s| !s.is_empty()) .map(|p| vec![p.clone().into()]) }), cast: Arc::clone(&actor_names), - cover: series.poster.clone().unwrap_or_default().into(), + cover: series_art.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(", ").into()), @@ -451,7 +471,7 @@ pub fn metadata_cache_entry_to_xtream_series_info( #[cfg(test)] mod tests { use super::{metadata_cache_entry_to_xtream_series_info, thumbnail_url}; - use crate::library::{MediaMetadata, MetadataCacheEntry, MovieMetadata, SeriesMetadata, EpisodeMetadata}; + use crate::library::{EpisodeMetadata, MediaMetadata, MetadataCacheEntry, MovieMetadata, SeriesMetadata, TechnicalMetadata}; use shared::model::StreamProperties; #[test] @@ -502,4 +522,139 @@ mod tests { "/api/v1/library/thumbnail/31fedbc18dca3fa273fba98afda584486ad4f1d8e1ca06740435b97b14f2ec8b", ); } + + #[test] + fn series_info_uses_local_thumbnail_when_series_poster_missing() { + let mut entry = MetadataCacheEntry::new( + "/tmp/show/S01E01.mkv".to_string(), + 123, + 456, + MediaMetadata::Series(SeriesMetadata { + title: "Test Series".to_string(), + episodes: Some(vec![EpisodeMetadata { + title: "Episode 1".to_string(), + season: 1, + episode: 1, + file_path: "/tmp/show/S01E01.mkv".to_string(), + ..EpisodeMetadata::default() + }]), + ..SeriesMetadata::default() + }), + ); + entry.uuid = "series-thumb-uuid".to_string(); + entry.thumbnail_hash = Some("thumb-hash".to_string()); + + let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1") else { + panic!("expected series stream properties"); + }; + + assert_eq!(series.cover.as_ref(), "/api/v1/library/thumbnail/series-thumb-uuid"); + + let seasons = series.details.as_ref().and_then(|details| details.seasons.as_ref()).expect("seasons missing"); + let season = seasons.first().expect("season missing"); + assert_eq!(season.cover.as_deref().map(|v| v.as_ref()), Some("/api/v1/library/thumbnail/series-thumb-uuid")); + assert_eq!(season.cover_tmdb.as_deref().map(|v| v.as_ref()), Some("/api/v1/library/thumbnail/series-thumb-uuid")); + assert_eq!(season.cover_big.as_deref().map(|v| v.as_ref()), Some("/api/v1/library/thumbnail/series-thumb-uuid")); + assert!(season.overview.is_none()); + } + + #[test] + fn series_episode_falls_back_to_series_thumbnail_when_episode_thumb_missing() { + let mut entry = MetadataCacheEntry::new( + "/tmp/show/S01E01.mkv".to_string(), + 123, + 456, + MediaMetadata::Series(SeriesMetadata { + title: "Test Series".to_string(), + episodes: Some(vec![EpisodeMetadata { + title: "Episode 1".to_string(), + season: 1, + episode: 1, + file_path: "/tmp/show/S01E01.mkv".to_string(), + ..EpisodeMetadata::default() + }]), + ..SeriesMetadata::default() + }), + ); + entry.uuid = "series-thumb-uuid".to_string(); + entry.thumbnail_hash = Some("thumb-hash".to_string()); + + let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1") else { + panic!("expected series stream properties"); + }; + let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("episodes missing"); + let episode = episodes.first().expect("episode missing"); + + assert_eq!(episode.movie_image.as_ref(), "/api/v1/library/thumbnail/series-thumb-uuid"); + } + + #[test] + fn movie_info_prefers_local_technical_metadata_for_probe_fields() { + let entry = MetadataCacheEntry::new( + "/tmp/movie/Test.Movie.2024.mkv".to_string(), + 123, + 456, + MediaMetadata::Movie(MovieMetadata { + title: "Test Movie".to_string(), + runtime: Some(90), + technical: Some(TechnicalMetadata { + video: Some("{\"codec_name\":\"hevc\"}".to_string()), + audio: Some("{\"codec_name\":\"aac\"}".to_string()), + duration_secs: Some(5_430), + bitrate: Some(8_200_000), + }), + ..MovieMetadata::default() + }), + ); + + let Some(StreamProperties::Video(video)) = super::metadata_cache_entry_to_xtream_movie_info(&entry, "/api/v1") else { + panic!("expected video stream properties"); + }; + let details = video.details.as_ref().expect("details missing"); + + assert_eq!(details.video.as_deref().map(|value| value.as_ref()), Some("{\"codec_name\":\"hevc\"}")); + assert_eq!(details.audio.as_deref().map(|value| value.as_ref()), Some("{\"codec_name\":\"aac\"}")); + assert_eq!(details.duration_secs.as_deref().map(|value| value.as_ref()), Some("5430")); + assert_eq!(details.duration.as_deref().map(|value| value.as_ref()), Some("01:30:30")); + assert_eq!(details.runtime.as_deref().map(|value| value.as_ref()), Some("5430")); + assert_eq!(details.bitrate, 8_200_000); + } + + #[test] + fn series_episode_prefers_local_technical_metadata_for_probe_fields() { + let entry = MetadataCacheEntry::new( + "/tmp/show/S01E01.mkv".to_string(), + 123, + 456, + MediaMetadata::Series(SeriesMetadata { + title: "Test Series".to_string(), + episodes: Some(vec![EpisodeMetadata { + title: "Episode 1".to_string(), + season: 1, + episode: 1, + file_path: "/tmp/show/S01E01.mkv".to_string(), + technical: Some(TechnicalMetadata { + video: Some("{\"codec_name\":\"h264\"}".to_string()), + audio: Some("{\"codec_name\":\"ac3\"}".to_string()), + duration_secs: Some(1_541), + bitrate: Some(3_100_000), + }), + ..EpisodeMetadata::default() + }]), + ..SeriesMetadata::default() + }), + ); + + let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1") else { + panic!("expected series stream properties"); + }; + let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("episodes missing"); + let episode = episodes.first().expect("episode missing"); + + assert_eq!(episode.video.as_deref().map(|value| value.as_ref()), Some("{\"codec_name\":\"h264\"}")); + assert_eq!(episode.audio.as_deref().map(|value| value.as_ref()), Some("{\"codec_name\":\"ac3\"}")); + assert_eq!(episode.duration_secs, 1_541); + assert_eq!(episode.duration.as_ref(), "00:25:41"); + assert_eq!(episode.bitrate, 3_100_000); + } } diff --git a/backend/src/processing/processor/stream_probe.rs b/backend/src/processing/processor/stream_probe.rs index 5df357968..f4b6c7e44 100644 --- a/backend/src/processing/processor/stream_probe.rs +++ b/backend/src/processing/processor/stream_probe.rs @@ -4,7 +4,7 @@ use crate::model::{AppConfig}; use crate::processing::processor::{select_cancel_token, ProbeHandleGuard}; use crate::repository::{get_input_m3u_playlist_file_path, get_input_storage_path, get_input_local_library_playlist_file_path, xtream_get_file_path, BPlusTreeUpdate}; use crate::utils::debug_if_enabled; -use crate::utils::ffmpeg::{FfmpegExecutor, ProbeFailureKind, ProbeUrlOutcome}; +use crate::utils::ffmpeg::{FfmpegExecutor, ProbeFailureKind, ProbeStreamStats, ProbeUrlOutcome}; use log::{info, warn}; use shared::error::TuliproxError; use shared::model::{EpisodeStreamProperties, InputType, PlaylistItemType, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, LiveStreamProperties, M3uPlaylistItem, XtreamCluster, XtreamPlaylistItem}; @@ -139,8 +139,8 @@ pub async fn update_generic_stream_metadata( handle.release().await; } - let (raw_video, raw_audio) = match probe_data { - ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => (raw_video, raw_audio), + let (raw_video, raw_audio, stats) = match probe_data { + ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => (raw_video, raw_audio, stats), ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => { warn!("Probe target not found (404) for generic stream: {unique_id}"); return Err(shared::error::info_err!("Probe target returned 404 Not Found for stream {unique_id}")); @@ -175,6 +175,7 @@ pub async fn update_generic_stream_metadata( item.virtual_id, raw_video, raw_audio, + stats, ); tree_update .update(&key, item) @@ -198,6 +199,7 @@ pub async fn update_generic_stream_metadata( item.virtual_id, raw_video, raw_audio, + stats, ); tree_update .update(&uuid, item) @@ -228,6 +230,7 @@ pub async fn update_generic_stream_metadata( item.virtual_id, raw_video, raw_audio, + stats, ); tree_update .update(&provider_id, item) @@ -260,7 +263,8 @@ fn update_properties( name: &str, virtual_id: u32, raw_video: Option, - raw_audio: Option + raw_audio: Option, + stats: ProbeStreamStats, ) { if matches!(item_type, PlaylistItemType::Video | PlaylistItemType::LocalVideo) { let mut props = if let Some(StreamProperties::Video(p)) = props_opt { @@ -284,6 +288,12 @@ fn update_properties( if let Some(a) = raw_audio { details.audio = Some(a.to_string().into()); } + if let Some(duration_secs) = stats.duration_secs { + details.duration_secs = Some(duration_secs.to_string().into()); + } + if let Some(bitrate) = stats.bitrate { + details.bitrate = bitrate; + } } *props_opt = Some(StreamProperties::Video(Box::new(props))); } @@ -343,6 +353,7 @@ fn update_properties( #[cfg(test)] mod tests { use super::*; + use crate::utils::ffmpeg::ProbeStreamStats; #[test] fn library_probe_does_not_require_provider_connection() { @@ -366,4 +377,29 @@ mod tests { InputType::XtreamBatch )); } + + #[test] + fn update_properties_applies_probe_stats_to_video_details() { + let mut props_opt = None; + + update_properties( + &mut props_opt, + PlaylistItemType::Video, + "Example", + 77, + None, + None, + ProbeStreamStats { + duration_secs: Some(1_541), + bitrate: Some(3_100_000), + }, + ); + + let Some(StreamProperties::Video(video)) = props_opt else { + panic!("expected video properties"); + }; + let details = video.details.as_ref().unwrap_or_else(|| unreachable!()); + assert_eq!(details.duration_secs.as_deref().map(|value| value.as_ref()), Some("1541")); + assert_eq!(details.bitrate, 3_100_000); + } } diff --git a/backend/src/processing/processor/xtream.rs b/backend/src/processing/processor/xtream.rs index 08f7e409f..f21cf871d 100644 --- a/backend/src/processing/processor/xtream.rs +++ b/backend/src/processing/processor/xtream.rs @@ -143,7 +143,7 @@ pub async fn update_live_stream_metadata( ) .await { - ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => { + ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, _stats) => { // 3. Update properties on success if let Some(v) = raw_video { properties.video = Some(v.to_string().into()); diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index abb3aac6c..620d6dcec 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -935,7 +935,7 @@ pub async fn update_series_metadata( ) .await { - ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => { + ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, _stats) => { if let Some(v) = raw_video { ep.video = Some(v.to_string().into()); properties_updated = true; diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index 17026c7ae..da0747d52 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -852,7 +852,7 @@ pub async fn update_vod_metadata( ) .await { - ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => { + ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => { if let Some(details) = properties.details.as_mut() { if let Some(v) = raw_video { details.video = Some(v.to_string().into()); @@ -862,6 +862,14 @@ pub async fn update_vod_metadata( details.audio = Some(a.to_string().into()); properties_updated = true; } + if let Some(duration_secs) = stats.duration_secs { + details.duration_secs = Some(duration_secs.to_string().into()); + properties_updated = true; + } + if let Some(bitrate) = stats.bitrate { + details.bitrate = bitrate; + properties_updated = true; + } } } ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => { diff --git a/backend/src/repository/library_repository.rs b/backend/src/repository/library_repository.rs index 81adfa460..f779ab093 100644 --- a/backend/src/repository/library_repository.rs +++ b/backend/src/repository/library_repository.rs @@ -87,6 +87,12 @@ fn preserve_local_probe_state_if_unchanged(new_item: &mut XtreamPlaylistItem, ol if new_details.audio.is_none() { new_details.audio.clone_from(&old_details.audio); } + if new_details.duration_secs.is_none() { + new_details.duration_secs.clone_from(&old_details.duration_secs); + } + if new_details.bitrate == 0 { + new_details.bitrate = old_details.bitrate; + } } } (Some(StreamProperties::Episode(new_props)), Some(StreamProperties::Episode(old_props))) => { @@ -149,7 +155,14 @@ mod tests { use shared::utils::Internable; use shared::model::{EpisodeStreamProperties, VideoStreamDetailProperties, VideoStreamProperties}; - fn video_item(url: &str, added: &str, video: Option<&str>, audio: Option<&str>) -> XtreamPlaylistItem { + fn video_item( + url: &str, + added: &str, + video: Option<&str>, + audio: Option<&str>, + duration_secs: Option<&str>, + bitrate: u32, + ) -> XtreamPlaylistItem { XtreamPlaylistItem { virtual_id: 1, provider_id: 1, @@ -168,6 +181,8 @@ mod tests { details: Some(VideoStreamDetailProperties { video: video.map(Internable::intern), audio: audio.map(Internable::intern), + duration_secs: duration_secs.map(Internable::intern), + bitrate, ..Default::default() }), ..Default::default() @@ -180,7 +195,12 @@ mod tests { } } - fn episode_item(url: &str, added: Option<&str>, video: Option<&str>, audio: Option<&str>) -> XtreamPlaylistItem { + fn episode_item( + url: &str, + added: Option<&str>, + video: Option<&str>, + audio: Option<&str>, + ) -> XtreamPlaylistItem { XtreamPlaylistItem { virtual_id: 2, provider_id: 2, @@ -217,12 +237,14 @@ mod tests { #[test] fn keeps_movie_probe_when_file_unchanged() { - let mut new_item = video_item("file:///media/a.mkv", "100", None, None); + let mut new_item = video_item("file:///media/a.mkv", "100", None, None, None, 0); let old_item = video_item( "file:///media/a.mkv", "100", Some("{\"codec_name\":\"h264\"}"), Some("{\"codec_name\":\"aac\"}"), + Some("120"), + 1500, ); preserve_local_probe_state_if_unchanged(&mut new_item, &old_item); @@ -232,6 +254,8 @@ mod tests { let details = v.details.expect("details expected"); assert_eq!(details.video, Some("{\"codec_name\":\"h264\"}".intern())); assert_eq!(details.audio, Some("{\"codec_name\":\"aac\"}".intern())); + assert_eq!(details.duration_secs, Some("120".intern())); + assert_eq!(details.bitrate, 1500); } _ => panic!("expected video properties"), } @@ -239,12 +263,14 @@ mod tests { #[test] fn does_not_keep_movie_probe_when_mtime_changed() { - let mut new_item = video_item("file:///media/a.mkv", "200", None, None); + let mut new_item = video_item("file:///media/a.mkv", "200", None, None, None, 0); let old_item = video_item( "file:///media/a.mkv", "100", Some("{\"codec_name\":\"h264\"}"), Some("{\"codec_name\":\"aac\"}"), + Some("120"), + 1500, ); preserve_local_probe_state_if_unchanged(&mut new_item, &old_item); @@ -254,6 +280,8 @@ mod tests { let details = v.details.expect("details expected"); assert!(details.video.is_none()); assert!(details.audio.is_none()); + assert!(details.duration_secs.is_none()); + assert_eq!(details.bitrate, 0); } _ => panic!("expected video properties"), } diff --git a/backend/src/utils/ffmpeg.rs b/backend/src/utils/ffmpeg.rs index f3ee92f72..fddf266d5 100644 --- a/backend/src/utils/ffmpeg.rs +++ b/backend/src/utils/ffmpeg.rs @@ -18,10 +18,16 @@ pub enum ProbeFailureKind { } pub enum ProbeUrlOutcome { - Success(MediaQuality, Option, Option), + Success(MediaQuality, Option, Option, ProbeStreamStats), Failed(ProbeFailureKind), } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ProbeStreamStats { + pub duration_secs: Option, + pub bitrate: Option, +} + #[derive(Debug, Clone, Copy, Default)] pub struct FfmpegExecutor; @@ -96,6 +102,7 @@ impl FfmpegExecutor { command .arg("-v").arg("error") .arg("-show_streams") + .arg("-show_format") .arg("-of").arg("json") .arg("-analyzeduration").arg(analyze_duration.to_string()) .arg("-probesize").arg(probe_size.to_string()); @@ -153,10 +160,12 @@ impl FfmpegExecutor { let audio_str = audio_stream.map(Value::to_string); let mq = MediaQuality::from_ffprobe_info(audio_str.as_deref(), video_str.as_deref()); if let Some(quality) = mq { + let stats = extract_probe_stream_stats(&json, video_stream, audio_stream); return ProbeUrlOutcome::Success( quality, video_stream.cloned(), audio_stream.cloned(), + stats, ); } } @@ -301,6 +310,54 @@ fn is_not_found_probe_error(stderr: &str) -> bool { .any(|needle| normalized.contains(needle)) } +fn extract_probe_stream_stats(json: &Value, video_stream: Option<&Value>, audio_stream: Option<&Value>) -> ProbeStreamStats { + let format = json.get("format"); + ProbeStreamStats { + duration_secs: format + .and_then(|value| parse_duration_secs(value.get("duration"))) + .or_else(|| parse_duration_secs(video_stream.and_then(|value| value.get("duration")))) + .or_else(|| parse_duration_secs(audio_stream.and_then(|value| value.get("duration")))), + bitrate: format + .and_then(|value| parse_u32_field(value.get("bit_rate"))) + .or_else(|| parse_u32_field(video_stream.and_then(|value| value.get("bit_rate")))) + .or_else(|| parse_u32_field(audio_stream.and_then(|value| value.get("bit_rate")))), + } +} + +fn parse_duration_secs(value: Option<&Value>) -> Option { + let seconds = value.and_then(|value| match value { + Value::Number(number) => number.as_f64(), + Value::String(text) => text.parse::().ok(), + _ => None, + })?; + + if !seconds.is_finite() || seconds <= 0.0 { + return None; + } + + let rounded = seconds.round(); + if !(1.0..=f64::from(u32::MAX)).contains(&rounded) { + return None; + } + + Some(rounded_duration_secs_to_u32(rounded)) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn rounded_duration_secs_to_u32(rounded_seconds: f64) -> u32 { + rounded_seconds as u32 +} + +fn parse_u32_field(value: Option<&Value>) -> Option { + let raw = value.and_then(|value| match value { + Value::Number(number) => number.as_u64(), + Value::String(text) => text.parse::().ok(), + _ => None, + })?; + + u32::try_from(raw).ok().filter(|parsed| *parsed > 0) +} + fn build_thumbnail_scale_filter(width: u32, height: u32) -> String { let w = if width < 1 { default_thumbnail_width() } else { width }; let h = if height < 1 { default_thumbnail_height() } else { height }; @@ -328,8 +385,12 @@ fn build_thumbnail_args(input_path: &str, output_path: &Path, scale_filter: &str #[cfg(test)] mod tests { - use super::{build_ffprobe_proxy_url, build_thumbnail_args, build_thumbnail_scale_filter, format_ffmpeg_timeout_error, FFMPEG_TIMEOUT}; + use super::{ + build_ffprobe_proxy_url, build_thumbnail_args, build_thumbnail_scale_filter, extract_probe_stream_stats, + format_ffmpeg_timeout_error, FFMPEG_TIMEOUT, ProbeStreamStats, + }; use crate::model::ProxyConfig; + use serde_json::json; use shared::utils::{default_thumbnail_height, default_thumbnail_width}; use std::path::Path; @@ -410,4 +471,40 @@ mod tests { assert!(!super::is_not_found_probe_error("file not found")); assert!(!super::is_not_found_probe_error("protocol handler not found")); } + + #[test] + fn extract_probe_stream_stats_prefers_format_section() { + let payload = json!({ + "format": { + "duration": "1541.4", + "bit_rate": "3100000" + }, + "streams": [] + }); + + assert_eq!( + extract_probe_stream_stats(&payload, None, None), + ProbeStreamStats { + duration_secs: Some(1541), + bitrate: Some(3_100_000), + } + ); + } + + #[test] + fn extract_probe_stream_stats_falls_back_to_stream_values() { + let payload = json!({}); + let video = json!({ + "duration": "120.0", + "bit_rate": "1500" + }); + + assert_eq!( + extract_probe_stream_stats(&payload, Some(&video), None), + ProbeStreamStats { + duration_secs: Some(120), + bitrate: Some(1500), + } + ); + } } diff --git a/frontend/scss/app/_app.scss b/frontend/scss/app/_app.scss index 18c6cbcfe..1308c6f63 100644 --- a/frontend/scss/app/_app.scss +++ b/frontend/scss/app/_app.scss @@ -5,6 +5,7 @@ flex-flow: row; overflow: hidden; flex: 1 1 auto; + min-height: 0; box-sizing: border-box; max-width: 100%; color: var(--text-color); @@ -14,6 +15,7 @@ display: flex; flex-flow: column; flex: 1 1 auto; + min-height: 0; align-content: flex-start; justify-content: flex-start; overflow: hidden; @@ -21,15 +23,27 @@ &__body { display: flex; flex-flow: column; + flex: 1 1 auto; + min-height: 0; box-sizing: border-box; padding: 0 var(--padding-larger); align-items: center; overflow: hidden; - height: 100vh; @media (max-width: size.$mobile-breakpoint) { padding: 0 var(--padding-micro); } } + &__body > .tp__panel { + display: flex; + flex-flow: column; + flex: 1 1 auto; + min-height: 0; + align-self: stretch; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } + } -} \ No newline at end of file +} diff --git a/frontend/scss/app/components/dashboard/_streams_view.scss b/frontend/scss/app/components/dashboard/_streams_view.scss index 89b3c9971..a643dad8d 100644 --- a/frontend/scss/app/components/dashboard/_streams_view.scss +++ b/frontend/scss/app/components/dashboard/_streams_view.scss @@ -4,6 +4,7 @@ display: flex; flex-flow: column; flex: 1 1 auto; + min-height: 0; box-sizing: border-box; width: 100%; overflow: hidden; @@ -16,6 +17,8 @@ &__body { display: flex; flex-flow: column; + flex: 1 1 auto; + min-height: 0; gap: var(--gap-larger); overflow: hidden; } @@ -81,6 +84,7 @@ &__body { display: flex; flex: 1 1 auto; + min-height: 0; overflow: hidden; padding: var(--padding-default); } @@ -91,7 +95,9 @@ flex-flow: column; gap: var(--gap-small); min-height: 0; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; padding-right: var(--padding-small); } diff --git a/frontend/scss/themes/_theme-dark.scss b/frontend/scss/themes/_theme-dark.scss index 75d63975c..a4cac059d 100644 --- a/frontend/scss/themes/_theme-dark.scss +++ b/frontend/scss/themes/_theme-dark.scss @@ -241,13 +241,13 @@ body[data-theme="dark"] { --theme-palette-chip-audio-surface: #432c36; --theme-palette-chip-audio-channels-text: #ea97b5; - --theme-palette-graph-input-xtream: #1565c0; - --theme-palette-graph-input-m3u: #409aff; - --theme-palette-graph-input-library: #64b5f6; + --theme-palette-graph-input-xtream: #0061cf; + --theme-palette-graph-input-m3u: #5c88ff; + --theme-palette-graph-input-library: #c341db; --theme-palette-graph-target: #2aa198; --theme-palette-graph-output: #f39c12; - --theme-palette-graph-output-xtream: #0099ff; - --theme-palette-graph-output-m3u: #8bc34a; + --theme-palette-graph-output-xtream: #0061cf; + --theme-palette-graph-output-m3u: #5c88ff; --theme-palette-graph-output-hdhomerun: #8b5cf6; --theme-palette-graph-output-strm: #e74c3c; --theme-palette-graph-port: #587293; diff --git a/frontend/scss/themes/_theme-refined-dark.scss b/frontend/scss/themes/_theme-refined-dark.scss index 198cffc8d..4022d0d04 100644 --- a/frontend/scss/themes/_theme-refined-dark.scss +++ b/frontend/scss/themes/_theme-refined-dark.scss @@ -241,18 +241,18 @@ body[data-theme="refined-dark"] { --theme-palette-chip-audio-surface: #3c284a; --theme-palette-chip-audio-channels-text: #f3e8ff; - --theme-palette-graph-input-xtream: #2563eb; - --theme-palette-graph-input-m3u: #60a5fa; - --theme-palette-graph-input-library: #93c5fd; - --theme-palette-graph-target: #14b8a6; - --theme-palette-graph-output: #f59e0b; - --theme-palette-graph-output-xtream: #38bdf8; - --theme-palette-graph-output-m3u: #34d399; - --theme-palette-graph-output-hdhomerun: #a855f7; - --theme-palette-graph-output-strm: #f43f5e; - --theme-palette-graph-port: #6b86aa; - --theme-palette-graph-port-border: #edf4ff; - --theme-palette-graph-port-hover: #fbbf24; + --theme-palette-graph-input-xtream: #0061cf; + --theme-palette-graph-input-m3u: #5c88ff; + --theme-palette-graph-input-library: #c341db; + --theme-palette-graph-target: #2aa198; + --theme-palette-graph-output: #f39c12; + --theme-palette-graph-output-xtream: #0061cf; + --theme-palette-graph-output-m3u: #5c88ff; + --theme-palette-graph-output-hdhomerun: #8b5cf6; + --theme-palette-graph-output-strm: #e74c3c; + --theme-palette-graph-port: #587293; + --theme-palette-graph-port-border: #ffffff; + --theme-palette-graph-port-hover: #f59e0b; --theme-palette-login-logo: color-mix(in srgb, #08111f 78%, #7db8ff 10%); --theme-palette-series-title: #edf4ff; diff --git a/frontend/src/app/components/dashboard/stream_display/mod.rs b/frontend/src/app/components/dashboard/stream_display/mod.rs index 8cb6a2b23..601ba8da4 100644 --- a/frontend/src/app/components/dashboard/stream_display/mod.rs +++ b/frontend/src/app/components/dashboard/stream_display/mod.rs @@ -301,10 +301,10 @@ pub fn StreamDisplay(props: &StreamDisplayProps) -> Html { <>
{ for streams.iter().cloned().map(|stream| { - let uid = stream.uid; + let key = format!("{}-{}", stream.addr, stream.uid); html! { , label: String) -> Html { html! { diff --git a/frontend/src/hooks/use_server_status.rs b/frontend/src/hooks/use_server_status.rs index df159352f..2eded12b3 100644 --- a/frontend/src/hooks/use_server_status.rs +++ b/frontend/src/hooks/use_server_status.rs @@ -1,13 +1,25 @@ use crate::{hooks::use_service_context, model::EventMessage}; use gloo_timers::callback::Interval; use shared::model::{ActiveUserConnectionChange, StatusCheck, StreamInfo, SystemInfo}; -use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; +use std::{ + cell::RefCell, + collections::{BTreeMap, HashSet}, + net::SocketAddr, + rc::Rc, +}; use yew::{platform::spawn_local, prelude::*}; type ServerStatusState = (UseStateHandle>>>, UseStateHandle>>>); +fn stream_identity_key(stream: &StreamInfo) -> (SocketAddr, u32) { (stream.addr, stream.uid) } + fn find_stream_update_index(streams: &[StreamInfo], updated_stream: &StreamInfo) -> Option { + let updated_key = stream_identity_key(updated_stream); + if let Some(index) = streams.iter().position(|stream| stream_identity_key(stream) == updated_key) { + return Some(index); + } + if updated_stream.channel.item_type.is_live_adaptive() { if let Some(session_token) = updated_stream.session_token.as_deref() { if let Some(index) = @@ -21,6 +33,11 @@ fn find_stream_update_index(streams: &[StreamInfo], updated_stream: &StreamInfo) streams.iter().position(|stream| stream.addr == updated_stream.addr) } +fn dedupe_streams_by_identity(streams: &mut Vec) { + let mut seen = HashSet::new(); + streams.retain(|stream| seen.insert(stream_identity_key(stream))); +} + #[hook] pub fn use_server_status( status: UseStateHandle>>, @@ -45,6 +62,9 @@ pub fn use_server_status( if *enabled { subid = Some(services_ctx.event.subscribe(move |msg| match msg { EventMessage::ServerStatus(server_status) => { + let mut server_status = (*server_status).clone(); + dedupe_streams_by_identity(&mut server_status.active_user_streams); + let server_status = Rc::new(server_status); *status_holder_signal.borrow_mut() = Some(Rc::clone(&server_status)); status_signal.set(Some(server_status)); } @@ -66,6 +86,7 @@ pub fn use_server_status( } else { server_status.active_user_streams.push(stream_info); } + dedupe_streams_by_identity(&mut server_status.active_user_streams); } ActiveUserConnectionChange::Disconnected(addr) => { server_status.active_user_streams.retain(|stream_info| stream_info.addr != addr); @@ -137,16 +158,16 @@ pub fn use_server_status( #[cfg(test)] mod tests { - use super::find_stream_update_index; + use super::{dedupe_streams_by_identity, find_stream_update_index}; use shared::{ model::{PlaylistItemType, StreamChannel, StreamInfo, XtreamCluster}, utils::Internable, }; use std::net::SocketAddr; - fn test_stream(addr: &str, session_token: Option<&str>, item_type: PlaylistItemType) -> StreamInfo { + fn test_stream(uid: u32, addr: &str, session_token: Option<&str>, item_type: PlaylistItemType) -> StreamInfo { StreamInfo { - uid: 1, + uid, meter_uid: 2, username: "user".to_string(), channel: StreamChannel { @@ -174,9 +195,32 @@ mod tests { #[test] fn test_find_stream_update_index_prefers_adaptive_session_token_over_addr() { - let existing = test_stream("127.0.0.1:1234", Some("tok-hls"), PlaylistItemType::LiveHls); - let updated = test_stream("127.0.0.1:5678", Some("tok-hls"), PlaylistItemType::LiveDash); + let existing = test_stream(1, "127.0.0.1:1234", Some("tok-hls"), PlaylistItemType::LiveHls); + let updated = test_stream(1, "127.0.0.1:5678", Some("tok-hls"), PlaylistItemType::LiveDash); assert_eq!(find_stream_update_index(&[existing], &updated), Some(0)); } + + #[test] + fn test_find_stream_update_index_matches_exact_render_key_first() { + let existing = test_stream(2, "127.0.0.1:1234", Some("tok-a"), PlaylistItemType::LiveHls); + let updated = test_stream(2, "127.0.0.1:1234", Some("tok-b"), PlaylistItemType::LiveDash); + + assert_eq!(find_stream_update_index(&[existing], &updated), Some(0)); + } + + #[test] + fn test_dedupe_streams_by_identity_removes_duplicate_render_keys() { + let mut streams = vec![ + test_stream(2, "127.0.0.1:1234", Some("tok-a"), PlaylistItemType::LiveHls), + test_stream(2, "127.0.0.1:1234", Some("tok-b"), PlaylistItemType::LiveDash), + test_stream(2, "127.0.0.1:5678", Some("tok-c"), PlaylistItemType::LiveDash), + ]; + + dedupe_streams_by_identity(&mut streams); + + assert_eq!(streams.len(), 2); + assert_eq!(streams[0].addr, "127.0.0.1:1234".parse::().unwrap_or_else(|_| unreachable!())); + assert_eq!(streams[1].addr, "127.0.0.1:5678".parse::().unwrap_or_else(|_| unreachable!())); + } } diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index a998736df..64a2804e9 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -5,7 +5,7 @@ use crate::{ SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamProperties, XtreamInfoDocument, }, utils::{ - arc_str_option_serde, arc_str_serde, concat_path, extract_extension_from_url, generate_playlist_uuid, + arc_str_option_serde, arc_str_serde, concat_path, extract_extension_from_url, generate_runtime_playlist_uuid, get_provider_id, obfuscate_text, Internable, }, }; @@ -307,7 +307,7 @@ impl Default for PlaylistItemHeader { impl PlaylistItemHeader { #[inline] pub fn gen_uuid(&mut self) { - self.uuid = generate_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url); + self.uuid = generate_runtime_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url); } #[inline] @@ -553,7 +553,7 @@ impl PlaylistEntry for M3uPlaylistItem { fn get_provider_url(&self) -> Arc { Arc::clone(&self.url) } fn get_uuid(&self) -> UUIDType { - generate_playlist_uuid(&self.input_name, &self.provider_id, self.item_type, &self.url) + generate_runtime_playlist_uuid(&self.input_name, &self.provider_id, self.item_type, &self.url) } #[inline] @@ -678,6 +678,10 @@ impl XtreamMappingOptions { resource_url: &str, resource_field: &str, ) -> String { + if !self.web_ui_request && Self::is_trusted_web_ui_resource_path(resource_url) { + return concat_path(&self.base_url, resource_url); + } + if self.web_ui_request { if resource_url.is_empty() { return resource_url.to_string(); @@ -707,6 +711,10 @@ impl XtreamMappingOptions { resource_field: &str, index: usize, ) -> String { + if !self.web_ui_request && Self::is_trusted_web_ui_resource_path(resource_url) { + return concat_path(&self.base_url, resource_url); + } + if self.web_ui_request { if resource_url.is_empty() { return resource_url.to_string(); @@ -780,6 +788,45 @@ mod tests { ); } + #[test] + fn get_resource_url_absolutizes_internal_thumbnail_paths_for_xtream_clients() { + let mut options = sample_options(); + options.web_ui_request = false; + options.base_url = "http://proxy.example/base".to_string(); + options.reverse_item_types = PlaylistItemTypeSet::empty(); + + assert_eq!( + options.get_resource_url( + XtreamCluster::Series, + PlaylistItemType::LocalSeries, + 1, + "/api/v1/library/thumbnail/abc", + "logo", + ), + "http://proxy.example/base/api/v1/library/thumbnail/abc", + ); + } + + #[test] + fn get_bd_path_resource_url_absolutizes_internal_thumbnail_paths_for_xtream_clients() { + let mut options = sample_options(); + options.web_ui_request = false; + options.base_url = "http://proxy.example/base".to_string(); + options.reverse_item_types = PlaylistItemTypeSet::empty(); + + assert_eq!( + options.get_bd_path_resource_url( + XtreamCluster::Series, + PlaylistItemType::LocalSeries, + 1, + "/api/v1/library/thumbnail/backdrop", + "backdrop_", + 0, + ), + "http://proxy.example/base/api/v1/library/thumbnail/backdrop", + ); + } + #[test] fn get_resource_url_obfuscates_protocol_relative_urls_for_web_ui_requests() { let options = sample_options(); @@ -900,7 +947,7 @@ impl PlaylistEntry for XtreamPlaylistItem { #[inline] fn get_uuid(&self) -> UUIDType { - generate_playlist_uuid(&self.input_name, &self.provider_id.to_string(), self.item_type, &self.url) + generate_runtime_playlist_uuid(&self.input_name, &self.provider_id.to_string(), self.item_type, &self.url) } #[inline] fn get_item_type(&self) -> PlaylistItemType { self.item_type } @@ -1290,7 +1337,7 @@ impl PlaylistEntry for PlaylistItem { #[inline] fn get_uuid(&self) -> UUIDType { let header = &self.header; - generate_playlist_uuid(&header.input_name, &header.id, header.item_type, &header.url) + generate_runtime_playlist_uuid(&header.input_name, &header.id, header.item_type, &header.url) } #[inline] diff --git a/shared/src/model/playlist_info_document.rs b/shared/src/model/playlist_info_document.rs index cc42ea5c6..fd78657bc 100644 --- a/shared/src/model/playlist_info_document.rs +++ b/shared/src/model/playlist_info_document.rs @@ -294,7 +294,7 @@ impl StreamProperties { }) .collect() }), - tmdb: series.tmdb.unwrap_or_default().to_string().intern(), + tmdb: series.tmdb.map(|value| value.to_string().intern()).unwrap_or_else(|| "".intern()), youtube_trailer: Arc::clone(&series.youtube_trailer), episode_run_time: series.episode_run_time.as_ref().map(Arc::clone).unwrap_or_else(|| "".intern()), category_id: category_id.to_string().intern(), diff --git a/shared/src/utils/hash_utils.rs b/shared/src/utils/hash_utils.rs index b9aa2d76e..9476de308 100644 --- a/shared/src/utils/hash_utils.rs +++ b/shared/src/utils/hash_utils.rs @@ -122,12 +122,7 @@ fn url_path_and_more(url: &str) -> Option { Some(out) } -pub fn generate_playlist_uuid(key: &str, provider_id: &str, item_type: PlaylistItemType, url: &str) -> UUIDType { - if provider_id.is_empty() || provider_id == "0" { - if let Some(url_path) = url_path_and_more(url) { - return hash_string(&url_path); - } - } +pub fn generate_provider_playlist_uuid(key: &str, provider_id: &str, item_type: PlaylistItemType) -> UUIDType { let mut hasher = blake3::Hasher::new(); hasher.update(key.as_bytes()); hasher.update(provider_id.as_bytes()); @@ -135,6 +130,33 @@ pub fn generate_playlist_uuid(key: &str, provider_id: &str, item_type: PlaylistI UUIDType(hasher.finalize().into()) } +pub fn generate_local_playlist_uuid(key: &str, item_type: PlaylistItemType, url: &str) -> UUIDType { + let mut hasher = blake3::Hasher::new(); + hasher.update(key.as_bytes()); + hasher.update(item_type.to_string().as_bytes()); + + if let Some(url_path) = url_path_and_more(url) { + hasher.update(url_path.as_bytes()); + } else { + hasher.update(url.as_bytes()); + } + + UUIDType(hasher.finalize().into()) +} + +pub fn generate_runtime_playlist_uuid( + key: &str, + provider_id: &str, + item_type: PlaylistItemType, + url: &str, +) -> UUIDType { + if item_type.is_local() { + generate_local_playlist_uuid(key, item_type, url) + } else { + generate_provider_playlist_uuid(key, provider_id, item_type) + } +} + pub fn u32_to_base64(value: u32) -> String { let bytes = value.to_be_bytes(); general_purpose::URL_SAFE_NO_PAD.encode(bytes) @@ -494,44 +516,61 @@ mod tests { assert_eq!(get_provider_id("100", "http://x.com/live/200.ts"), Some(100)); } - // ── generate_playlist_uuid ─────────────────────────────────────── + // ── provider/local playlist uuid ──────────────────────────────── #[test] - fn generate_playlist_uuid_deterministic() { - let a = generate_playlist_uuid("k", "1", PlaylistItemType::Live, "http://x.com/1.ts"); - let b = generate_playlist_uuid("k", "1", PlaylistItemType::Live, "http://x.com/1.ts"); + fn generate_provider_playlist_uuid_deterministic() { + let a = generate_provider_playlist_uuid("k", "1", PlaylistItemType::Live); + let b = generate_provider_playlist_uuid("k", "1", PlaylistItemType::Live); assert_eq!(a, b); } #[test] - fn generate_playlist_uuid_different_key() { - let a = generate_playlist_uuid("k1", "1", PlaylistItemType::Live, "http://x.com/1.ts"); - let b = generate_playlist_uuid("k2", "1", PlaylistItemType::Live, "http://x.com/1.ts"); + fn generate_provider_playlist_uuid_different_key() { + let a = generate_provider_playlist_uuid("k1", "1", PlaylistItemType::Live); + let b = generate_provider_playlist_uuid("k2", "1", PlaylistItemType::Live); assert_ne!(a, b); } #[test] - fn generate_playlist_uuid_empty_provider_uses_url_path() { - let a = generate_playlist_uuid("k", "", PlaylistItemType::Live, "http://x.com/path/to/stream"); - let b = generate_playlist_uuid("k", "", PlaylistItemType::Live, "http://y.com/path/to/stream"); - // Same path → same UUID (host is stripped by Url::parse → path()) + fn generate_local_playlist_uuid_uses_url_path() { + let a = generate_local_playlist_uuid("k", PlaylistItemType::LocalSeries, "http://x.com/path/to/stream"); + let b = generate_local_playlist_uuid("k", PlaylistItemType::LocalSeries, "http://y.com/path/to/stream"); assert_eq!(a, b); } #[test] - fn generate_playlist_uuid_zero_provider_uses_url_path() { - let a = generate_playlist_uuid("k", "0", PlaylistItemType::Live, "http://x.com/stream"); - let b = generate_playlist_uuid("k", "0", PlaylistItemType::Live, "http://x.com/stream"); + fn generate_local_playlist_uuid_is_deterministic() { + let a = generate_local_playlist_uuid("k", PlaylistItemType::LocalSeries, "http://x.com/stream"); + let b = generate_local_playlist_uuid("k", PlaylistItemType::LocalSeries, "http://x.com/stream"); assert_eq!(a, b); } #[test] - fn generate_playlist_uuid_different_type() { - let a = generate_playlist_uuid("k", "1", PlaylistItemType::Live, "http://x.com/1.ts"); - let b = generate_playlist_uuid("k", "1", PlaylistItemType::Video, "http://x.com/1.ts"); + fn generate_local_playlist_uuid_keeps_local_series_info_and_episode_distinct() { + let url = "file:///library/show/Season%201/Episode%2001.mkv"; + let series_info = generate_local_playlist_uuid("library", PlaylistItemType::LocalSeriesInfo, url); + let episode = generate_local_playlist_uuid("library", PlaylistItemType::LocalSeries, url); + + assert_ne!(series_info, episode); + } + + #[test] + fn generate_provider_playlist_uuid_different_type() { + let a = generate_provider_playlist_uuid("k", "1", PlaylistItemType::Live); + let b = generate_provider_playlist_uuid("k", "1", PlaylistItemType::Video); assert_ne!(a, b); } + #[test] + fn generate_runtime_playlist_uuid_dispatches_by_item_type() { + let provider = generate_runtime_playlist_uuid("input", "100", PlaylistItemType::Series, "file:///ignored.mkv"); + let local = + generate_runtime_playlist_uuid("input", "100", PlaylistItemType::LocalSeries, "file:///ignored.mkv"); + + assert_ne!(provider, local); + } + // ── u32_to_base64 / base64_to_u32 ─────────────────────────────── #[test]