diff --git a/synctv-adapter/src/source_config.rs b/synctv-adapter/src/source_config.rs index 5d1fcc3b..2ab4f92c 100644 --- a/synctv-adapter/src/source_config.rs +++ b/synctv-adapter/src/source_config.rs @@ -852,6 +852,12 @@ fn playback_proxy_mode_from_proto( source_config_proto::PlaybackProxyMode::Only => { Ok(synctv_core::models::PlaybackProxyMode::Only) } + source_config_proto::PlaybackProxyMode::DirectPrefer => { + Ok(synctv_core::models::PlaybackProxyMode::DirectPrefer) + } + source_config_proto::PlaybackProxyMode::DirectOnly => { + Ok(synctv_core::models::PlaybackProxyMode::DirectOnly) + } } } @@ -970,3 +976,26 @@ fn twitch_playlist_content_from_proto( }, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_direct_playback_proxy_modes() { + assert_eq!( + playback_proxy_mode_from_proto( + source_config_proto::PlaybackProxyMode::DirectPrefer as i32 + ) + .expect("direct-prefer proxy mode should map"), + synctv_core::models::PlaybackProxyMode::DirectPrefer, + ); + assert_eq!( + playback_proxy_mode_from_proto( + source_config_proto::PlaybackProxyMode::DirectOnly as i32 + ) + .expect("direct-only proxy mode should map"), + synctv_core::models::PlaybackProxyMode::DirectOnly, + ); + } +} diff --git a/synctv-api-common/src/impls/admin/media.rs b/synctv-api-common/src/impls/admin/media.rs index 198c43f5..1cbf8862 100644 --- a/synctv-api-common/src/impls/admin/media.rs +++ b/synctv-api-common/src/impls/admin/media.rs @@ -663,6 +663,7 @@ impl AdminApiImpl { current_path: Vec::new(), version: String::new(), pagination: None, + supports_search: true, }; response.version = crate::impls::client::media::compute_playlist_items_response_version(&response)?; @@ -702,6 +703,7 @@ impl AdminApiImpl { current_path, version: String::new(), pagination: None, + supports_search: false, }; response.version = crate::impls::client::media::compute_playlist_items_response_version( @@ -731,6 +733,7 @@ impl AdminApiImpl { .await .map_err(ApiError::from)?; let response_pagination = result.pagination.clone(); + let supports_search = result.supports_search; let dynamic_items = result .items @@ -1076,6 +1079,7 @@ impl AdminApiImpl { ) } }), + supports_search, }; response.version = crate::impls::client::media::compute_playlist_items_response_version(&response)?; @@ -1199,6 +1203,7 @@ impl AdminApiImpl { current_path, version: String::new(), pagination: None, + supports_search: true, }; response.version = crate::impls::client::media::compute_playlist_items_response_version(&response)?; diff --git a/synctv-api-common/src/impls/client/convert.rs b/synctv-api-common/src/impls/client/convert.rs index fe789a00..6c5d2fc1 100644 --- a/synctv-api-common/src/impls/client/convert.rs +++ b/synctv-api-common/src/impls/client/convert.rs @@ -1587,6 +1587,12 @@ const fn playback_proxy_mode_to_proto(mode: synctv_core::models::PlaybackProxyMo synctv_core::models::PlaybackProxyMode::Only => { source_config_proto::PlaybackProxyMode::Only as i32 } + synctv_core::models::PlaybackProxyMode::DirectPrefer => { + source_config_proto::PlaybackProxyMode::DirectPrefer as i32 + } + synctv_core::models::PlaybackProxyMode::DirectOnly => { + source_config_proto::PlaybackProxyMode::DirectOnly as i32 + } } } @@ -3820,6 +3826,7 @@ fn playback_media_headers_for_proto( | PlaybackMediaProvider::Bilibili( PlaybackBilibiliMedia::Direct { headers, .. } | PlaybackBilibiliMedia::DirectDashManifest { headers, .. } + | PlaybackBilibiliMedia::DirectDurlManifest { headers, .. } | PlaybackBilibiliMedia::DurlManifest { headers, .. }, ) | PlaybackMediaProvider::DirectUrl(PlaybackDirectUrlMedia::Direct { headers, .. }) @@ -4028,12 +4035,20 @@ fn playback_media_url( mode_name, .. }) => dash_manifest_resource(version, *expires_at, mode_name, "direct"), - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { - version, - expires_at, - mode_name, - .. - }) => versioned_indexed_resource( + PlaybackMediaProvider::Bilibili( + PlaybackBilibiliMedia::DirectDurlManifest { + version, + expires_at, + mode_name, + .. + } + | PlaybackBilibiliMedia::DurlManifest { + version, + expires_at, + mode_name, + .. + }, + ) => versioned_indexed_resource( "bilibili", version, *expires_at, @@ -5179,6 +5194,54 @@ mod playback_conversion_tests { assert_eq!(media.headers, headers); } + #[test] + fn direct_durl_manifest_preserves_bilibili_headers_for_clients() { + let key = signing_key(); + let signing = signing_context(&key); + let mut headers = HashMap::new(); + headers.insert( + "Referer".to_string(), + "https://www.bilibili.com".to_string(), + ); + headers.insert("User-Agent".to_string(), "SyncTV".to_string()); + let info = PlaybackInfo::builder() + .add_media(PlaybackMedia { + name: "MP4".to_string(), + format: "m3u8".to_string(), + expire_at: synctv_core::SystemClock + .now() + .checked_add_signed(chrono::Duration::minutes(30)), + metadata: None, + p2p_swarm_id: None, + provider: PlaybackMediaProvider::Bilibili( + PlaybackBilibiliMedia::DirectDurlManifest { + version: "v1".to_string(), + expires_at: synctv_core::SystemClock.now().timestamp() + 1800, + mode_name: "mp4".to_string(), + segments: vec![synctv_core::models::media::BilibiliDurlSegment { + url: "https://cdn.example/video.mp4".to_string(), + backup_urls: Vec::new(), + duration_millis: 1_000, + }], + headers: headers.clone(), + }, + ), + }) + .build(); + + let proto = try_playback_to_proto(&playback_result(info), &codec(), Some(&signing)) + .expect("playback should convert"); + let media = &proto.playback_infos["dash"].medias[0]; + assert!( + media + .url + .starts_with("/api/playback-providers/bilibili/v1/hls-manifests/mp4/0?"), + "unexpected direct DURL URL: {}", + media.url + ); + assert_eq!(media.headers, headers); + } + #[test] fn live_danmaku_provider_converts_to_live_endpoint() { let key = signing_key(); diff --git a/synctv-api-common/src/impls/client/media.rs b/synctv-api-common/src/impls/client/media.rs index fdd0e4fd..820016ef 100644 --- a/synctv-api-common/src/impls/client/media.rs +++ b/synctv-api-common/src/impls/client/media.rs @@ -964,6 +964,7 @@ pub fn compute_playlist_items_response_version( hash_optional_u64(&mut hasher, response.total); hasher.update(response.playlist_count.to_le_bytes()); hasher.update(response.file_count.to_le_bytes()); + hasher.update([u8::from(response.supports_search)]); for playlist in &response.playlists { hash_proto_message(&mut hasher, playlist)?; @@ -2178,6 +2179,7 @@ impl ClientApiImpl { .await .map_err(ApiError::from)?; let response_pagination = result.pagination.clone(); + let supports_search = result.supports_search; let dynamic_items = result .items .into_iter() @@ -2262,6 +2264,7 @@ impl ClientApiImpl { current_path: Vec::new(), version: String::new(), pagination: Some(dynamic_response_pagination(response_pagination)?), + supports_search, }, ); } @@ -2388,6 +2391,7 @@ impl ClientApiImpl { current_path: Vec::new(), version: String::new(), pagination: None, + supports_search: true, }, ); }; @@ -2434,6 +2438,7 @@ impl ClientApiImpl { current_path, version: String::new(), pagination: None, + supports_search: false, }, ); } @@ -2466,6 +2471,7 @@ impl ClientApiImpl { .await .map_err(ApiError::from)?; let response_pagination = result.pagination.clone(); + let supports_search = result.supports_search; // Convert provider DynamicPlaylistItem to proto PlaylistItem let dynamic_items: Vec<_> = result @@ -2803,6 +2809,7 @@ impl ClientApiImpl { current_path, version: String::new(), pagination, + supports_search, }, ); } @@ -2928,6 +2935,7 @@ impl ClientApiImpl { current_path, version: String::new(), pagination: None, + supports_search: true, }) } @@ -3153,6 +3161,7 @@ mod tests { current_path: Vec::new(), version: String::new(), pagination: None, + supports_search: true, }; let original = compute_playlist_items_response_version(&make_response( @@ -3206,6 +3215,7 @@ mod tests { current_path: Vec::new(), version: String::new(), pagination: None, + supports_search: true, }; let live = compute_playlist_items_response_version(&make_response(true))?; diff --git a/synctv-api-common/src/impls/messaging/tests.rs b/synctv-api-common/src/impls/messaging/tests.rs index f016cbb3..9a357d4c 100644 --- a/synctv-api-common/src/impls/messaging/tests.rs +++ b/synctv-api-common/src/impls/messaging/tests.rs @@ -696,6 +696,7 @@ fn empty_playlist_items_response( dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: version.into(), } } @@ -5199,6 +5200,7 @@ async fn test_observe_playlist_items_without_cursor_sends_snapshot_immediately() dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v1".to_string(), }, })); @@ -5277,6 +5279,7 @@ async fn test_observed_playlist_items_batch_coalesces_identical_snapshot_loads() dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v1".to_string(), }); let handler = test_message_handler_for_user_with_runtime( @@ -5331,6 +5334,7 @@ async fn test_observed_playlist_items_batch_coalesces_identical_snapshot_loads() dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v2".to_string(), }); @@ -5527,6 +5531,7 @@ async fn test_observe_playlist_items_requires_inner_request() { dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v1".to_string(), }); let handler = test_message_handler_for_user_with_runtime( @@ -6128,6 +6133,7 @@ async fn test_media_resource_hub_coalesces_event_refresh_and_fans_out() { dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v1".to_string(), }); let sender_a = RecordingMessageSender::new(); @@ -6217,6 +6223,7 @@ async fn test_media_resource_hub_coalesces_event_refresh_and_fans_out() { dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v2".to_string(), }); let event = RealtimeEvent::MediaAdded { @@ -6586,6 +6593,7 @@ async fn test_observe_playlist_items_sends_current_snapshot() { dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v1".to_string(), }); let handler = handler @@ -6662,6 +6670,7 @@ async fn test_observed_playlist_items_receive_future_media_updates() { dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v1".to_string(), }); let handler = handler @@ -6734,6 +6743,7 @@ async fn test_observed_playlist_items_receive_future_media_updates() { dynamic_items: Vec::new(), current_path: Vec::new(), pagination: None, + supports_search: true, version: "items-v2".to_string(), }); diff --git a/synctv-api-common/src/playback_provider/common.rs b/synctv-api-common/src/playback_provider/common.rs index 5badb8b1..5b79a46e 100644 --- a/synctv-api-common/src/playback_provider/common.rs +++ b/synctv-api-common/src/playback_provider/common.rs @@ -769,6 +769,14 @@ pub async fn playback_transport_action_to_chunk_stream( .map_err(|error| ApiError::ServiceUnavailable(error.to_string()))?; Ok(hls_manifest_chunk_stream(rewritten, 200, head)) } + PlaybackTransportAction::M3u8DirectBody { body } => { + if body.len() > MAX_MANIFEST_SIZE { + return Err(ApiError::ServiceUnavailable( + "M3U8 manifest exceeded size limit".to_string(), + )); + } + Ok(hls_manifest_chunk_stream(body, 200, head)) + } PlaybackTransportAction::MpdRewrite { .. } | PlaybackTransportAction::MpdBodyRewrite { .. } => Err(ApiError::Internal( "MPD rewrite action requires DASH route signing context".to_string(), @@ -2760,4 +2768,50 @@ mod tests { assert_eq!(chunk.cache_control.as_deref(), Some("no-store")); Ok(()) } + + #[tokio::test] + async fn in_memory_direct_m3u8_keeps_upstream_segment_urls() -> anyhow::Result<()> { + let Some(mock_server) = start_mock_server_or_skip().await? else { + return Ok(()); + }; + let client = mock_proxy_client(&mock_server)?; + let ssrf_guard = test_ssrf_guard(); + let proxy_slice_cache = + synctv_proxy::slice_cache::SliceCache::new_with_client_and_ssrf_guard( + synctv_proxy::slice_cache::SliceCacheConfig::default(), + client.clone(), + ssrf_guard.clone(), + )?; + let signing_key = crate::proxy_signature::ProxySigningKey::try_derive_from( + b"test-secret-key-for-direct-in-memory-m3u8", + )?; + let body = "#EXTM3U\n#EXTINF:2,\nhttps://cdn.example/part-1.mp4\n#EXT-X-ENDLIST\n"; + let deps = PlaybackTransportExecutorDeps { + proxy_signing_key: &signing_key, + proxy_http_client: &client, + ssrf_guard: &ssrf_guard, + proxy_slice_cache: &proxy_slice_cache, + request_control: None, + hls_rewrite: None, + }; + + let mut stream = playback_transport_action_to_chunk_stream( + deps, + PlaybackTransportAction::M3u8DirectBody { + body: body.as_bytes().to_vec(), + }, + false, + ) + .await + .map_err(|error| anyhow::anyhow!("{error:?}"))?; + let chunk = stream + .next() + .await + .ok_or_else(|| anyhow::anyhow!("direct manifest should emit one chunk"))? + .map_err(|error| anyhow::anyhow!("{error:?}"))?; + + assert_eq!(std::str::from_utf8(&chunk.data)?, body); + assert_eq!(chunk.cache_control.as_deref(), Some("no-store")); + Ok(()) + } } diff --git a/synctv-api-common/src/providers/common.rs b/synctv-api-common/src/providers/common.rs index 8ca46fa5..bc049a64 100644 --- a/synctv-api-common/src/providers/common.rs +++ b/synctv-api-common/src/providers/common.rs @@ -160,6 +160,12 @@ fn core_playback_proxy_mode( synctv_proto::source_config::PlaybackProxyMode::Only => { Ok(synctv_core::models::PlaybackProxyMode::Only) } + synctv_proto::source_config::PlaybackProxyMode::DirectPrefer => { + Ok(synctv_core::models::PlaybackProxyMode::DirectPrefer) + } + synctv_proto::source_config::PlaybackProxyMode::DirectOnly => { + Ok(synctv_core::models::PlaybackProxyMode::DirectOnly) + } } } diff --git a/synctv-api-http/src/providers/mod.rs b/synctv-api-http/src/providers/mod.rs index a61e6468..a1be9341 100644 --- a/synctv-api-http/src/providers/mod.rs +++ b/synctv-api-http/src/providers/mod.rs @@ -102,6 +102,7 @@ pub(crate) async fn execute_playback_transport( PlaybackTransportAction::M3u8Rewrite { .. } | PlaybackTransportAction::M3u8RewriteWithSource { .. } | PlaybackTransportAction::M3u8BodyRewrite { .. } + | PlaybackTransportAction::M3u8DirectBody { .. } | PlaybackTransportAction::MpdRewrite { .. } | PlaybackTransportAction::MpdBodyRewrite { .. } => Err(AppError::internal_server_error( "Manifest rewrite actions require provider-specific route context".to_string(), @@ -293,7 +294,8 @@ async fn execute_playback_transport_with_runtime_for_method( } PlaybackTransportAction::M3u8Rewrite { .. } | PlaybackTransportAction::M3u8RewriteWithSource { .. } - | PlaybackTransportAction::M3u8BodyRewrite { .. } => { + | PlaybackTransportAction::M3u8BodyRewrite { .. } + | PlaybackTransportAction::M3u8DirectBody { .. } => { if method != Method::GET { return Err(playback_transport_method_not_allowed()); } diff --git a/synctv-api/tests/dynamic_playback_api_tests.rs b/synctv-api/tests/dynamic_playback_api_tests.rs index 0b3a6506..92394cac 100644 --- a/synctv-api/tests/dynamic_playback_api_tests.rs +++ b/synctv-api/tests/dynamic_playback_api_tests.rs @@ -353,6 +353,7 @@ impl DynamicPlaylistProvider for StubDynamicProvider { has_more: false, items, pagination: synctv_core::provider::DynamicPagination::Page { page: 1 }, + supports_search: false, }) } diff --git a/synctv-core/src/models/media.rs b/synctv-core/src/models/media.rs index aeea2b20..d8a6fc2c 100644 --- a/synctv-core/src/models/media.rs +++ b/synctv-core/src/models/media.rs @@ -1228,6 +1228,18 @@ pub enum PlaybackBilibiliMedia { #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] headers: std::collections::HashMap, }, + /// A SyncTV-generated HLS manifest whose Bilibili media segments are + /// fetched by the client directly. + DirectDurlManifest { + version: String, + expires_at: i64, + mode_name: String, + segments: Vec, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + headers: std::collections::HashMap, + }, + /// A SyncTV-generated HLS manifest whose media segments are forwarded by + /// the server so backup CDN candidates remain available. DurlManifest { version: String, expires_at: i64, @@ -3015,6 +3027,7 @@ impl PlaybackMedia { | PlaybackMediaProvider::Bilibili( PlaybackBilibiliMedia::Direct { headers, .. } | PlaybackBilibiliMedia::DirectDashManifest { headers, .. } + | PlaybackBilibiliMedia::DirectDurlManifest { headers, .. } | PlaybackBilibiliMedia::DurlManifest { headers, .. } | PlaybackBilibiliMedia::ProxyMediaStream { headers, .. } | PlaybackBilibiliMedia::ProxyHlsManifest { headers, .. }, diff --git a/synctv-core/src/models/source_config.rs b/synctv-core/src/models/source_config.rs index d8759426..93c7378c 100644 --- a/synctv-core/src/models/source_config.rs +++ b/synctv-core/src/models/source_config.rs @@ -11,6 +11,8 @@ pub enum PlaybackProxyMode { Auto, Prefer, Only, + DirectPrefer, + DirectOnly, } impl PlaybackProxyMode { @@ -25,6 +27,8 @@ impl PlaybackProxyMode { Self::Auto => "auto", Self::Prefer => "prefer", Self::Only => "only", + Self::DirectPrefer => "directPrefer", + Self::DirectOnly => "directOnly", } } } diff --git a/synctv-core/src/provider/alist.rs b/synctv-core/src/provider/alist.rs index bf1cff86..2d056134 100644 --- a/synctv-core/src/provider/alist.rs +++ b/synctv-core/src/provider/alist.rs @@ -7,6 +7,7 @@ use super::upstream_transport::alist as alist_upstream; use super::{ access::{AlistAccess, AlistBinding}, provider_client::{create_remote_alist_client, AlistClientArc, ProviderClientManager}, + traits::dynamic_page_has_more, DynamicBrowsePathSegment, DynamicListQuery, DynamicListResult, DynamicPagination, DynamicPlaylistItem, DynamicPlaylistProvider, ItemType, MediaProvider, NextPlayItem, PlaybackClientProfile, PlaybackInfo, PlaybackResult, PlaybackStreamPreference, @@ -2785,101 +2786,111 @@ impl DynamicPlaylistProvider for AlistProvider { .map(str::trim) .filter(|s| !s.is_empty()); - let items: Vec = if let Some(keywords) = search { - let search_resp = client - .fs_search(alist_resolved_search_request( - &resolved, - full_path.clone(), - keywords.to_string(), - page, - per_page, - )) - .await?; + let (items, total, returned_count): (Vec, usize, usize) = + if let Some(keywords) = search { + let search_resp = client + .fs_search(alist_resolved_search_request( + &resolved, + full_path.clone(), + keywords.to_string(), + page, + per_page, + )) + .await?; - search_resp - .content - .into_iter() - .filter_map(|file_item| { - let item_type = alist_directory_item_type(&file_item.name, file_item.is_dir)?; - Some((file_item, item_type)) - }) - .map(|(file_item, item_type)| { - let full_item_path = join_alist_path(&file_item.parent, &file_item.name); - let item_relative_path = - alist_relative_path_from_base(&resolved.path, &full_item_path).ok_or_else( - || { - ProviderError::ApiError(format!( + let total = usize::try_from(search_resp.total).unwrap_or(usize::MAX); + let returned_count = search_resp.content.len(); + let items = search_resp + .content + .into_iter() + .filter_map(|file_item| { + let item_type = + alist_directory_item_type(&file_item.name, file_item.is_dir)?; + Some((file_item, item_type)) + }) + .map(|(file_item, item_type)| { + let full_item_path = join_alist_path(&file_item.parent, &file_item.name); + let item_relative_path = + alist_relative_path_from_base(&resolved.path, &full_item_path) + .ok_or_else(|| { + ProviderError::ApiError(format!( "Alist search result path '{full_item_path}' is outside base path '{}'", resolved.path )) - }, - )?; - - Ok(DynamicPlaylistItem { - name: file_item.name, - item_type, - target: Self::encode_target(&item_relative_path)?, - size: Some(file_item.size), - thumbnail: None, - description: None, - modified_at: None, - source_config: None, - metadata: None, + })?; + + Ok(DynamicPlaylistItem { + name: file_item.name, + item_type, + target: Self::encode_target(&item_relative_path)?, + size: Some(file_item.size), + thumbnail: None, + description: None, + modified_at: None, + source_config: None, + metadata: None, + }) }) - }) - .collect::, ProviderError>>()? - } else { - let list_resp = client - .fs_list(alist_resolved_list_request( - &resolved, - full_path.clone(), - page, - per_page, - query.refresh, - )) - .await?; - - list_resp - .content - .into_iter() - .filter_map(|file_item| { - let item_type = alist_directory_item_type(&file_item.name, file_item.is_dir)?; - Some((file_item, item_type)) - }) - .map(|(file_item, item_type)| { - let item_relative_path = if let Some(rel) = relative_path.as_deref() { - format!("{}/{}", rel.trim_end_matches('/'), file_item.name) - } else { - format!("/{}", file_item.name) - }; + .collect::, ProviderError>>()?; + (items, total, returned_count) + } else { + let list_resp = client + .fs_list(alist_resolved_list_request( + &resolved, + full_path.clone(), + page, + per_page, + query.refresh, + )) + .await?; - Ok(DynamicPlaylistItem { - name: file_item.name, - item_type, - target: Self::encode_target(&item_relative_path)?, - size: Some(file_item.size), - thumbnail: if file_item.thumb.is_empty() { - None + let total = usize::try_from(list_resp.total).unwrap_or(usize::MAX); + let returned_count = list_resp.content.len(); + let items = list_resp + .content + .into_iter() + .filter_map(|file_item| { + let item_type = + alist_directory_item_type(&file_item.name, file_item.is_dir)?; + Some((file_item, item_type)) + }) + .map(|(file_item, item_type)| { + let item_relative_path = if let Some(rel) = relative_path.as_deref() { + format!("{}/{}", rel.trim_end_matches('/'), file_item.name) } else { - Some(crate::provider::DynamicPlaylistItemThumbnail::Url( - file_item.thumb, - )) - }, - description: None, - modified_at: Some(alist_modified_to_i64(file_item.modified)?), - source_config: None, - metadata: None, + format!("/{}", file_item.name) + }; + + Ok(DynamicPlaylistItem { + name: file_item.name, + item_type, + target: Self::encode_target(&item_relative_path)?, + size: Some(file_item.size), + thumbnail: if file_item.thumb.is_empty() { + None + } else { + Some(crate::provider::DynamicPlaylistItemThumbnail::Url( + file_item.thumb, + )) + }, + description: None, + modified_at: Some(alist_modified_to_i64(file_item.modified)?), + source_config: None, + metadata: None, + }) }) - }) - .collect::, ProviderError>>()? - }; + .collect::, ProviderError>>()?; + (items, total, returned_count) + }; + let page = query.page().max(1); Ok(DynamicListResult { - has_more: items.len() >= query.page_size.max(1), + has_more: dynamic_page_has_more(total, page, query.page_size, returned_count), items, pagination: DynamicPagination::Page { page: query.page().max(1), }, + supports_search: true, }) } @@ -2921,7 +2932,7 @@ impl DynamicPlaylistProvider for AlistProvider { let mut page = 1; loop { - let page_items = self + let page_result = self .list_playlist( ctx, playlist, @@ -2933,11 +2944,8 @@ impl DynamicPlaylistProvider for AlistProvider { }, ) .await?; - if page_items.is_empty() { - return Ok(None); - } - if let Some(item) = page_items + if let Some(item) = page_result .iter() .find(|item| item.item_type == ItemType::Media && &item.target == target) { @@ -2949,7 +2957,7 @@ impl DynamicPlaylistProvider for AlistProvider { })); } - if page_items.len() < LIST_PAGE_SIZE { + if !page_result.has_more { return Ok(None); } page += 1; @@ -3004,7 +3012,7 @@ impl DynamicPlaylistProvider for AlistProvider { let mut current_page = 1; loop { - let page_items = self + let page_result = self .list_playlist( ctx, playlist, @@ -3017,12 +3025,8 @@ impl DynamicPlaylistProvider for AlistProvider { ) .await?; - if page_items.is_empty() { - break; - } - if found_current { - if let Some(next) = page_items + if let Some(next) = page_result .iter() .find(|item| item.item_type == ItemType::Media) { @@ -3040,10 +3044,10 @@ impl DynamicPlaylistProvider for AlistProvider { })); } } else if let Some(idx) = - page_items.iter().position(|item| &item.target == target) + page_result.iter().position(|item| &item.target == target) { found_current = true; - if let Some(next) = page_items + if let Some(next) = page_result .iter() .skip(idx + 1) .find(|item| item.item_type == ItemType::Media) @@ -3063,7 +3067,7 @@ impl DynamicPlaylistProvider for AlistProvider { } } - if page_items.len() < LIST_PAGE_SIZE { + if !page_result.has_more { break; } current_page += 1; @@ -3075,34 +3079,43 @@ impl DynamicPlaylistProvider for AlistProvider { .map(|x| x.0) .filter(|&s| !s.is_empty()); let parent_target = parent_path.map(Self::encode_target).transpose()?; - let first_page = self - .list_playlist( - ctx, - playlist, - parent_target.as_ref(), - DynamicListQuery { - pagination: DynamicPagination::Page { page: 1 }, - page_size: LIST_PAGE_SIZE, - ..DynamicListQuery::default() - }, - ) - .await?; - if let Some(first) = first_page - .iter() - .find(|item| item.item_type == ItemType::Media) - { - return Ok(Some(NextPlayItem { - name: first.name.clone(), - item_type: first.item_type, - source_config: build_next_source_config(&build_full_path( - &Self::decode_target(Some(&first.target))?.ok_or_else(|| { - ProviderError::InvalidConfig( - "Missing Alist item target".to_string(), - ) - })?, - )), - target: first.target.clone(), - })); + let mut page = 1; + loop { + let page_result = self + .list_playlist( + ctx, + playlist, + parent_target.as_ref(), + DynamicListQuery { + pagination: DynamicPagination::Page { page }, + page_size: LIST_PAGE_SIZE, + ..DynamicListQuery::default() + }, + ) + .await?; + if let Some(first) = page_result + .iter() + .find(|item| item.item_type == ItemType::Media) + { + return Ok(Some(NextPlayItem { + name: first.name.clone(), + item_type: first.item_type, + source_config: build_next_source_config(&build_full_path( + &Self::decode_target(Some(&first.target))?.ok_or_else( + || { + ProviderError::InvalidConfig( + "Missing Alist item target".to_string(), + ) + }, + )?, + )), + target: first.target.clone(), + })); + } + if !page_result.has_more { + break; + } + page += 1; } } @@ -3118,7 +3131,7 @@ impl DynamicPlaylistProvider for AlistProvider { let mut all_items = Vec::with_capacity(SHUFFLE_MAX_ITEMS); let mut page = 1; loop { - let page_items = self + let page_result = self .list_playlist( ctx, playlist, @@ -3130,9 +3143,9 @@ impl DynamicPlaylistProvider for AlistProvider { }, ) .await?; - let is_last_page = page_items.len() < LIST_PAGE_SIZE; - all_items.extend(page_items); - if is_last_page || all_items.len() >= SHUFFLE_MAX_ITEMS { + let has_more = page_result.has_more; + all_items.extend(page_result); + if !has_more || all_items.len() >= SHUFFLE_MAX_ITEMS { break; } page += 1; diff --git a/synctv-core/src/provider/bilibili.rs b/synctv-core/src/provider/bilibili.rs index cfe3a976..aad56e42 100644 --- a/synctv-core/src/provider/bilibili.rs +++ b/synctv-core/src/provider/bilibili.rs @@ -53,6 +53,7 @@ const SMS_LOGIN_SESSION_TTL_SECONDS: i64 = 10 * 60; const SMS_LOGIN_SESSION_VERSION: &str = "v2"; const SMS_LOGIN_DOMAIN_SEPARATOR: &[u8] = b"synctv-bilibili-sms-login"; const SMS_LOGIN_TOKEN_NONCE_SIZE: usize = 12; +const BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION: &str = "v2"; type HmacSha256 = Hmac; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -2085,16 +2086,18 @@ fn mark_bilibili_playback_resources(result: &mut PlaybackResult, version: &str, if original_info.medias.len() == 1 && matches!( original_info.medias[0].provider, - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { .. }) + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) ) { if let Some(info) = result.playback_infos.get_mut(&mode_name) { - if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { - version: resource_version, - expires_at: resource_expires_at, - mode_name: resource_mode_name, - .. - }) = &mut info.medias[0].provider + if let PlaybackMediaProvider::Bilibili( + PlaybackBilibiliMedia::DirectDurlManifest { + version: resource_version, + expires_at: resource_expires_at, + mode_name: resource_mode_name, + .. + }, + ) = &mut info.medias[0].provider { *resource_version = version.to_string(); *resource_expires_at = expires_at; @@ -2102,21 +2105,25 @@ fn mark_bilibili_playback_resources(result: &mut PlaybackResult, version: &str, } } - // DURL playback already uses a SyncTV-generated manifest and - // server-forwarded segments. Keep a proxy-named sibling so - // PLAYBACK_PROXY_MODE_ONLY can retain this fallback route. + // Direct DURL playback serves a generated manifest with Bilibili + // segment URLs. The proxy sibling preserves server forwarding and + // backup CDN candidate selection for proxy playback modes. let mut proxy_info = original_info.clone(); if let Some(media) = proxy_info.medias.first_mut() { - if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { - version: resource_version, - expires_at: resource_expires_at, - mode_name: resource_mode_name, - .. - }) = &mut media.provider + if let PlaybackMediaProvider::Bilibili( + PlaybackBilibiliMedia::DirectDurlManifest { + segments, headers, .. + }, + ) = &media.provider { - *resource_version = version.to_string(); - *resource_expires_at = expires_at; - resource_mode_name.clone_from(&mode_name); + media.provider = + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { + version: version.to_string(), + expires_at, + mode_name: mode_name.clone(), + segments: segments.clone(), + headers: headers.clone(), + }); } } result @@ -2357,7 +2364,7 @@ fn playback_cache_entry( .cache_key_part(); Ok(( format!( - "playback:video:{video_key}:{}:{credential_cache_partition}", + "playback:{BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION}:video:{video_key}:{}:{credential_cache_partition}", config.cid ), Duration::from_hours(2), @@ -2365,14 +2372,14 @@ fn playback_cache_entry( } BilibiliSourceConfig::Pgc(config) => Ok(( format!( - "playback:pgc:{}:{}:{credential_cache_partition}", + "playback:{BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION}:pgc:{}:{}:{credential_cache_partition}", config.epid, config.cid ), Duration::from_hours(2), )), BilibiliSourceConfig::Live(config) => Ok(( format!( - "playback:live:{}:{credential_cache_partition}", + "playback:{BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION}:live:{}:{credential_cache_partition}", config.room_id ), Duration::from_mins(2), @@ -3542,6 +3549,7 @@ impl DynamicPlaylistProvider for BilibiliProvider { cursor: Self::encode_history_cursor(response.cursor)?, }, has_more: response.has_more, + supports_search: false, }); } let DynamicPagination::Page { page } = query.pagination else { @@ -3611,6 +3619,7 @@ impl DynamicPlaylistProvider for BilibiliProvider { has_more: start.saturating_add(items.len()) < total, items, pagination: DynamicPagination::Page { page }, + supports_search: false, }); } @@ -3663,6 +3672,7 @@ impl DynamicPlaylistProvider for BilibiliProvider { items, pagination: DynamicPagination::Page { page }, has_more: response.has_more, + supports_search: false, }); } @@ -3710,6 +3720,7 @@ impl DynamicPlaylistProvider for BilibiliProvider { has_more: start.saturating_add(items.len()) < total, items, pagination: DynamicPagination::Page { page }, + supports_search: false, }); } @@ -3754,6 +3765,7 @@ impl DynamicPlaylistProvider for BilibiliProvider { has_more: start.saturating_add(items.len()) < total, items, pagination: DynamicPagination::Page { page }, + supports_search: false, }); } @@ -3794,6 +3806,7 @@ impl DynamicPlaylistProvider for BilibiliProvider { items, pagination: DynamicPagination::Page { page }, has_more: response.has_more, + supports_search: matches!(&config.source, BilibiliPlaylistSource::UpVideos { .. }), }) } @@ -4144,6 +4157,22 @@ mod tests { result.map_err(|error| anyhow::anyhow!(error.to_string())) } + #[test] + fn playback_cache_key_uses_the_durl_transport_schema_version() -> TestResult { + let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig { + bvid: Some("BV1test12345".to_string()), + aid: None, + cid: 42, + shared: false, + proxy_mode: crate::models::PlaybackProxyMode::Auto, + }); + + let (key, _) = provider_ok(super::playback_cache_entry(&config, "anonymous"))?; + + assert!(key.starts_with("playback:v2:video:")); + Ok(()) + } + fn test_sms_login_secret() -> &'static [u8] { b"test-bilibili-sms-login-secret" } @@ -4341,11 +4370,12 @@ mod tests { ], "sm3_test_durl".to_string(), ))?; - let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { - segments, .. + let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { + segments, + .. }) = &media.provider else { - anyhow::bail!("DURL media should use an in-memory manifest"); + anyhow::bail!("DURL media should use a direct in-memory manifest"); }; let manifest = provider_ok(build_bilibili_durl_manifest(segments))?; @@ -4355,25 +4385,16 @@ mod tests { &media, "https://cdn.example/part-1.mp4?deadline=200" ), - Some(vec![ - "https://cdn.example/part-1.mp4?deadline=200".to_string(), - "https://backup.example/part-1.mp4?deadline=75".to_string(), - ]) + None ); let restored: crate::models::PlaybackMedia = serde_json::from_value(serde_json::to_value(&media)?)?; assert_eq!(media.p2p_swarm_id.as_deref(), Some("sm3_test_durl")); assert_eq!(restored.p2p_swarm_id, media.p2p_swarm_id); - assert_eq!( - bilibili_durl_resource_candidates( - &restored, - "https://cdn.example/part-1.mp4?deadline=200" - ), - bilibili_durl_resource_candidates( - &media, - "https://cdn.example/part-1.mp4?deadline=200" - ) - ); + assert!(matches!( + restored.provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) + )); assert_eq!(manifest.matches("#EXTINF:").count(), 2); assert_eq!(manifest.matches("#EXT-X-DISCONTINUITY").count(), 1); assert!(manifest.contains("#EXT-X-TARGETDURATION:3")); @@ -4417,6 +4438,27 @@ mod tests { mark_bilibili_playback_resources(&mut result, "version", 123); assert!(result.playback_infos.contains_key("proxy_durl")); + assert!(matches!( + result.playback_infos["durl"].medias[0].provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) + )); + assert!(matches!( + result.playback_infos["proxy_durl"].medias[0].provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { .. }) + )); + + let mut direct_only = result.clone(); + super::super::apply_provider_playback_policy( + &mut direct_only, + crate::models::PlaybackProxyMode::DirectOnly, + true, + ); + assert_eq!(direct_only.default_mode, "durl"); + assert!(!direct_only.playback_infos.contains_key("proxy_durl")); + assert!(matches!( + direct_only.playback_infos["durl"].medias[0].provider, + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { .. }) + )); super::super::apply_provider_playback_policy( &mut result, @@ -4712,6 +4754,17 @@ impl BilibiliProvider { .medias .get(url_index) .ok_or(ProviderError::NotFound)?; + if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { + segments, + .. + }) = &media.provider + { + return Ok( + super::playback_transport::PlaybackTransportAction::M3u8DirectBody { + body: build_bilibili_durl_manifest(segments)?.into_bytes(), + }, + ); + } if let PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { segments, .. @@ -5165,7 +5218,7 @@ fn bilibili_durl_media( "m3u8".to_string(), expires_at, Some(p2p_swarm_id), - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DurlManifest { + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDurlManifest { version: String::new(), expires_at: 0, mode_name: "mp4".to_string(), diff --git a/synctv-core/src/provider/cloudreve.rs b/synctv-core/src/provider/cloudreve.rs index 30e1abef..f42e9b12 100644 --- a/synctv-core/src/provider/cloudreve.rs +++ b/synctv-core/src/provider/cloudreve.rs @@ -1270,6 +1270,7 @@ impl DynamicPlaylistProvider for CloudreveProvider { items, pagination: pagination_result, has_more, + supports_search: true, }) } diff --git a/synctv-core/src/provider/douyin.rs b/synctv-core/src/provider/douyin.rs index 329e4988..3866cc7e 100644 --- a/synctv-core/src/provider/douyin.rs +++ b/synctv-core/src/provider/douyin.rs @@ -1145,6 +1145,7 @@ impl DynamicPlaylistProvider for DouyinProvider { cursor: page.cursor, }, has_more: page.has_more, + supports_search: false, }) } diff --git a/synctv-core/src/provider/emby.rs b/synctv-core/src/provider/emby.rs index e25414e2..05bccebb 100644 --- a/synctv-core/src/provider/emby.rs +++ b/synctv-core/src/provider/emby.rs @@ -5,6 +5,7 @@ use super::{ access::EmbyAccess, provider_client::{create_remote_emby_client, EmbyClientArc, ProviderClientManager}, + traits::dynamic_page_has_more, DynamicBrowsePathSegment, DynamicListQuery, DynamicListResult, DynamicPagination, DynamicPlaylistItem, DynamicPlaylistItemSourceConfig, DynamicPlaylistItemThumbnail, DynamicPlaylistProvider, ItemType, MediaProvider, NextPlayItem, PlaybackClientProfile, @@ -1899,9 +1900,7 @@ impl MediaProvider for EmbyProvider { .report_playback_stop(_ctx, &play_session_id, source_config, 0.0) .await; return Err(ProviderError::Internal(match cleanup { - Ok(()) => format!( - "failed to persist Emby playback session: {error}" - ), + Ok(()) => format!("failed to persist Emby playback session: {error}"), Err(cleanup_error) => format!( "failed to persist Emby playback session: {error}; compensation={cleanup_error}" ), @@ -2516,6 +2515,8 @@ impl DynamicPlaylistProvider for EmbyProvider { )?; let response = client.fs_list(list_req).await?; + let total = usize::try_from(response.total).unwrap_or(usize::MAX); + let returned_count = response.items.len(); let items = response .items .into_iter() @@ -2602,9 +2603,10 @@ impl DynamicPlaylistProvider for EmbyProvider { .collect::, ProviderError>>()?; Ok(DynamicListResult { - has_more: items.len() >= query.page_size.max(1), + has_more: dynamic_page_has_more(total, page, page_size, returned_count), items, pagination: DynamicPagination::Page { page }, + supports_search: true, }) } @@ -2690,7 +2692,7 @@ impl DynamicPlaylistProvider for EmbyProvider { let mut current_page = 1; loop { - let page_items = self + let page_result = self .list_playlist( ctx, playlist, @@ -2703,12 +2705,8 @@ impl DynamicPlaylistProvider for EmbyProvider { ) .await?; - if page_items.is_empty() { - break; - } - if found_current { - if let Some(next) = page_items + if let Some(next) = page_result .iter() .find(|item| item.item_type == ItemType::Media) { @@ -2728,10 +2726,10 @@ impl DynamicPlaylistProvider for EmbyProvider { })); } } else if let Some(idx) = - page_items.iter().position(|item| &item.target == target) + page_result.iter().position(|item| &item.target == target) { found_current = true; - if let Some(next) = page_items + if let Some(next) = page_result .iter() .skip(idx + 1) .find(|item| item.item_type == ItemType::Media) @@ -2753,44 +2751,52 @@ impl DynamicPlaylistProvider for EmbyProvider { } } - if page_items.len() < PAGE_SIZE { + if !page_result.has_more { break; } current_page += 1; } if found_current && play_mode == PlayMode::RepeatAll { - let first_page = self - .list_playlist( - ctx, - playlist, - browse_target.as_ref(), - DynamicListQuery { - pagination: DynamicPagination::Page { page: 1 }, - page_size: PAGE_SIZE, - ..DynamicListQuery::default() - }, - ) - .await?; - - if let Some(first) = first_page - .iter() - .find(|item| item.item_type == ItemType::Media) - { - return Ok(Some(NextPlayItem { - name: first.name.clone(), - item_type: first.item_type, - source_config: Self::build_next_source_config( - &base_config.server_id, - &Self::decode_target(Some(&first.target))?.ok_or_else(|| { - ProviderError::InvalidConfig( - "Missing Emby item target".to_string(), - ) - })?, - base_config.proxy_mode, - ), - target: first.target.clone(), - })); + let mut page = 1; + loop { + let page_result = self + .list_playlist( + ctx, + playlist, + browse_target.as_ref(), + DynamicListQuery { + pagination: DynamicPagination::Page { page }, + page_size: PAGE_SIZE, + ..DynamicListQuery::default() + }, + ) + .await?; + if let Some(first) = page_result + .iter() + .find(|item| item.item_type == ItemType::Media) + { + return Ok(Some(NextPlayItem { + name: first.name.clone(), + item_type: first.item_type, + source_config: Self::build_next_source_config( + &base_config.server_id, + &Self::decode_target(Some(&first.target))?.ok_or_else( + || { + ProviderError::InvalidConfig( + "Missing Emby item target".to_string(), + ) + }, + )?, + base_config.proxy_mode, + ), + target: first.target.clone(), + })); + } + if !page_result.has_more { + break; + } + page += 1; } } @@ -2802,7 +2808,7 @@ impl DynamicPlaylistProvider for EmbyProvider { let mut all_items = Vec::with_capacity(MAX_ITEMS); let mut page = 1; loop { - let page_items = self + let page_result = self .list_playlist( ctx, playlist, @@ -2814,9 +2820,9 @@ impl DynamicPlaylistProvider for EmbyProvider { }, ) .await?; - let is_last_page = page_items.len() < PAGE_SIZE; - all_items.extend(page_items); - if is_last_page || all_items.len() >= MAX_ITEMS { + let has_more = page_result.has_more; + all_items.extend(page_result); + if !has_more || all_items.len() >= MAX_ITEMS { break; } page += 1; diff --git a/synctv-core/src/provider/error.rs b/synctv-core/src/provider/error.rs index a68624f9..32792435 100644 --- a/synctv-core/src/provider/error.rs +++ b/synctv-core/src/provider/error.rs @@ -54,7 +54,9 @@ pub enum ProviderError { #[error("Credential expired: {0}")] CredentialExpired(String), - #[error("Credential encryption required for sensitive provider '{0}'. Configure credential_encryption in server settings.")] + #[error( + "Credential encryption required for sensitive provider '{0}'. Configure credential_encryption in server settings." + )] EncryptionRequired(&'static str), #[error("Route registration failed: {0}")] diff --git a/synctv-core/src/provider/fnos.rs b/synctv-core/src/provider/fnos.rs index 91adb6c2..008ad005 100644 --- a/synctv-core/src/provider/fnos.rs +++ b/synctv-core/src/provider/fnos.rs @@ -956,12 +956,7 @@ impl FnosProvider { { let persist_error = ProviderError::Internal(error.to_string()); return match self - .quit_transcode( - &client, - &token, - &versioned.version, - &response.play_link, - ) + .quit_transcode(&client, &token, &versioned.version, &response.play_link) .await { Ok(()) => Err(persist_error), @@ -1715,6 +1710,19 @@ fn mark_fnos_playback_resources(result: &mut PlaybackResult, version: &str, expi } } +fn remap_filtered_default_index( + resources: &[(usize, T)], + default_index: Option, +) -> Option { + default_index + .and_then(|default_index| { + resources + .iter() + .position(|(source_index, _)| *source_index == default_index) + }) + .or_else(|| (!resources.is_empty()).then_some(0)) +} + #[async_trait] impl MediaProvider for FnosProvider { fn name(&self) -> &'static str { @@ -1817,7 +1825,7 @@ impl MediaProvider for FnosProvider { }, ))), }; - if config.proxy_mode == crate::models::PlaybackProxyMode::Prefer { + if super::playback_proxy_mode_includes_direct_routes(config.proxy_mode) { if let Ok(webdav) = self.client.webdav_config(&endpoints, &credential).await { if let Ok(url) = FnosClient::webdav_file_url(&webdav, path) { let headers = webdav_headers(&credential); @@ -2234,7 +2242,7 @@ impl MediaProvider for FnosProvider { }, ))), }; - if config.proxy_mode == crate::models::PlaybackProxyMode::Prefer { + if super::playback_proxy_mode_includes_direct_routes(config.proxy_mode) { let webdav = match self .credential_with_repo(repo, owner, &config.server_id) .await @@ -2253,9 +2261,12 @@ impl MediaProvider for FnosProvider { .get("direct") .cloned() .map(|mut info| { + let default_media_index = info.default_media_index; let medias = std::mem::take(&mut info.medias) .into_iter() - .filter_map(|mut media| { + .enumerate() + .filter_map(|media| { + let (source_index, mut media) = media; let direct = match &media.provider { PlaybackMediaProvider::Fnos( PlaybackFnosMedia::MediaOriginalRefresh { @@ -2286,9 +2297,13 @@ impl MediaProvider for FnosProvider { headers: direct.1, }, ); - Some(media) + Some((source_index, media)) }) .collect::>(); + info.default_media_index = + remap_filtered_default_index(&medias, default_media_index); + let medias: Vec = + medias.into_iter().map(|(_, media)| media).collect(); (medias, info) }); if let Some((medias, info)) = direct_medias { @@ -2321,6 +2336,7 @@ impl MediaProvider for FnosProvider { || async { Ok(result) }, ) .await?; + let result = super::require_direct_playback_route(result, config.proxy_mode)?; let media = result .metadata @@ -2836,6 +2852,7 @@ impl DynamicPlaylistProvider for FnosProvider { items, pagination: DynamicPagination::Page { page }, has_more, + supports_search: true, }); } let mut items = client @@ -2977,6 +2994,7 @@ impl DynamicPlaylistProvider for FnosProvider { items, pagination: DynamicPagination::Page { page }, has_more, + supports_search: true, }) } @@ -3418,4 +3436,16 @@ mod tests { }) if version == "version" && headers.get("Authorization").is_some() )); } + + #[test] + fn filtered_direct_media_remaps_the_default_index() { + assert_eq!( + remap_filtered_default_index(&[(0, ()), (2, ())], Some(2)), + Some(1) + ); + assert_eq!( + remap_filtered_default_index(&[(0, ()), (2, ())], Some(1)), + Some(0) + ); + } } diff --git a/synctv-core/src/provider/mod.rs b/synctv-core/src/provider/mod.rs index 0e383fe0..855104f0 100644 --- a/synctv-core/src/provider/mod.rs +++ b/synctv-core/src/provider/mod.rs @@ -141,41 +141,113 @@ pub(crate) fn apply_provider_playback_policy( .unwrap_or(&result.default_mode) .to_string(); let proxy_default = format!("proxy_{base_default}"); - let has_proxy_default = result - .playback_infos - .get(&proxy_default) - .is_some_and(|info| !info.medias.is_empty()); + let preferred_proxy = route_if_available(result, &proxy_default); + let preferred_direct = direct_route(result, &base_default); match proxy_mode { crate::models::PlaybackProxyMode::Auto if default_proxy => { result.playback_infos.retain(|mode_name, info| { mode_name.starts_with("proxy_") && !info.medias.is_empty() }); - if has_proxy_default { - result.default_mode = proxy_default; + if let Some(mode_name) = preferred_proxy { + result.default_mode = mode_name; } } crate::models::PlaybackProxyMode::Prefer => { - if has_proxy_default { - result.default_mode = proxy_default; + if let Some(mode_name) = preferred_proxy { + result.default_mode = mode_name; } } crate::models::PlaybackProxyMode::Only => { result.playback_infos.retain(|mode_name, info| { mode_name.starts_with("proxy_") && !info.medias.is_empty() }); - if has_proxy_default { - result.default_mode = proxy_default; - } else { - let mut remaining = result.playback_infos.keys().cloned().collect::>(); - remaining.sort(); - result.default_mode = remaining.into_iter().next().unwrap_or_default(); - } + result.default_mode = preferred_proxy + .or_else(|| first_available_proxy_route(result)) + .unwrap_or_default(); + } + crate::models::PlaybackProxyMode::DirectPrefer => { + result.default_mode = preferred_direct + .or(preferred_proxy) + .or_else(|| first_available_proxy_route(result)) + .unwrap_or_default(); + } + crate::models::PlaybackProxyMode::DirectOnly => { + result.playback_infos.retain(|mode_name, info| { + !mode_name.starts_with("proxy_") && !info.medias.is_empty() + }); + result.default_mode = preferred_direct + .or_else(|| first_available_direct_route(result)) + .unwrap_or_default(); } crate::models::PlaybackProxyMode::Auto => {} } } +#[must_use] +pub(crate) const fn playback_proxy_mode_includes_direct_routes( + proxy_mode: crate::models::PlaybackProxyMode, +) -> bool { + matches!( + proxy_mode, + crate::models::PlaybackProxyMode::Prefer + | crate::models::PlaybackProxyMode::DirectPrefer + | crate::models::PlaybackProxyMode::DirectOnly + ) +} + +pub(crate) fn require_direct_playback_route( + result: PlaybackResult, + proxy_mode: crate::models::PlaybackProxyMode, +) -> std::result::Result { + if matches!(proxy_mode, crate::models::PlaybackProxyMode::DirectOnly) + && result.playback_infos.is_empty() + { + return Err(ProviderError::UnsupportedFormat( + "This media source cannot provide a direct playback route".to_string(), + )); + } + Ok(result) +} + +fn route_if_available(result: &PlaybackResult, mode_name: &str) -> Option { + result + .playback_infos + .get(mode_name) + .filter(|info| !info.medias.is_empty()) + .map(|_| mode_name.to_string()) +} + +fn direct_route(result: &PlaybackResult, preferred: &str) -> Option { + if preferred.starts_with("proxy_") { + first_available_direct_route(result) + } else { + route_if_available(result, preferred).or_else(|| first_available_direct_route(result)) + } +} + +fn first_available_direct_route(result: &PlaybackResult) -> Option { + first_available_route(result, |mode_name| !mode_name.starts_with("proxy_")) +} + +fn first_available_proxy_route(result: &PlaybackResult) -> Option { + first_available_route(result, |mode_name| mode_name.starts_with("proxy_")) +} + +fn first_available_route( + result: &PlaybackResult, + include_mode: impl Fn(&str) -> bool, +) -> Option { + let mut modes = result + .playback_infos + .iter() + .filter(|(mode_name, info)| include_mode(mode_name) && !info.medias.is_empty()) + .map(|(mode_name, _)| mode_name.clone()) + .collect::>(); + modes.sort(); + modes.into_iter().next() +} + pub(crate) fn playback_session_registration( ctx: &ProviderContext<'_>, resource_key: String, @@ -1482,6 +1554,45 @@ mod playback_policy_tests { ); } + #[test] + fn direct_prefer_keeps_both_routes_and_selects_direct() { + let mut result = playback_result(); + + apply_provider_playback_policy(&mut result, PlaybackProxyMode::DirectPrefer, true); + + assert_eq!(result.default_mode, "direct"); + assert!(result.playback_infos.contains_key("direct")); + assert!(result.playback_infos.contains_key("proxy_direct")); + } + + #[test] + fn direct_prefer_falls_back_to_proxy_when_direct_is_unavailable() { + let mut result = playback_result(); + result + .playback_infos + .get_mut("direct") + .expect("direct route should exist in the playback fixture") + .medias + .clear(); + + apply_provider_playback_policy(&mut result, PlaybackProxyMode::DirectPrefer, true); + + assert_eq!(result.default_mode, "proxy_direct"); + } + + #[test] + fn direct_only_filters_proxy_routes() { + let mut result = playback_result(); + + apply_provider_playback_policy(&mut result, PlaybackProxyMode::DirectOnly, true); + + assert_eq!(result.default_mode, "direct"); + assert_eq!( + result.playback_infos.keys().cloned().collect::>(), + vec!["direct"] + ); + } + #[test] fn auto_keeps_direct_route_for_public_url_sources() { let mut result = playback_result(); @@ -1491,6 +1602,42 @@ mod playback_policy_tests { assert_eq!(result.default_mode, "direct"); assert_eq!(result.playback_infos.len(), 2); } + + #[test] + fn direct_route_modes_include_direct_urls() { + assert!(playback_proxy_mode_includes_direct_routes( + PlaybackProxyMode::Prefer + )); + assert!(playback_proxy_mode_includes_direct_routes( + PlaybackProxyMode::DirectPrefer + )); + assert!(playback_proxy_mode_includes_direct_routes( + PlaybackProxyMode::DirectOnly + )); + assert!(!playback_proxy_mode_includes_direct_routes( + PlaybackProxyMode::Auto + )); + assert!(!playback_proxy_mode_includes_direct_routes( + PlaybackProxyMode::Only + )); + } + + #[test] + fn direct_only_requires_an_available_direct_route() { + let result = PlaybackResult { + playback_infos: HashMap::new(), + default_mode: String::new(), + provider: crate::models::SourceProvider::DirectUrl, + provider_instance_name: None, + duration_seconds: None, + playback_kind: None, + metadata: None, + }; + + let error = require_direct_playback_route(result, PlaybackProxyMode::DirectOnly) + .expect_err("direct-only playback should require a direct route"); + assert!(matches!(error, ProviderError::UnsupportedFormat(_))); + } } #[cfg(test)] diff --git a/synctv-core/src/provider/nextcloud.rs b/synctv-core/src/provider/nextcloud.rs index 2fbe92fe..c92fc821 100644 --- a/synctv-core/src/provider/nextcloud.rs +++ b/synctv-core/src/provider/nextcloud.rs @@ -719,7 +719,7 @@ impl MediaProvider for NextcloudProvider { default_danmaku_index: None, }, ); - if config.proxy_mode == crate::models::PlaybackProxyMode::Prefer { + if super::playback_proxy_mode_includes_direct_routes(config.proxy_mode) { let mut direct = playback_infos.get("original").cloned().ok_or_else(|| { ProviderError::Internal("Nextcloud playback mode missing".to_string()) })?; @@ -748,7 +748,7 @@ impl MediaProvider for NextcloudProvider { playback_kind: Some(crate::models::PlaybackKind::Regular), metadata: Some(PlaybackMetadata::Nextcloud(metadata)), }; - super::cached_versioned_playback_or_fill( + let result = super::cached_versioned_playback_or_fill( Self::NAME, &format!( "playback:{owner}:{}:room:{}:{}:proxy:{}", @@ -766,7 +766,8 @@ impl MediaProvider for NextcloudProvider { }, || async { Ok(result) }, ) - .await + .await?; + super::require_direct_playback_route(result, config.proxy_mode) } async fn validate_source_config( @@ -960,6 +961,7 @@ impl DynamicPlaylistProvider for NextcloudProvider { items, pagination: DynamicPagination::Page { page }, has_more, + supports_search: true, }) } diff --git a/synctv-core/src/provider/playback_transport.rs b/synctv-core/src/provider/playback_transport.rs index d4bd155c..781df434 100644 --- a/synctv-core/src/provider/playback_transport.rs +++ b/synctv-core/src/provider/playback_transport.rs @@ -68,6 +68,11 @@ pub enum PlaybackTransportAction { MpdBodyRewrite { body: Vec, source_url: String }, /// Rewrite an already generated M3U8 body through the normal signed segment pipeline. M3u8BodyRewrite { body: Vec }, + /// Return a generated M3U8 body without rewriting its segment URLs. + /// + /// This keeps media bytes on the client-to-upstream path while the signed + /// manifest endpoint controls access to the generated playlist. + M3u8DirectBody { body: Vec }, /// Return a direct response body with a content type. /// /// Used for provider-specific responses that do not involve upstream diff --git a/synctv-core/src/provider/qnap.rs b/synctv-core/src/provider/qnap.rs index af806706..d2957a53 100644 --- a/synctv-core/src/provider/qnap.rs +++ b/synctv-core/src/provider/qnap.rs @@ -869,7 +869,7 @@ impl MediaProvider for QnapProvider { info.default_media_index = info.medias.len().checked_sub(1); } } - if config.proxy_mode == crate::models::PlaybackProxyMode::Prefer { + if super::playback_proxy_mode_includes_direct_routes(config.proxy_mode) { let original_modes = playback_infos .iter() .map(|(name, info)| (name.clone(), info.clone())) @@ -918,7 +918,7 @@ impl MediaProvider for QnapProvider { realtime_heights: Vec::new(), })), }; - super::cached_versioned_playback_or_fill( + let result = super::cached_versioned_playback_or_fill( Self::NAME, &format!( "playback:{owner}:{}:room:{}:{}:profile:{}:proxy:{}", @@ -937,7 +937,8 @@ impl MediaProvider for QnapProvider { }, || async { Ok(result) }, ) - .await + .await?; + super::require_direct_playback_route(result, config.proxy_mode) } async fn validate_source_config( @@ -1099,6 +1100,7 @@ impl DynamicPlaylistProvider for QnapProvider { page: response.page, }, has_more: response.has_more, + supports_search: true, }) } diff --git a/synctv-core/src/provider/seafile.rs b/synctv-core/src/provider/seafile.rs index 6587cf76..3aa45716 100644 --- a/synctv-core/src/provider/seafile.rs +++ b/synctv-core/src/provider/seafile.rs @@ -151,7 +151,7 @@ impl MediaProvider for SeafileProvider { metadata: Some(PlaybackMetadata::Seafile(metadata)), }; let mut result = result; - if config.proxy_mode == crate::models::PlaybackProxyMode::Prefer { + if super::playback_proxy_mode_includes_direct_routes(config.proxy_mode) { let url = auth .client .download_url(&auth.token, &config.repository_id, &config.path) @@ -184,7 +184,7 @@ impl MediaProvider for SeafileProvider { ); } } - super::cached_versioned_playback_or_fill( + let result = super::cached_versioned_playback_or_fill( Self::NAME, &format!( "playback:{owner}:{}:{}:room:{}:{}:proxy:{}", @@ -203,7 +203,8 @@ impl MediaProvider for SeafileProvider { }, || async { Ok(result) }, ) - .await + .await?; + super::require_direct_playback_route(result, config.proxy_mode) } async fn validate_source_config( @@ -401,6 +402,11 @@ impl DynamicPlaylistProvider for SeafileProvider { items, pagination: DynamicPagination::Page { page }, has_more, + supports_search: target.is_some() + || matches!( + &config.source, + SeafilePlaylistSource::Folder { .. } | SeafilePlaylistSource::Search { .. } + ), }) } diff --git a/synctv-core/src/provider/synology.rs b/synctv-core/src/provider/synology.rs index cfafd9dd..b72197a2 100644 --- a/synctv-core/src/provider/synology.rs +++ b/synctv-core/src/provider/synology.rs @@ -966,7 +966,7 @@ impl MediaProvider for SynologyProvider { .await } }?; - if config.proxy_mode == crate::models::PlaybackProxyMode::Prefer { + if super::playback_proxy_mode_includes_direct_routes(config.proxy_mode) { if let SynologyMediaSource::File { path } = &config.source { let direct_url = auth.client.download_url( required_api(&auth.apis, "SYNO.FileStation.Download")?, @@ -1010,6 +1010,7 @@ impl MediaProvider for SynologyProvider { || async { Ok(result) }, ) .await?; + let result = super::require_direct_playback_route(result, config.proxy_mode)?; if let SynologyMediaSource::LibraryItem { file_id, .. } = &config.source { let resource_version = result.playback_infos.values().find_map(|info| { @@ -1603,6 +1604,7 @@ async fn list_dynamic_page( items, pagination: DynamicPagination::Page { page }, has_more: offset.saturating_add(u64::from(limit)) < total, + supports_search: !matches!(&config.source, SynologyPlaylistSource::Files { .. }), }) } diff --git a/synctv-core/src/provider/tiktok.rs b/synctv-core/src/provider/tiktok.rs index 4975fd2f..c5114dac 100644 --- a/synctv-core/src/provider/tiktok.rs +++ b/synctv-core/src/provider/tiktok.rs @@ -1164,6 +1164,7 @@ impl DynamicPlaylistProvider for TikTokProvider { cursor: page.cursor, }, has_more: page.has_more, + supports_search: false, }) } diff --git a/synctv-core/src/provider/traits.rs b/synctv-core/src/provider/traits.rs index 94294f85..ef80f521 100644 --- a/synctv-core/src/provider/traits.rs +++ b/synctv-core/src/provider/traits.rs @@ -430,11 +430,29 @@ impl DynamicListQuery { } } +/// Determines whether a page-based upstream listing has another page. +/// +/// `returned_count` is measured before provider-specific filtering so a page +/// containing only unsupported entries continues to the next upstream page. +#[must_use] +pub fn dynamic_page_has_more( + total: usize, + page: usize, + page_size: usize, + returned_count: usize, +) -> bool { + let effective_page_size = if page_size == 0 { 1 } else { page_size }; + let page_start = page.saturating_sub(1).saturating_mul(effective_page_size); + page_start.saturating_add(returned_count) < total +} + #[derive(Debug, Clone, Default)] pub struct DynamicListResult { pub items: Vec, pub pagination: DynamicPagination, pub has_more: bool, + /// Whether this playlist scope supports the generic provider-side search query. + pub supports_search: bool, } impl std::ops::Deref for DynamicListResult { @@ -454,6 +472,22 @@ impl IntoIterator for DynamicListResult { } } +#[cfg(test)] +mod dynamic_pagination_tests { + use super::dynamic_page_has_more; + + #[test] + fn uses_the_upstream_page_size_before_filtering() { + assert!(dynamic_page_has_more(100, 1, 50, 50)); + assert!(!dynamic_page_has_more(100, 2, 50, 50)); + } + + #[test] + fn stops_on_the_last_short_upstream_page() { + assert!(!dynamic_page_has_more(75, 2, 50, 25)); + } +} + /// Media provider trait. /// /// Core interface that all providers must implement. The maintenance contract diff --git a/synctv-core/src/provider/truenas.rs b/synctv-core/src/provider/truenas.rs index 0280c3ca..859c2ad9 100644 --- a/synctv-core/src/provider/truenas.rs +++ b/synctv-core/src/provider/truenas.rs @@ -580,7 +580,7 @@ impl MediaProvider for TrueNasProvider { metadata: Some(PlaybackMetadata::TrueNas(metadata)), }; let mut result = result; - if config.proxy_mode == crate::models::PlaybackProxyMode::Prefer { + if super::playback_proxy_mode_includes_direct_routes(config.proxy_mode) { let ticket = auth .client .download_ticket(&auth.api_key, &config.path) @@ -612,7 +612,7 @@ impl MediaProvider for TrueNasProvider { ); } } - super::cached_versioned_playback_or_fill( + let result = super::cached_versioned_playback_or_fill( Self::NAME, &format!( "playback:{owner}:{}:room:{}:{}:proxy:{}", @@ -630,7 +630,8 @@ impl MediaProvider for TrueNasProvider { }, || async { Ok(result) }, ) - .await + .await?; + super::require_direct_playback_route(result, config.proxy_mode) } async fn validate_source_config( @@ -749,6 +750,8 @@ impl DynamicPlaylistProvider for TrueNasProvider { .collect(), pagination: DynamicPagination::Page { page }, has_more, + supports_search: target.is_some() + || matches!(&config.source, TrueNasPlaylistSource::Folder { .. }), }) } diff --git a/synctv-core/src/provider/twitch.rs b/synctv-core/src/provider/twitch.rs index 15cc6f3f..aba77591 100644 --- a/synctv-core/src/provider/twitch.rs +++ b/synctv-core/src/provider/twitch.rs @@ -1380,6 +1380,7 @@ impl DynamicPlaylistProvider for TwitchProvider { cursor: next_cursor, }, has_more, + supports_search: false, }) } diff --git a/synctv-core/src/provider/youtube.rs b/synctv-core/src/provider/youtube.rs index e33715ab..527534e2 100644 --- a/synctv-core/src/provider/youtube.rs +++ b/synctv-core/src/provider/youtube.rs @@ -1280,6 +1280,7 @@ impl DynamicPlaylistProvider for YoutubeProvider { cursor: page.next_cursor, }, has_more, + supports_search: false, }) } diff --git a/synctv-core/tests/playback_play_next_tests.rs b/synctv-core/tests/playback_play_next_tests.rs index 8559a6ec..343a4386 100644 --- a/synctv-core/tests/playback_play_next_tests.rs +++ b/synctv-core/tests/playback_play_next_tests.rs @@ -463,6 +463,7 @@ impl DynamicPlaylistProvider for TestDynamicProvider { has_more: false, items, pagination: DynamicPagination::Page { page: 1 }, + supports_search: false, }) } diff --git a/synctv-management/src/mapping/response.rs b/synctv-management/src/mapping/response.rs index 6b656b3e..dc147cfe 100644 --- a/synctv-management/src/mapping/response.rs +++ b/synctv-management/src/mapping/response.rs @@ -81,6 +81,12 @@ const fn playback_proxy_mode_to_proto(mode: synctv_core::models::PlaybackProxyMo synctv_core::models::PlaybackProxyMode::Only => { source_config_proto::PlaybackProxyMode::Only as i32 } + synctv_core::models::PlaybackProxyMode::DirectPrefer => { + source_config_proto::PlaybackProxyMode::DirectPrefer as i32 + } + synctv_core::models::PlaybackProxyMode::DirectOnly => { + source_config_proto::PlaybackProxyMode::DirectOnly as i32 + } } } @@ -575,17 +581,7 @@ fn media_source_config_to_proto( } }), duration_seconds: config.duration_seconds, - proxy_mode: match config.proxy_mode { - synctv_core::models::PlaybackProxyMode::Auto => { - source_config_proto::PlaybackProxyMode::Auto as i32 - } - synctv_core::models::PlaybackProxyMode::Prefer => { - source_config_proto::PlaybackProxyMode::Prefer as i32 - } - synctv_core::models::PlaybackProxyMode::Only => { - source_config_proto::PlaybackProxyMode::Only as i32 - } - }, + proxy_mode: playback_proxy_mode_to_proto(config.proxy_mode), medias: config .medias .into_iter() @@ -1298,3 +1294,22 @@ pub(crate) fn created_media_to_client_proto( thumbnail: None, }) } + +#[cfg(test)] +mod tests { + use super::playback_proxy_mode_to_proto; + use synctv_core::models::PlaybackProxyMode; + use synctv_proto::source_config::PlaybackProxyMode as ProtoPlaybackProxyMode; + + #[test] + fn maps_direct_playback_proxy_modes() { + assert_eq!( + playback_proxy_mode_to_proto(PlaybackProxyMode::DirectPrefer), + ProtoPlaybackProxyMode::DirectPrefer as i32, + ); + assert_eq!( + playback_proxy_mode_to_proto(PlaybackProxyMode::DirectOnly), + ProtoPlaybackProxyMode::DirectOnly as i32, + ); + } +} diff --git a/synctv-proto/proto/client.proto b/synctv-proto/proto/client.proto index 6f568ccd..a9241aff 100644 --- a/synctv-proto/proto/client.proto +++ b/synctv-proto/proto/client.proto @@ -2271,6 +2271,9 @@ message ListPlaylistItemsResponse { PagePagination page = 9; CursorPagination cursor = 10; // Cursor for the next page; empty at the end. } + // Whether the current playlist scope accepts the request search field. + // Static playlists always support search; dynamic playlists report the provider capability. + bool supports_search = 11; } enum ItemType { diff --git a/synctv-proto/proto/source_config.proto b/synctv-proto/proto/source_config.proto index de0a9938..c8a8a874 100644 --- a/synctv-proto/proto/source_config.proto +++ b/synctv-proto/proto/source_config.proto @@ -39,6 +39,8 @@ enum PlaybackProxyMode { PLAYBACK_PROXY_MODE_AUTO = 0; PLAYBACK_PROXY_MODE_PREFER = 1; PLAYBACK_PROXY_MODE_ONLY = 2; + PLAYBACK_PROXY_MODE_DIRECT_PREFER = 3; + PLAYBACK_PROXY_MODE_DIRECT_ONLY = 4; } message DirectUrlMediaResourceConfig { diff --git a/synctv/src/cli/human_output.rs b/synctv/src/cli/human_output.rs index 15163c99..c8cfff6d 100644 --- a/synctv/src/cli/human_output.rs +++ b/synctv/src/cli/human_output.rs @@ -857,6 +857,7 @@ pub(in crate::cli) struct HumanPlaylistItemsResponse { dynamic_items: Vec, current_path: Vec, version: String, + supports_search: bool, } #[derive(Debug, Clone, Serialize)] @@ -1954,6 +1955,7 @@ impl ToHuman for synctv_proto::client::ListPlaylistItemsResponse { dynamic_items: self.dynamic_items.clone(), current_path: self.current_path.clone(), version: self.version.clone(), + supports_search: self.supports_search, } } } diff --git a/synctv/src/cli/tests.rs b/synctv/src/cli/tests.rs index eed24612..0328201c 100644 --- a/synctv/src/cli/tests.rs +++ b/synctv/src/cli/tests.rs @@ -5924,10 +5924,12 @@ fn render_human_output_includes_playlist_items_snapshot_version() { current_path: Vec::new(), version: "items-v42".into(), pagination: None, + supports_search: true, }) .expect("playlist items human output should render"); assert_eq!(rendered["version"], "items-v42"); + assert_eq!(rendered["supportsSearch"], true); assert_eq!(rendered["playlists"][0]["version"], 10); assert_eq!(rendered["media"][0]["version"], 11); }