feat(provider): improve media discovery and direct playback (#411)

pull/414/head
zijiren 1 month ago committed by GitHub
parent 2f61d0e8de
commit dd80379d67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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,
);
}
}

@ -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)?;

@ -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();

@ -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))?;

@ -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(),
});

@ -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(())
}
}

@ -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)
}
}
}

@ -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());
}

@ -353,6 +353,7 @@ impl DynamicPlaylistProvider for StubDynamicProvider {
has_more: false,
items,
pagination: synctv_core::provider::DynamicPagination::Page { page: 1 },
supports_search: false,
})
}

@ -1228,6 +1228,18 @@ pub enum PlaybackBilibiliMedia {
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
headers: std::collections::HashMap<String, String>,
},
/// 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<BilibiliDurlSegment>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
headers: std::collections::HashMap<String, String>,
},
/// 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, .. },

@ -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",
}
}
}

@ -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<DynamicPlaylistItem> = 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<DynamicPlaylistItem>, 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::<Result<Vec<_>, 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::<Result<Vec<_>, 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::<Result<Vec<_>, ProviderError>>()?
};
.collect::<Result<Vec<_>, 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;

@ -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<sha2::Sha256>;
#[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(),

@ -1270,6 +1270,7 @@ impl DynamicPlaylistProvider for CloudreveProvider {
items,
pagination: pagination_result,
has_more,
supports_search: true,
})
}

@ -1145,6 +1145,7 @@ impl DynamicPlaylistProvider for DouyinProvider {
cursor: page.cursor,
},
has_more: page.has_more,
supports_search: false,
})
}

@ -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::<Result<Vec<_>, 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;

@ -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}")]

@ -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<T>(
resources: &[(usize, T)],
default_index: Option<usize>,
) -> Option<usize> {
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::<Vec<_>>();
info.default_media_index =
remap_filtered_default_index(&medias, default_media_index);
let medias: Vec<PlaybackMedia> =
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)
);
}
}

@ -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::<Vec<_>>();
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<PlaybackResult, ProviderError> {
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<String> {
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<String> {
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<String> {
first_available_route(result, |mode_name| !mode_name.starts_with("proxy_"))
}
fn first_available_proxy_route(result: &PlaybackResult) -> Option<String> {
first_available_route(result, |mode_name| mode_name.starts_with("proxy_"))
}
fn first_available_route(
result: &PlaybackResult,
include_mode: impl Fn(&str) -> bool,
) -> Option<String> {
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::<Vec<_>>();
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<_>>(),
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)]

@ -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,
})
}

@ -68,6 +68,11 @@ pub enum PlaybackTransportAction {
MpdBodyRewrite { body: Vec<u8>, source_url: String },
/// Rewrite an already generated M3U8 body through the normal signed segment pipeline.
M3u8BodyRewrite { body: Vec<u8> },
/// 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<u8> },
/// Return a direct response body with a content type.
///
/// Used for provider-specific responses that do not involve upstream

@ -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,
})
}

@ -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 { .. }
),
})
}

@ -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 { .. }),
})
}

@ -1164,6 +1164,7 @@ impl DynamicPlaylistProvider for TikTokProvider {
cursor: page.cursor,
},
has_more: page.has_more,
supports_search: false,
})
}

@ -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<DynamicPlaylistItem>,
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

@ -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 { .. }),
})
}

@ -1380,6 +1380,7 @@ impl DynamicPlaylistProvider for TwitchProvider {
cursor: next_cursor,
},
has_more,
supports_search: false,
})
}

@ -1280,6 +1280,7 @@ impl DynamicPlaylistProvider for YoutubeProvider {
cursor: page.next_cursor,
},
has_more,
supports_search: false,
})
}

@ -463,6 +463,7 @@ impl DynamicPlaylistProvider for TestDynamicProvider {
has_more: false,
items,
pagination: DynamicPagination::Page { page: 1 },
supports_search: false,
})
}

@ -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,
);
}
}

@ -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 {

@ -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 {

@ -857,6 +857,7 @@ pub(in crate::cli) struct HumanPlaylistItemsResponse<P, M> {
dynamic_items: Vec<synctv_proto::client::PlaylistItem>,
current_path: Vec<synctv_proto::client::PlaylistBrowsePathNode>,
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,
}
}
}

@ -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);
}

Loading…
Cancel
Save