From 355b7790b6f74998e17f9de7bc1258c0e89673ce Mon Sep 17 00:00:00 2001 From: zijiren <84728412+zijiren233@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:18:47 +0800 Subject: [PATCH] fix: bilibili dash codec filter (#459) --- synctv-api-common/src/impls/client/convert.rs | 246 ++-- synctv-api-http/src/http/room/query.rs | 165 +-- synctv-api-http/src/http/room/tests.rs | 176 +-- synctv-core/src/models/media.rs | 72 +- synctv-core/src/models/mod.rs | 54 +- synctv-core/src/provider/bilibili.rs | 1209 ++++++++++++----- synctv-core/src/provider/mod.rs | 76 +- synctv-core/src/provider/playback_profile.rs | 163 ++- synctv-media-providers/src/bilibili/client.rs | 130 +- .../src/bilibili/client_tests.rs | 113 ++ synctv-proto/proto/client.proto | 3 +- 11 files changed, 1388 insertions(+), 1019 deletions(-) diff --git a/synctv-api-common/src/impls/client/convert.rs b/synctv-api-common/src/impls/client/convert.rs index e307f175..376b6757 100644 --- a/synctv-api-common/src/impls/client/convert.rs +++ b/synctv-api-common/src/impls/client/convert.rs @@ -2256,9 +2256,7 @@ pub fn playback_client_profile_from_proto( return Ok(None); }; - let default_profile = synctv_core::provider::PlaybackClientProfile::default(); - let uses_legacy_defaults = profile.profile_version == 0; - if !matches!(profile.profile_version, 0 | 2) { + if profile.profile_version != synctv_core::provider::CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION { return Err(crate::impls::ApiError::InvalidInput(format!( "Unsupported playback client profile version {}", profile.profile_version @@ -2272,14 +2270,9 @@ pub fn playback_client_profile_from_proto( "Unsupported playback client environment".to_string(), ) })? { - synctv_proto::client::PlaybackClientEnvironment::Unspecified - if uses_legacy_defaults => - { - default_profile.environment - } synctv_proto::client::PlaybackClientEnvironment::Unspecified => { return Err(crate::impls::ApiError::InvalidInput( - "Playback client environment is required for profile version 2".to_string(), + "Playback client environment is required".to_string(), )); } synctv_proto::client::PlaybackClientEnvironment::Native => { @@ -2308,66 +2301,56 @@ pub fn playback_client_profile_from_proto( } }; - let supported_video_codecs = if uses_legacy_defaults - && profile.supported_video_codecs.is_empty() - { - default_profile.supported_video_codecs.clone() - } else { - profile - .supported_video_codecs - .iter() - .filter_map(|codec| { - Some( - match synctv_proto::client::PlaybackVideoCodec::try_from(*codec) { - Ok(synctv_proto::client::PlaybackVideoCodec::Unspecified) => return None, - Ok(synctv_proto::client::PlaybackVideoCodec::H264) => { - Ok(synctv_core::provider::PlaybackVideoCodec::H264) - } - Ok(synctv_proto::client::PlaybackVideoCodec::Hevc) => { - Ok(synctv_core::provider::PlaybackVideoCodec::Hevc) - } - Ok(synctv_proto::client::PlaybackVideoCodec::Vp9) => { - Ok(synctv_core::provider::PlaybackVideoCodec::Vp9) - } - Ok(synctv_proto::client::PlaybackVideoCodec::Av1) => { - Ok(synctv_core::provider::PlaybackVideoCodec::Av1) - } - Err(_) => Err(crate::impls::ApiError::InvalidInput( - "Unsupported playback video codec".to_string(), - )), - }, - ) - }) - .collect::, _>>()? - }; + let supported_video_codecs = profile + .supported_video_codecs + .iter() + .filter_map(|codec| { + Some( + match synctv_proto::client::PlaybackVideoCodec::try_from(*codec) { + Ok(synctv_proto::client::PlaybackVideoCodec::Unspecified) => return None, + Ok(synctv_proto::client::PlaybackVideoCodec::H264) => { + Ok(synctv_core::provider::PlaybackVideoCodec::H264) + } + Ok(synctv_proto::client::PlaybackVideoCodec::Hevc) => { + Ok(synctv_core::provider::PlaybackVideoCodec::Hevc) + } + Ok(synctv_proto::client::PlaybackVideoCodec::Vp9) => { + Ok(synctv_core::provider::PlaybackVideoCodec::Vp9) + } + Ok(synctv_proto::client::PlaybackVideoCodec::Av1) => { + Ok(synctv_core::provider::PlaybackVideoCodec::Av1) + } + Err(_) => Err(crate::impls::ApiError::InvalidInput( + "Unsupported playback video codec".to_string(), + )), + }, + ) + }) + .collect::, _>>()?; - let supported_containers = if uses_legacy_defaults && profile.supported_containers.is_empty() { - default_profile.supported_containers.clone() - } else { - profile - .supported_containers - .iter() - .filter_map(|container| { - Some( - match synctv_proto::client::PlaybackContainer::try_from(*container) { - Ok(synctv_proto::client::PlaybackContainer::Unspecified) => return None, - Ok(synctv_proto::client::PlaybackContainer::Mp4) => { - Ok(synctv_core::provider::PlaybackContainer::Mp4) - } - Ok(synctv_proto::client::PlaybackContainer::Mkv) => { - Ok(synctv_core::provider::PlaybackContainer::Mkv) - } - Ok(synctv_proto::client::PlaybackContainer::Webm) => { - Ok(synctv_core::provider::PlaybackContainer::Webm) - } - Err(_) => Err(crate::impls::ApiError::InvalidInput( - "Unsupported playback container".to_string(), - )), - }, - ) - }) - .collect::, _>>()? - }; + let supported_containers = profile + .supported_containers + .iter() + .filter_map(|container| { + Some( + match synctv_proto::client::PlaybackContainer::try_from(*container) { + Ok(synctv_proto::client::PlaybackContainer::Unspecified) => return None, + Ok(synctv_proto::client::PlaybackContainer::Mp4) => { + Ok(synctv_core::provider::PlaybackContainer::Mp4) + } + Ok(synctv_proto::client::PlaybackContainer::Mkv) => { + Ok(synctv_core::provider::PlaybackContainer::Mkv) + } + Ok(synctv_proto::client::PlaybackContainer::Webm) => { + Ok(synctv_core::provider::PlaybackContainer::Webm) + } + Err(_) => Err(crate::impls::ApiError::InvalidInput( + "Unsupported playback container".to_string(), + )), + }, + ) + }) + .collect::, _>>()?; let audio_capability = match synctv_proto::client::PlaybackAudioCapability::try_from(profile.audio_capability) @@ -2376,12 +2359,9 @@ pub fn playback_client_profile_from_proto( "Unsupported playback audio capability".to_string(), ) })? { - synctv_proto::client::PlaybackAudioCapability::Unspecified if uses_legacy_defaults => { - default_profile.audio_capability - } synctv_proto::client::PlaybackAudioCapability::Unspecified => { return Err(crate::impls::ApiError::InvalidInput( - "Playback audio capability is required for profile version 2".to_string(), + "Playback audio capability is required".to_string(), )); } synctv_proto::client::PlaybackAudioCapability::Stereo => { @@ -2413,36 +2393,29 @@ pub fn playback_client_profile_from_proto( } }; - let supported_live_transports = - if uses_legacy_defaults && profile.supported_live_transports.is_empty() { - default_profile.supported_live_transports.clone() - } else { - profile - .supported_live_transports - .iter() - .filter_map(|transport| { - Some( - match synctv_proto::client::PlaybackLiveTransport::try_from(*transport) { - Ok(synctv_proto::client::PlaybackLiveTransport::Unspecified) => { - return None - } - Ok(synctv_proto::client::PlaybackLiveTransport::Hls) => { - Ok(synctv_core::provider::PlaybackLiveTransport::Hls) - } - Ok(synctv_proto::client::PlaybackLiveTransport::Flv) => { - Ok(synctv_core::provider::PlaybackLiveTransport::Flv) - } - Ok(synctv_proto::client::PlaybackLiveTransport::Whep) => { - Ok(synctv_core::provider::PlaybackLiveTransport::Whep) - } - Err(_) => Err(crate::impls::ApiError::InvalidInput( - "Unsupported playback live transport".to_string(), - )), - }, - ) - }) - .collect::, _>>()? - }; + let supported_live_transports = profile + .supported_live_transports + .iter() + .filter_map(|transport| { + Some( + match synctv_proto::client::PlaybackLiveTransport::try_from(*transport) { + Ok(synctv_proto::client::PlaybackLiveTransport::Unspecified) => return None, + Ok(synctv_proto::client::PlaybackLiveTransport::Hls) => { + Ok(synctv_core::provider::PlaybackLiveTransport::Hls) + } + Ok(synctv_proto::client::PlaybackLiveTransport::Flv) => { + Ok(synctv_core::provider::PlaybackLiveTransport::Flv) + } + Ok(synctv_proto::client::PlaybackLiveTransport::Whep) => { + Ok(synctv_core::provider::PlaybackLiveTransport::Whep) + } + Err(_) => Err(crate::impls::ApiError::InvalidInput( + "Unsupported playback live transport".to_string(), + )), + }, + ) + }) + .collect::, _>>()?; let media_capabilities = profile .media_capabilities @@ -2611,34 +2584,16 @@ pub fn playback_client_profile_from_proto( environment, stream_preference, max_streaming_bitrate: profile.max_streaming_bitrate, - max_audio_channels: if uses_legacy_defaults { - profile - .max_audio_channels - .or(default_profile.max_audio_channels) - } else { - profile.max_audio_channels - }, + max_audio_channels: profile.max_audio_channels, supported_video_codecs, supported_containers, audio_capability, subtitle_preference, supported_live_transports, media_capabilities, - supports_custom_http_headers: if uses_legacy_defaults { - default_profile.supports_custom_http_headers - } else { - profile.supports_custom_http_headers - }, - supports_provider_proxy: if uses_legacy_defaults { - default_profile.supports_provider_proxy - } else { - profile.supports_provider_proxy - }, - supports_insecure_http_media: if uses_legacy_defaults { - default_profile.supports_insecure_http_media - } else { - profile.supports_insecure_http_media - }, + supports_custom_http_headers: profile.supports_custom_http_headers, + supports_provider_proxy: profile.supports_provider_proxy, + supports_insecure_http_media: profile.supports_insecure_http_media, })) } @@ -2661,34 +2616,15 @@ mod playback_client_profile_conversion_tests { } #[test] - fn v0_empty_capability_lists_restore_legacy_defaults() { - let converted = + fn obsolete_profile_version_is_rejected() { + let error = playback_client_profile_from_proto(Some(&proto::PlaybackClientProfile::default())) - .expect("legacy profile should convert") - .expect("profile should be present"); - let defaults = synctv_core::provider::PlaybackClientProfile::default(); - - assert_eq!(converted.environment, defaults.environment); - assert_eq!( - converted.supported_video_codecs, - defaults.supported_video_codecs - ); - assert_eq!( - converted.supported_containers, - defaults.supported_containers - ); - assert_eq!( - converted.supported_live_transports, - defaults.supported_live_transports - ); - assert_eq!( - converted.supports_custom_http_headers, - defaults.supports_custom_http_headers - ); - assert_eq!( - converted.supports_provider_proxy, - defaults.supports_provider_proxy - ); + .expect_err("obsolete profiles must fail closed"); + assert!(matches!( + error, + crate::impls::ApiError::InvalidInput(message) + if message.contains("Unsupported playback client profile version 0") + )); } #[test] @@ -5638,7 +5574,7 @@ mod playback_conversion_tests { PlaybackBilibiliMedia::DirectDashManifest { version: "v1".to_string(), expires_at: synctv_core::SystemClock.now().timestamp() + 1800, - mode_name: "h264".to_string(), + mode_name: "dash".to_string(), headers: headers.clone(), }, ), @@ -5646,15 +5582,15 @@ mod playback_conversion_tests { .build(); let proto = try_playback_to_proto( - &playback_result_with_mode("h264", info), + &playback_result_with_mode("dash", info), &codec(), Some(&signing), ) .expect("playback should convert"); - let media = &proto.playback_infos["h264"].medias[0]; + let media = &proto.playback_infos["dash"].medias[0]; assert!( media.url.starts_with( - "/api/playback-providers/room-1/bilibili/v1/dash-manifests/h264/direct?" + "/api/playback-providers/room-1/bilibili/v1/dash-manifests/dash/direct?" ), "unexpected direct DASH URL: {}", media.url diff --git a/synctv-api-http/src/http/room/query.rs b/synctv-api-http/src/http/room/query.rs index 196a5bc0..17c46d64 100644 --- a/synctv-api-http/src/http/room/query.rs +++ b/synctv-api-http/src/http/room/query.rs @@ -25,17 +25,8 @@ pub(super) fn validate_include_message_types(values: Vec) -> AppResult, - pub stream_preference: Option, - pub max_streaming_bitrate: Option, - pub max_audio_channels: Option, - pub video_codecs: Option, - pub containers: Option, - pub live_transports: Option, - pub audio_capability: Option, - pub subtitle_preference: Option, } #[derive(Debug, Default, serde::Deserialize)] @@ -81,14 +72,6 @@ pub struct WatchPlaybackQuery { pub delivery_mode: Option, pub format: Option, pub client_profile: Option, - pub stream_preference: Option, - pub max_streaming_bitrate: Option, - pub max_audio_channels: Option, - pub video_codecs: Option, - pub containers: Option, - pub live_transports: Option, - pub audio_capability: Option, - pub subtitle_preference: Option, } pub(crate) fn parse_watch_delivery_mode(value: Option) -> AppResult { @@ -137,100 +120,6 @@ pub(crate) fn watch_after_event_sequence( } } -fn parse_stream_preference( - value: Option, -) -> Result { - value - .map(synctv_proto::client::PlaybackStreamPreference::try_from) - .transpose() - .map(|value| value.unwrap_or(synctv_proto::client::PlaybackStreamPreference::Unspecified)) - .map_err(|_| super::super::AppError::bad_request("Invalid streamPreference enum integer")) -} - -fn parse_subtitle_preference( - value: Option, -) -> Result { - value - .map(synctv_proto::client::PlaybackSubtitlePreference::try_from) - .transpose() - .map(|value| value.unwrap_or(synctv_proto::client::PlaybackSubtitlePreference::Unspecified)) - .map_err(|_| super::super::AppError::bad_request("Invalid subtitlePreference enum integer")) -} - -fn parse_video_codecs(value: Option<&str>) -> Result, super::super::AppError> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(Vec::new()); - }; - - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|codec| { - let value = codec.parse::().map_err(|_| { - super::super::AppError::bad_request("Invalid videoCodecs enum integer") - })?; - synctv_proto::client::PlaybackVideoCodec::try_from(value) - .map(|_| value) - .map_err(|_| { - super::super::AppError::bad_request("Invalid videoCodecs enum integer") - }) - }) - .collect() -} - -fn parse_containers(value: Option<&str>) -> Result, super::super::AppError> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(Vec::new()); - }; - - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|container| { - let value = container.parse::().map_err(|_| { - super::super::AppError::bad_request("Invalid containers enum integer") - })?; - synctv_proto::client::PlaybackContainer::try_from(value) - .map(|_| value) - .map_err(|_| super::super::AppError::bad_request("Invalid containers enum integer")) - }) - .collect() -} - -fn parse_live_transports(value: Option<&str>) -> Result, super::super::AppError> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(Vec::new()); - }; - - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|transport| { - let value = transport.parse::().map_err(|_| { - super::super::AppError::bad_request("Invalid liveTransports enum integer") - })?; - synctv_proto::client::PlaybackLiveTransport::try_from(value) - .map(|_| value) - .map_err(|_| { - super::super::AppError::bad_request("Invalid liveTransports enum integer") - }) - }) - .collect() -} - -fn parse_audio_capability( - value: Option, -) -> Result { - value - .map(synctv_proto::client::PlaybackAudioCapability::try_from) - .transpose() - .map(|value| value.unwrap_or(synctv_proto::client::PlaybackAudioCapability::Unspecified)) - .map_err(|_| super::super::AppError::bad_request("Invalid audioCapability enum integer")) -} - fn decode_client_profile( encoded: &str, ) -> Result { @@ -251,43 +140,13 @@ fn decode_client_profile( pub(crate) fn build_get_playback_request( query: &GetPlaybackQuery, ) -> AppResult { - let has_legacy_profile = query.stream_preference.is_some() - || query.max_streaming_bitrate.is_some() - || query.max_audio_channels.is_some() - || query.video_codecs.is_some() - || query.containers.is_some() - || query.live_transports.is_some() - || query.audio_capability.is_some() - || query.subtitle_preference.is_some(); - - if query.client_profile.is_some() && has_legacy_profile { - return Err(super::super::AppError::bad_request( - "clientProfile cannot be combined with legacy playback profile parameters", - )); - } - - let playback_client_profile = if let Some(encoded) = query.client_profile.as_deref() { - Some(decode_client_profile(encoded)?) - } else if has_legacy_profile { - Some(synctv_proto::client::PlaybackClientProfile { - stream_preference: parse_stream_preference(query.stream_preference)? as i32, - max_streaming_bitrate: query.max_streaming_bitrate, - max_audio_channels: query.max_audio_channels, - supported_video_codecs: parse_video_codecs(query.video_codecs.as_deref())?, - supported_containers: parse_containers(query.containers.as_deref())?, - supported_live_transports: parse_live_transports(query.live_transports.as_deref())?, - audio_capability: parse_audio_capability(query.audio_capability)? as i32, - subtitle_preference: parse_subtitle_preference(query.subtitle_preference)? as i32, - ..Default::default() - }) - } else { - None - }; - - let request = GetPlaybackRequest { - playback_client_profile, - }; - Ok(request) + Ok(GetPlaybackRequest { + playback_client_profile: query + .client_profile + .as_deref() + .map(decode_client_profile) + .transpose()?, + }) } pub(crate) fn build_playback_client_profile_from_watch_query( @@ -295,14 +154,6 @@ pub(crate) fn build_playback_client_profile_from_watch_query( ) -> AppResult> { build_get_playback_request(&GetPlaybackQuery { client_profile: query.client_profile.clone(), - stream_preference: query.stream_preference, - max_streaming_bitrate: query.max_streaming_bitrate, - max_audio_channels: query.max_audio_channels, - video_codecs: query.video_codecs.clone(), - containers: query.containers.clone(), - live_transports: query.live_transports.clone(), - audio_capability: query.audio_capability, - subtitle_preference: query.subtitle_preference, }) .map(|request| request.playback_client_profile) } diff --git a/synctv-api-http/src/http/room/tests.rs b/synctv-api-http/src/http/room/tests.rs index 690832aa..da81fca3 100644 --- a/synctv-api-http/src/http/room/tests.rs +++ b/synctv-api-http/src/http/room/tests.rs @@ -96,75 +96,6 @@ fn test_watch_after_event_sequence_rejects_non_utf8_last_event_id() -> TestResul Ok(()) } -#[test] -fn test_build_get_playback_request_parses_generic_profile_query() -> TestResult { - let request = app_ok(build_get_playback_request(&GetPlaybackQuery { - client_profile: None, - stream_preference: Some(synctv_proto::client::PlaybackStreamPreference::Transcode as i32), - max_streaming_bitrate: Some(8_000_000), - max_audio_channels: Some(2), - video_codecs: Some(format!( - "{},{}", - synctv_proto::client::PlaybackVideoCodec::H264 as i32, - synctv_proto::client::PlaybackVideoCodec::Av1 as i32 - )), - containers: Some(format!( - "{},{}", - synctv_proto::client::PlaybackContainer::Mp4 as i32, - synctv_proto::client::PlaybackContainer::Webm as i32 - )), - live_transports: Some(format!( - "{},{}", - synctv_proto::client::PlaybackLiveTransport::Hls as i32, - synctv_proto::client::PlaybackLiveTransport::Flv as i32 - )), - audio_capability: Some(synctv_proto::client::PlaybackAudioCapability::Surround as i32), - subtitle_preference: Some( - synctv_proto::client::PlaybackSubtitlePreference::EmbeddedOrExternal as i32, - ), - }))?; - - let profile = request - .playback_client_profile - .ok_or_else(|| test_error("query should produce playback client profile"))?; - assert_eq!( - profile.stream_preference, - synctv_proto::client::PlaybackStreamPreference::Transcode as i32 - ); - assert_eq!(profile.max_streaming_bitrate, Some(8_000_000)); - assert_eq!(profile.max_audio_channels, Some(2)); - assert_eq!( - profile.supported_video_codecs, - vec![ - synctv_proto::client::PlaybackVideoCodec::H264 as i32, - synctv_proto::client::PlaybackVideoCodec::Av1 as i32, - ] - ); - assert_eq!( - profile.supported_containers, - vec![ - synctv_proto::client::PlaybackContainer::Mp4 as i32, - synctv_proto::client::PlaybackContainer::Webm as i32, - ] - ); - assert_eq!( - profile.supported_live_transports, - vec![ - synctv_proto::client::PlaybackLiveTransport::Hls as i32, - synctv_proto::client::PlaybackLiveTransport::Flv as i32, - ] - ); - assert_eq!( - profile.audio_capability, - synctv_proto::client::PlaybackAudioCapability::Surround as i32 - ); - assert_eq!( - profile.subtitle_preference, - synctv_proto::client::PlaybackSubtitlePreference::EmbeddedOrExternal as i32 - ); - Ok(()) -} - #[test] fn test_build_get_playback_request_omits_profile_when_query_is_empty() -> TestResult { let request = app_ok(build_get_playback_request(&GetPlaybackQuery::default()))?; @@ -195,31 +126,18 @@ fn test_build_get_playback_request_decodes_versioned_profile() -> TestResult { let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(expected.encode_to_vec()); let request = app_ok(build_get_playback_request(&GetPlaybackQuery { client_profile: Some(encoded), - ..Default::default() }))?; assert_eq!(request.playback_client_profile, Some(expected)); Ok(()) } -#[test] -fn test_build_get_playback_request_rejects_mixed_profile_encodings() { - let error = build_get_playback_request(&GetPlaybackQuery { - client_profile: Some("AA".to_string()), - stream_preference: Some(1), - ..Default::default() - }) - .expect_err("profile encodings must be unambiguous"); - - assert!(error.message().contains("cannot be combined"), "{error:?}"); -} - #[test] fn test_handwritten_room_queries_ignore_unknown_fields() { let playback_query = - serde_urlencoded::from_str::("streamPreference=2&extra=true") + serde_urlencoded::from_str::("clientProfile=AA&extra=true") .expect("playback query should ignore unknown fields"); - assert_eq!(playback_query.stream_preference, Some(2)); + assert_eq!(playback_query.client_profile.as_deref(), Some("AA")); let watch_query = serde_urlencoded::from_str::("format=json&afterEventSequence=12&extra=true") .expect("watch query should ignore unknown fields"); @@ -279,96 +197,6 @@ fn test_handwritten_room_queries_ignore_unknown_fields() { assert_eq!(playlist_cover.token, "token"); } -#[test] -fn test_build_get_playback_request_rejects_invalid_video_codec() { - let error = build_get_playback_request(&GetPlaybackQuery { - client_profile: None, - stream_preference: None, - max_streaming_bitrate: None, - max_audio_channels: None, - video_codecs: Some("1,999".to_string()), - containers: None, - live_transports: None, - audio_capability: None, - subtitle_preference: None, - }) - .expect_err("unknown codec must be rejected"); - - assert!(error.message().contains("videoCodecs"), "{error:?}"); -} - -#[test] -fn test_build_get_playback_request_rejects_invalid_stream_preference() { - let error = build_get_playback_request(&GetPlaybackQuery { - client_profile: None, - stream_preference: Some(999), - max_streaming_bitrate: None, - max_audio_channels: None, - video_codecs: None, - containers: None, - live_transports: None, - audio_capability: None, - subtitle_preference: None, - }) - .expect_err("unknown stream preference must be rejected"); - - assert!(error.message().contains("streamPreference"), "{error:?}"); -} - -#[test] -fn test_build_get_playback_request_rejects_invalid_container() { - let error = build_get_playback_request(&GetPlaybackQuery { - client_profile: None, - stream_preference: None, - max_streaming_bitrate: None, - max_audio_channels: None, - video_codecs: None, - containers: Some("1,999".to_string()), - live_transports: None, - audio_capability: None, - subtitle_preference: None, - }) - .expect_err("unknown container must be rejected"); - - assert!(error.message().contains("container"), "{error:?}"); -} - -#[test] -fn test_build_get_playback_request_rejects_invalid_audio_capability() { - let error = build_get_playback_request(&GetPlaybackQuery { - client_profile: None, - stream_preference: None, - max_streaming_bitrate: None, - max_audio_channels: None, - video_codecs: None, - containers: None, - live_transports: None, - audio_capability: Some(999), - subtitle_preference: None, - }) - .expect_err("unknown audio capability must be rejected"); - - assert!(error.message().contains("audioCapability"), "{error:?}"); -} - -#[test] -fn test_build_get_playback_request_rejects_invalid_subtitle_preference() -> TestResult { - let error = app_err(build_get_playback_request(&GetPlaybackQuery { - client_profile: None, - stream_preference: None, - max_streaming_bitrate: None, - max_audio_channels: None, - video_codecs: None, - containers: None, - live_transports: None, - audio_capability: None, - subtitle_preference: Some(999), - }))?; - - assert!(error.message().contains("subtitlePreference"), "{error:?}"); - Ok(()) -} - #[test] fn test_scalar_query_parsers_reject_invalid_values() { assert!(serde_urlencoded::from_str::("force=definitely").is_err()); diff --git a/synctv-core/src/models/media.rs b/synctv-core/src/models/media.rs index 96b1f06a..5e056ad4 100644 --- a/synctv-core/src/models/media.rs +++ b/synctv-core/src/models/media.rs @@ -2047,8 +2047,8 @@ pub struct BilibiliPlaybackMetadata { /// Current upstream state when the provider could determine it. #[serde(default, skip_serializing_if = "Option::is_none")] pub is_currently_live: Option, - #[serde(default, skip_serializing_if = "BilibiliDashManifests::is_empty")] - pub dash_manifests: BilibiliDashManifests, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dash_manifest: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -2075,44 +2075,7 @@ impl BilibiliPlaybackMetadata { live_started_at: None, is_live: false, is_currently_live: None, - dash_manifests: BilibiliDashManifests::default(), - } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BilibiliDashManifests { - /// H.264/AVC DASH representations. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub h264: Option, - /// AV1 DASH representations. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub av1: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hevc: Option, -} - -impl BilibiliDashManifests { - #[must_use] - pub const fn is_empty(&self) -> bool { - self.h264.is_none() && self.av1.is_none() && self.hevc.is_none() - } - - pub fn set(&mut self, mode: BilibiliDashManifestSlot, manifest: BilibiliDashManifest) { - match mode { - BilibiliDashManifestSlot::H264 => self.h264 = Some(manifest), - BilibiliDashManifestSlot::Av1 => self.av1 = Some(manifest), - BilibiliDashManifestSlot::Hevc => self.hevc = Some(manifest), - } - } - - #[must_use] - pub const fn get(&self, mode: BilibiliDashManifestSlot) -> Option<&BilibiliDashManifest> { - match mode { - BilibiliDashManifestSlot::H264 => self.h264.as_ref(), - BilibiliDashManifestSlot::Av1 => self.av1.as_ref(), - BilibiliDashManifestSlot::Hevc => self.hevc.as_ref(), + dash_manifest: None, } } } @@ -2173,35 +2136,6 @@ pub struct BilibiliDashSegmentBase { pub initialization_range: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BilibiliDashManifestSlot { - H264, - Av1, - Hevc, -} - -impl BilibiliDashManifestSlot { - #[must_use] - pub fn parse(value: &str) -> Option { - match value.trim() { - "h264" => Some(Self::H264), - "av1" => Some(Self::Av1), - "hevc" => Some(Self::Hevc), - _ => None, - } - } - - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::H264 => "h264", - Self::Av1 => "av1", - Self::Hevc => "hevc", - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmbyPlaybackMetadata { diff --git a/synctv-core/src/models/mod.rs b/synctv-core/src/models/mod.rs index d6ce5673..5ec71549 100644 --- a/synctv-core/src/models/mod.rs +++ b/synctv-core/src/models/mod.rs @@ -162,33 +162,33 @@ pub use media::{ provider_type_code_from_name, provider_type_name_from_code, AcFunPlaybackFormat, AcFunPlaybackMetadata, AcFunPlaybackResourceKind, AlistPlaybackMetadata, AlistTranscodingTaskMetadata, AlistVideoPreviewMetadata, BilibiliDashAudioStream, - BilibiliDashManifest, BilibiliDashManifestSlot, BilibiliDashManifests, BilibiliDashSegmentBase, - BilibiliDashVideoStream, BilibiliPlaybackKind, BilibiliPlaybackMetadata, CctvChapterMetadata, - CctvPlaybackMetadata, CctvPlaybackStreamKind, DirectUrlPlaybackMetadata, DouyinPlaybackKind, - DouyinPlaybackMetadata, DouyinPlaybackResource, DouyuPlaybackCodec, DouyuPlaybackFormat, - DouyuPlaybackMetadata, EmbyPlaybackKind, EmbyPlaybackMetadata, FnosAudioTrackMetadata, - FnosFilePlaybackMetadata, FnosMediaPlaybackMetadata, FnosPlaybackMetadata, FnosProxyResource, - FnosSubtitleTrackMetadata, FnosTranscodeResource, FromProviderParams, HuyaPlaybackFormat, - HuyaPlaybackMetadata, HuyaPlaybackResourceKind, LivePlaybackMetadata, - LiveProxyPlaybackMetadata, Media, MediaListQuery, MediaListSortBy, NextcloudPlaybackMetadata, - PlaybackAcFunDanmaku, PlaybackAcFunMedia, PlaybackAlistMedia, PlaybackAlistSubtitle, - PlaybackBilibiliDanmaku, PlaybackBilibiliMedia, PlaybackBilibiliSubtitle, PlaybackCctvMedia, - PlaybackCloudreveMedia, PlaybackCloudreveSubtitle, PlaybackDanmaku, PlaybackDanmakuProvider, - PlaybackDirectUrlDanmaku, PlaybackDirectUrlMedia, PlaybackDirectUrlSubtitle, - PlaybackDouyinDanmaku, PlaybackDouyinMedia, PlaybackDouyuDanmaku, PlaybackDouyuMedia, - PlaybackEmbyMedia, PlaybackEmbySubtitle, PlaybackFnosMedia, PlaybackFnosSubtitle, - PlaybackHuyaDanmaku, PlaybackHuyaMedia, PlaybackInfo, PlaybackLiveProxyMedia, PlaybackMedia, - PlaybackMediaMetadata, PlaybackMediaProvider, PlaybackMetadata, PlaybackNextcloudMedia, - PlaybackNextcloudSubtitle, PlaybackQnapMedia, PlaybackQnapSubtitle, PlaybackResult, - PlaybackRtmpMedia, PlaybackSeafileMedia, PlaybackSeafileSubtitle, PlaybackSubtitle, - PlaybackSubtitleProvider, PlaybackSynologyMedia, PlaybackSynologySubtitle, PlaybackTikTokMedia, - PlaybackTikTokSubtitle, PlaybackTrueNasMedia, PlaybackTrueNasSubtitle, PlaybackTwitchDanmaku, - PlaybackTwitchMedia, PlaybackYoutubeMedia, PlaybackYoutubeSubtitle, QnapPlaybackMetadata, - QnapPlaybackMode, QnapPlaybackResource, SeafilePlaybackMetadata, SourceProvider, - SynologyAudioTrackMetadata, SynologyPlaybackMetadata, SynologyPlaybackProfile, - SynologyPlaybackResource, SynologySubtitleMetadata, TikTokPlaybackKind, TikTokPlaybackMetadata, - TikTokPlaybackResource, TrueNasPlaybackMetadata, TwitchChapterMetadata, TwitchPlaybackMetadata, - TwitchPlaybackResourceKind, YoutubePlaybackMetadata, YoutubePlaybackResource, + BilibiliDashManifest, BilibiliDashSegmentBase, BilibiliDashVideoStream, BilibiliPlaybackKind, + BilibiliPlaybackMetadata, CctvChapterMetadata, CctvPlaybackMetadata, CctvPlaybackStreamKind, + DirectUrlPlaybackMetadata, DouyinPlaybackKind, DouyinPlaybackMetadata, DouyinPlaybackResource, + DouyuPlaybackCodec, DouyuPlaybackFormat, DouyuPlaybackMetadata, EmbyPlaybackKind, + EmbyPlaybackMetadata, FnosAudioTrackMetadata, FnosFilePlaybackMetadata, + FnosMediaPlaybackMetadata, FnosPlaybackMetadata, FnosProxyResource, FnosSubtitleTrackMetadata, + FnosTranscodeResource, FromProviderParams, HuyaPlaybackFormat, HuyaPlaybackMetadata, + HuyaPlaybackResourceKind, LivePlaybackMetadata, LiveProxyPlaybackMetadata, Media, + MediaListQuery, MediaListSortBy, NextcloudPlaybackMetadata, PlaybackAcFunDanmaku, + PlaybackAcFunMedia, PlaybackAlistMedia, PlaybackAlistSubtitle, PlaybackBilibiliDanmaku, + PlaybackBilibiliMedia, PlaybackBilibiliSubtitle, PlaybackCctvMedia, PlaybackCloudreveMedia, + PlaybackCloudreveSubtitle, PlaybackDanmaku, PlaybackDanmakuProvider, PlaybackDirectUrlDanmaku, + PlaybackDirectUrlMedia, PlaybackDirectUrlSubtitle, PlaybackDouyinDanmaku, PlaybackDouyinMedia, + PlaybackDouyuDanmaku, PlaybackDouyuMedia, PlaybackEmbyMedia, PlaybackEmbySubtitle, + PlaybackFnosMedia, PlaybackFnosSubtitle, PlaybackHuyaDanmaku, PlaybackHuyaMedia, PlaybackInfo, + PlaybackLiveProxyMedia, PlaybackMedia, PlaybackMediaMetadata, PlaybackMediaProvider, + PlaybackMetadata, PlaybackNextcloudMedia, PlaybackNextcloudSubtitle, PlaybackQnapMedia, + PlaybackQnapSubtitle, PlaybackResult, PlaybackRtmpMedia, PlaybackSeafileMedia, + PlaybackSeafileSubtitle, PlaybackSubtitle, PlaybackSubtitleProvider, PlaybackSynologyMedia, + PlaybackSynologySubtitle, PlaybackTikTokMedia, PlaybackTikTokSubtitle, PlaybackTrueNasMedia, + PlaybackTrueNasSubtitle, PlaybackTwitchDanmaku, PlaybackTwitchMedia, PlaybackYoutubeMedia, + PlaybackYoutubeSubtitle, QnapPlaybackMetadata, QnapPlaybackMode, QnapPlaybackResource, + SeafilePlaybackMetadata, SourceProvider, SynologyAudioTrackMetadata, SynologyPlaybackMetadata, + SynologyPlaybackProfile, SynologyPlaybackResource, SynologySubtitleMetadata, + TikTokPlaybackKind, TikTokPlaybackMetadata, TikTokPlaybackResource, TrueNasPlaybackMetadata, + TwitchChapterMetadata, TwitchPlaybackMetadata, TwitchPlaybackResourceKind, + YoutubePlaybackMetadata, YoutubePlaybackResource, }; pub use notification::{ CreateNotificationRequest, MarkAllAsReadRequest, MarkAsReadRequest, Notification, diff --git a/synctv-core/src/provider/bilibili.rs b/synctv-core/src/provider/bilibili.rs index 1ddb8f64..2c191f4e 100644 --- a/synctv-core/src/provider/bilibili.rs +++ b/synctv-core/src/provider/bilibili.rs @@ -29,11 +29,11 @@ use std::sync::Arc; use std::time::Duration; use crate::models::media::{ - BilibiliDashAudioStream, BilibiliDashManifest, BilibiliDashManifestSlot, - BilibiliDashSegmentBase, BilibiliDashVideoStream, BilibiliDurlSegment, BilibiliPlaybackKind, - BilibiliPlaybackMetadata, PlaybackBilibiliDanmaku, PlaybackBilibiliMedia, - PlaybackBilibiliSubtitle, PlaybackDanmaku, PlaybackDanmakuProvider, PlaybackMedia, - PlaybackMediaProvider, PlaybackMetadata, PlaybackSubtitle, PlaybackSubtitleProvider, + BilibiliDashAudioStream, BilibiliDashManifest, BilibiliDashSegmentBase, + BilibiliDashVideoStream, BilibiliDurlSegment, BilibiliPlaybackKind, BilibiliPlaybackMetadata, + PlaybackBilibiliDanmaku, PlaybackBilibiliMedia, PlaybackBilibiliSubtitle, PlaybackDanmaku, + PlaybackDanmakuProvider, PlaybackMedia, PlaybackMediaProvider, PlaybackMetadata, + PlaybackSubtitle, PlaybackSubtitleProvider, }; use crate::models::{ normalize_provider_instance_name, validate_provider_instance_name, BilibiliHistoryType, @@ -53,7 +53,11 @@ 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 = "v6"; +const BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION: &str = "v10"; +const BILIBILI_DASH_SWARM_SCHEMA_VERSION: &str = "v1"; +const BILIBILI_DASH_MODE_NAME: &str = "dash"; +const BILIBILI_DASH_LABEL: &str = "DASH"; +const BILIBILI_DASH_RETRY_DELAY: Duration = Duration::from_millis(200); type HmacSha256 = Hmac; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1742,22 +1746,16 @@ fn bilibili_dash_resource_candidates( Ok(None) } -fn insert_dash_manifest_metadata( - metadata: &mut PlaybackMetadata, - mode: BilibiliDashManifestSlot, - dash: BilibiliDashManifest, -) { +fn insert_dash_manifest_metadata(metadata: &mut PlaybackMetadata, dash: BilibiliDashManifest) { let PlaybackMetadata::Bilibili(metadata) = metadata else { return; }; - metadata.dash_manifests.set(mode, dash); + metadata.dash_manifest = Some(dash); } fn dash_manifest_from_metadata( result: &PlaybackResult, - mode_name: &str, ) -> Result { - let mode = BilibiliDashManifestSlot::parse(mode_name).ok_or(ProviderError::NotFound)?; result .metadata .as_ref() @@ -1765,42 +1763,11 @@ fn dash_manifest_from_metadata( PlaybackMetadata::Bilibili(metadata) => Some(metadata), _ => None, }) - .and_then(|metadata| metadata.dash_manifests.get(mode)) + .and_then(|metadata| metadata.dash_manifest.as_ref()) .cloned() .ok_or(ProviderError::NotFound) } -fn has_dash_manifest_metadata(result: &PlaybackResult, mode_name: &str) -> bool { - dash_manifest_from_metadata(result, mode_name).is_ok() -} - -fn available_dash_manifest_slots(result: &PlaybackResult) -> Vec { - [ - BilibiliDashManifestSlot::H264, - BilibiliDashManifestSlot::Av1, - BilibiliDashManifestSlot::Hevc, - ] - .into_iter() - .filter(|slot| dash_manifest_from_metadata(result, slot.as_str()).is_ok()) - .collect() -} - -const fn bilibili_dash_label(slot: BilibiliDashManifestSlot) -> &'static str { - match slot { - BilibiliDashManifestSlot::H264 => "H.264", - BilibiliDashManifestSlot::Av1 => "AV1", - BilibiliDashManifestSlot::Hevc => "HEVC", - } -} - -const fn bilibili_dash_mode_name(slot: BilibiliDashManifestSlot) -> &'static str { - match slot { - BilibiliDashManifestSlot::H264 => "h264", - BilibiliDashManifestSlot::Av1 => "av1", - BilibiliDashManifestSlot::Hevc => "hevc", - } -} - fn dash_manifest_from_upstream(dash: &bilibili_upstream::DashInfo) -> BilibiliDashManifest { BilibiliDashManifest { duration: dash.duration, @@ -1818,126 +1785,180 @@ fn dash_manifest_from_upstream(dash: &bilibili_upstream::DashInfo) -> BilibiliDa } } -fn bilibili_dash_codec_slot(codecs: &str) -> BilibiliDashManifestSlot { +fn bilibili_dash_video_codec(codecs: &str) -> super::PlaybackVideoCodec { let codecs = codecs.trim().to_ascii_lowercase(); if codecs.starts_with("hev1") || codecs.starts_with("hvc1") { - BilibiliDashManifestSlot::Hevc + super::PlaybackVideoCodec::Hevc } else if codecs.starts_with("av01") { - BilibiliDashManifestSlot::Av1 + super::PlaybackVideoCodec::Av1 + } else if codecs.starts_with("vp09") || codecs.starts_with("vp9") { + super::PlaybackVideoCodec::Vp9 } else { - BilibiliDashManifestSlot::H264 + super::PlaybackVideoCodec::H264 } } -fn split_bilibili_dash_manifest( +fn bilibili_dash_audio_codec(codecs: &str) -> Option { + let codecs = codecs.trim().to_ascii_lowercase(); + if codecs.starts_with("mp4a") || codecs.starts_with("aac") { + Some(super::PlaybackAudioCodec::Aac) + } else if codecs.starts_with("ec-3") || codecs.starts_with("eac3") { + Some(super::PlaybackAudioCodec::Eac3) + } else if codecs.starts_with("ac-3") || codecs.starts_with("ac3") { + Some(super::PlaybackAudioCodec::Ac3) + } else if codecs.starts_with("flac") { + Some(super::PlaybackAudioCodec::Flac) + } else if codecs.starts_with("opus") { + Some(super::PlaybackAudioCodec::Opus) + } else if codecs.starts_with("vorbis") { + Some(super::PlaybackAudioCodec::Vorbis) + } else if codecs.starts_with("mp3") { + Some(super::PlaybackAudioCodec::Mp3) + } else { + None + } +} + +fn filter_bilibili_dash_manifest( dash: &BilibiliDashManifest, -) -> Vec<(BilibiliDashManifestSlot, BilibiliDashManifest)> { - let mut variants = - std::collections::HashMap::::new(); - for stream in &dash.video_streams { - let slot = bilibili_dash_codec_slot(&stream.codecs); - let variant = variants - .entry(slot) - .or_insert_with(|| BilibiliDashManifest { - duration: dash.duration, - min_buffer_time: dash.min_buffer_time, - video_streams: Vec::new(), - audio_streams: dash.audio_streams.clone(), - }); - variant.video_streams.push(stream.clone()); - } - [ - BilibiliDashManifestSlot::H264, - BilibiliDashManifestSlot::Av1, - BilibiliDashManifestSlot::Hevc, - ] - .into_iter() - .filter_map(|slot| variants.remove(&slot).map(|manifest| (slot, manifest))) - .collect() + profile: Option<&super::PlaybackClientProfile>, +) -> BilibiliDashManifest { + let Some(profile) = profile else { + return dash.clone(); + }; + let transport = super::PlaybackMediaTransport::Dash; + let container = Some(super::PlaybackContainer::Mp4); + + BilibiliDashManifest { + duration: dash.duration, + min_buffer_time: dash.min_buffer_time, + video_streams: dash + .video_streams + .iter() + .filter(|stream| { + profile.supports_codec_string( + transport, + container, + Some(bilibili_dash_video_codec(&stream.codecs)), + None, + &stream.codecs, + ) + }) + .cloned() + .collect(), + audio_streams: dash + .audio_streams + .iter() + .filter(|stream| { + profile.supports_codec_string( + transport, + container, + None, + bilibili_dash_audio_codec(&stream.codecs), + &stream.codecs, + ) + }) + .cloned() + .collect(), + } +} + +fn merge_bilibili_dash_manifests( + primary: &BilibiliDashManifest, + additional: Option<&BilibiliDashManifest>, +) -> BilibiliDashManifest { + let mut merged = primary.clone(); + if let Some(additional) = additional { + for stream in &additional.video_streams { + if !merged.video_streams.iter().any(|existing| { + existing.id == stream.id + && existing.codecid == stream.codecid + && existing.codecs.eq_ignore_ascii_case(&stream.codecs) + }) { + merged.video_streams.push(stream.clone()); + } + } + for stream in &additional.audio_streams { + if !merged.audio_streams.iter().any(|existing| { + existing.id == stream.id && existing.codecs.eq_ignore_ascii_case(&stream.codecs) + }) { + merged.audio_streams.push(stream.clone()); + } + } + } + merged +} + +#[derive(Clone, Copy)] +struct BilibiliDashPlaybackOptions<'a> { + provider_instance_name: Option<&'a str>, + subtitles: &'a [PlaybackSubtitle], + danmakus: &'a [PlaybackDanmaku], + client_profile: Option<&'a super::PlaybackClientProfile>, } fn bilibili_dash_playback_infos( metadata: &mut PlaybackMetadata, content_descriptor: &str, - provider_instance_name: Option<&str>, primary_dash: &BilibiliDashManifest, hevc_dash: Option<&BilibiliDashManifest>, - subtitles: &[PlaybackSubtitle], - danmakus: &[PlaybackDanmaku], + options: BilibiliDashPlaybackOptions<'_>, ) -> Result<(HashMap, String), ProviderError> { - let mut variant_map = HashMap::::new(); - for (slot, manifest) in split_bilibili_dash_manifest(primary_dash) - .into_iter() - .chain(hevc_dash.into_iter().flat_map(split_bilibili_dash_manifest)) - { - if let Some(existing) = variant_map.get_mut(&slot) { - existing.video_streams.extend(manifest.video_streams); - if existing.audio_streams.is_empty() { - existing.audio_streams = manifest.audio_streams; - } - } else { - variant_map.insert(slot, manifest); + let BilibiliDashPlaybackOptions { + provider_instance_name, + subtitles, + danmakus, + client_profile, + } = options; + let merged = merge_bilibili_dash_manifests(primary_dash, hevc_dash); + let upstream_has_audio = !merged.audio_streams.is_empty(); + let dash = filter_bilibili_dash_manifest(&merged, client_profile); + if dash.video_streams.is_empty() { + if client_profile.is_some() { + return Err(ProviderError::ClientIncompatible { + reason: + "Bilibili DASH has no video representation matching the client codec profile" + .to_string(), + required_capability: Some("dash_video_codec_string".to_string()), + }); } - } - let variants = [ - BilibiliDashManifestSlot::H264, - BilibiliDashManifestSlot::Av1, - BilibiliDashManifestSlot::Hevc, - ] - .into_iter() - .filter_map(|slot| variant_map.remove(&slot).map(|manifest| (slot, manifest))) - .collect::>(); - if variants.is_empty() { return Err(ProviderError::ApiError( "Bilibili DASH response did not include playable video streams".to_string(), )); } - - let preferred_slot = variants - .iter() - .find_map(|(slot, _)| (*slot == BilibiliDashManifestSlot::H264).then_some(*slot)) - .unwrap_or(variants[0].0); - let mut playback_infos = HashMap::new(); - for (slot, dash) in variants { - let mode_name = bilibili_dash_mode_name(slot).to_string(); - insert_dash_manifest_metadata(metadata, slot, dash.clone()); - let swarm_id = - bilibili_dash_swarm_id(provider_instance_name, content_descriptor, slot, &dash); - let manifest_expires_at = dash_manifest_expiration(&dash); - let info = PlaybackInfo { - thumbnail: None, - medias: vec![playback_media( - bilibili_dash_label(slot).to_string(), - "mpd".to_string(), - manifest_expires_at, - Some(swarm_id), - PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDashManifest { - version: String::new(), - expires_at: manifest_expires_at.unwrap_or_default(), - mode_name: mode_name.clone(), - headers: bilibili_headers(), - }), - )], - default_media_index: Some(0), - subtitles: subtitles.to_vec(), - default_subtitle_index: None, - danmakus: danmakus.to_vec(), - default_danmaku_index: (!danmakus.is_empty()).then_some(0), - }; - playback_infos.insert(mode_name, info); + if upstream_has_audio && dash.audio_streams.is_empty() { + return Err(ProviderError::ClientIncompatible { + reason: "Bilibili DASH has no audio representation matching the client codec profile" + .to_string(), + required_capability: Some("dash_audio_codec_string".to_string()), + }); } - let preferred_mode_name = bilibili_dash_mode_name(preferred_slot); - let default_mode = if playback_infos.contains_key(preferred_mode_name) { - preferred_mode_name.to_string() - } else { - playback_infos.keys().next().cloned().ok_or_else(|| { - ProviderError::ApiError( - "Bilibili DASH response did not include playable URLs".to_string(), - ) - })? + let mode_name = BILIBILI_DASH_MODE_NAME.to_string(); + insert_dash_manifest_metadata(metadata, dash.clone()); + let swarm_id = bilibili_dash_manifest_swarm_id(provider_instance_name, content_descriptor); + let manifest_expires_at = dash_manifest_expiration(&dash); + let info = PlaybackInfo { + thumbnail: None, + medias: vec![playback_media( + BILIBILI_DASH_LABEL.to_string(), + "mpd".to_string(), + manifest_expires_at, + Some(swarm_id), + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDashManifest { + version: String::new(), + expires_at: manifest_expires_at.unwrap_or_default(), + mode_name: mode_name.clone(), + headers: bilibili_headers(), + }), + )], + default_media_index: Some(0), + subtitles: subtitles.to_vec(), + default_subtitle_index: None, + danmakus: danmakus.to_vec(), + default_danmaku_index: (!danmakus.is_empty()).then_some(0), }; - Ok((playback_infos, default_mode)) + Ok((HashMap::from([(mode_name.clone(), info)]), mode_name)) } fn dash_video_from_upstream(stream: &bilibili_upstream::VideoStream) -> BilibiliDashVideoStream { @@ -2063,9 +2084,29 @@ where r#""# ); - if !dash.video_streams.is_empty() { - xml.push_str(r#""#); - for stream in &dash.video_streams { + for codec in [ + super::PlaybackVideoCodec::H264, + super::PlaybackVideoCodec::Hevc, + super::PlaybackVideoCodec::Av1, + super::PlaybackVideoCodec::Vp9, + ] { + let streams = dash + .video_streams + .iter() + .filter(|stream| { + !stream.base_url.trim().is_empty() + && bilibili_dash_video_codec(&stream.codecs) == codec + }) + .collect::>(); + if streams.is_empty() { + continue; + } + let (adaptation_id, label, selection_priority) = bilibili_dash_video_adaptation(codec); + let _ = write!( + xml, + r#""#, + ); + for stream in streams { if stream.base_url.trim().is_empty() { continue; } @@ -2090,7 +2131,7 @@ where }; let _ = write!( xml, - r#"{}{}"#, + r#"{}{}"#, stream.id, stream.codecid, xml_escape(&stream.mime_type), @@ -2109,12 +2150,33 @@ where xml.push_str(""); } - if !dash.audio_streams.is_empty() { - xml.push_str(r#""#); - for stream in &dash.audio_streams { - if stream.base_url.trim().is_empty() { - continue; - } + for codec in [ + Some(super::PlaybackAudioCodec::Aac), + Some(super::PlaybackAudioCodec::Eac3), + Some(super::PlaybackAudioCodec::Ac3), + Some(super::PlaybackAudioCodec::Flac), + Some(super::PlaybackAudioCodec::Opus), + Some(super::PlaybackAudioCodec::Vorbis), + Some(super::PlaybackAudioCodec::Mp3), + None, + ] { + let streams = dash + .audio_streams + .iter() + .filter(|stream| { + !stream.base_url.trim().is_empty() + && bilibili_dash_audio_codec(&stream.codecs) == codec + }) + .collect::>(); + if streams.is_empty() { + continue; + } + let (adaptation_id, label, selection_priority) = bilibili_dash_audio_adaptation(codec); + let _ = write!( + xml, + r#""#, + ); + for stream in streams { let mut base_urls = String::new(); for upstream_url in std::iter::once(&stream.base_url).chain(&stream.backup_urls) { if upstream_url.trim().is_empty() { @@ -2136,9 +2198,8 @@ where }; let _ = write!( xml, - r#"{}{}"#, + r#"{}{}"#, stream.id, - xml_escape(&stream.codecs), xml_escape(&stream.mime_type), xml_escape(&stream.codecs), stream.bandwidth, @@ -2163,6 +2224,32 @@ where Ok(xml) } +const fn bilibili_dash_video_adaptation( + codec: super::PlaybackVideoCodec, +) -> (u64, &'static str, u64) { + match codec { + super::PlaybackVideoCodec::H264 => (100, "H.264", 400), + super::PlaybackVideoCodec::Hevc => (110, "HEVC", 300), + super::PlaybackVideoCodec::Av1 => (120, "AV1", 200), + super::PlaybackVideoCodec::Vp9 => (130, "VP9", 100), + } +} + +const fn bilibili_dash_audio_adaptation( + codec: Option, +) -> (u64, &'static str, u64) { + match codec { + Some(super::PlaybackAudioCodec::Aac) => (200, "AAC", 700), + Some(super::PlaybackAudioCodec::Eac3) => (210, "E-AC-3", 600), + Some(super::PlaybackAudioCodec::Ac3) => (220, "AC-3", 500), + Some(super::PlaybackAudioCodec::Flac) => (230, "FLAC", 400), + Some(super::PlaybackAudioCodec::Opus) => (240, "Opus", 300), + Some(super::PlaybackAudioCodec::Vorbis) => (250, "Vorbis", 200), + Some(super::PlaybackAudioCodec::Mp3) => (260, "MP3", 100), + None => (290, "Audio", 1), + } +} + fn bilibili_route_selection( proxy_mode: crate::models::PlaybackProxyMode, ) -> super::PlaybackRouteSelection { @@ -2460,59 +2547,36 @@ fn mark_bilibili_playback_resources( continue; } - let use_mpd_manifest = original_info - .medias - .first() - .is_some_and(|media| media.format == "mpd") - && has_dash_manifest_metadata(result, &mode_name); - let dash_slots = if use_mpd_manifest { - available_dash_manifest_slots(result) - .into_iter() - .filter(|slot| { - BilibiliDashManifestSlot::parse(&mode_name) - .is_none_or(|mode_slot| mode_slot == *slot) - }) - .filter_map(|slot| { - dash_manifest_from_metadata(result, slot.as_str()) - .ok() - .map(|manifest| { - let p2p_swarm_id = bilibili_content_descriptor(result).map(|content| { - bilibili_dash_swarm_id( - result.provider_instance_name.as_deref(), - &content, - slot, - &manifest, - ) - }); - (slot, dash_manifest_expiration(&manifest), p2p_swarm_id) - }) - }) - .collect::>() - } else { - Default::default() - }; + let dash_resource = (mode_name == BILIBILI_DASH_MODE_NAME + && original_info + .medias + .first() + .is_some_and(|media| media.format == "mpd")) + .then(|| dash_manifest_from_metadata(result).ok()) + .flatten() + .map(|manifest| { + let p2p_swarm_id = bilibili_content_descriptor(result).map(|content| { + bilibili_dash_manifest_swarm_id(result.provider_instance_name.as_deref(), &content) + }); + (dash_manifest_expiration(&manifest), p2p_swarm_id) + }); let mut direct_info = original_info.clone(); - if selection.direct && use_mpd_manifest { + if selection.direct && dash_resource.is_some() { let source_media = original_info.medias.first(); - direct_info.medias = dash_slots + direct_info.medias = dash_resource .iter() - .map(|(slot, slot_expires_at, p2p_swarm_id)| { + .map(|(manifest_expires_at, p2p_swarm_id)| { playback_media( - match slot { - BilibiliDashManifestSlot::H264 => "H.264", - BilibiliDashManifestSlot::Av1 => "AV1", - BilibiliDashManifestSlot::Hevc => "HEVC", - } - .to_string(), + BILIBILI_DASH_LABEL.to_string(), "mpd".to_string(), - *slot_expires_at, + *manifest_expires_at, p2p_swarm_id.clone(), PlaybackMediaProvider::Bilibili( PlaybackBilibiliMedia::DirectDashManifest { version: version.to_string(), expires_at, - mode_name: bilibili_dash_mode_name(*slot).to_string(), + mode_name: BILIBILI_DASH_MODE_NAME.to_string(), headers: source_media .map_or_else(bilibili_headers, PlaybackMedia::upstream_headers), }, @@ -2550,20 +2614,20 @@ fn mark_bilibili_playback_resources( if selection.proxy { let proxy_mode_name = format!("proxy_{mode_name}"); let mut proxy_info = original_info.clone(); - if use_mpd_manifest { - proxy_info.medias = dash_slots + if dash_resource.is_some() { + proxy_info.medias = dash_resource .iter() - .map(|(slot, slot_expires_at, p2p_swarm_id)| { + .map(|(manifest_expires_at, p2p_swarm_id)| { playback_media( - bilibili_dash_label(*slot).to_string(), + BILIBILI_DASH_LABEL.to_string(), "mpd".to_string(), - *slot_expires_at, + *manifest_expires_at, p2p_swarm_id.clone(), PlaybackMediaProvider::Bilibili( PlaybackBilibiliMedia::ProxyDashManifest { version: version.to_string(), expires_at, - mode_name: bilibili_dash_mode_name(*slot).to_string(), + mode_name: BILIBILI_DASH_MODE_NAME.to_string(), }, ), ) @@ -2672,22 +2736,29 @@ fn playback_cache_entry( credential_cache_partition: &str, playback_client_profile: Option<&super::PlaybackClientProfile>, ) -> Result<(String, Duration), ProviderError> { + let profile_partition = playback_client_profile.map_or_else( + || "unprofiled".to_string(), + |profile| { + let digest = sha2::Sha256::digest(profile.cache_fingerprint().as_bytes()); + hex::encode(digest).chars().take(24).collect() + }, + ); match config { BilibiliSourceConfig::Video(config) => { let video_key = BilibiliVideoIdentifier::parse(config.bvid.as_deref(), config.aid)? .cache_key_part(); Ok(( format!( - "playback:{BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION}:video:{video_key}:{}:{credential_cache_partition}", - config.cid + "playback:{BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION}:video:{video_key}:{}:{credential_cache_partition}:profile:{profile_partition}", + config.cid, ), Duration::from_hours(2), )) } BilibiliSourceConfig::Pgc(config) => Ok(( format!( - "playback:{BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION}:pgc:{}:{}:{credential_cache_partition}", - config.epid, config.cid + "playback:{BILIBILI_PLAYBACK_CACHE_SCHEMA_VERSION}:pgc:{}:{}:{credential_cache_partition}:profile:{profile_partition}", + config.epid, config.cid, ), Duration::from_hours(2), )), @@ -4489,19 +4560,20 @@ impl DynamicPlaylistProvider for BilibiliProvider { #[cfg(test)] mod tests { use super::{ - bilibili_dash_codec_slot, bilibili_dash_label, bilibili_dash_playback_infos, - bilibili_dash_resource_candidates, bilibili_durl_media, bilibili_durl_resource_candidates, - bilibili_live_danmaku_track, bilibili_live_playback_infos, bilibili_subtitle_track, - bilibili_upstream, bilibili_vod_danmaku_track, build_bilibili_durl_manifest, - default_bilibili_live_mode, mark_bilibili_playback_resources, BilibiliProvider, + bilibili_dash_manifest_swarm_id, bilibili_dash_playback_infos, + bilibili_dash_resource_candidates, bilibili_dash_video_codec, bilibili_durl_media, + bilibili_durl_resource_candidates, bilibili_live_danmaku_track, + bilibili_live_playback_infos, bilibili_subtitle_track, bilibili_upstream, + bilibili_vod_danmaku_track, build_bilibili_durl_manifest, build_bilibili_mpd_manifest, + default_bilibili_live_mode, filter_bilibili_dash_manifest, + mark_bilibili_playback_resources, BilibiliDashPlaybackOptions, BilibiliProvider, BilibiliSmsLoginSession, BilibiliSmsLoginTokenCodec, }; use crate::models::media::{ - BilibiliDashAudioStream, BilibiliDashManifest, BilibiliDashManifestSlot, - BilibiliDashManifests, BilibiliDashVideoStream, BilibiliDurlSegment, BilibiliPlaybackKind, - BilibiliPlaybackMetadata, PlaybackBilibiliDanmaku, PlaybackBilibiliMedia, - PlaybackBilibiliSubtitle, PlaybackDanmakuProvider, PlaybackMediaProvider, PlaybackMetadata, - PlaybackSubtitleProvider, + BilibiliDashAudioStream, BilibiliDashManifest, BilibiliDashVideoStream, + BilibiliDurlSegment, BilibiliPlaybackKind, BilibiliPlaybackMetadata, + PlaybackBilibiliDanmaku, PlaybackBilibiliMedia, PlaybackBilibiliSubtitle, + PlaybackDanmakuProvider, PlaybackMediaProvider, PlaybackMetadata, PlaybackSubtitleProvider, }; use crate::models::{BilibiliTarget, PlaylistId, ProviderTarget, RoomId}; use crate::provider::{ @@ -4583,7 +4655,38 @@ mod tests { let (key, _) = provider_ok(super::playback_cache_entry(&config, "anonymous", None))?; - assert!(key.starts_with("playback:v6:video:")); + assert!(key.starts_with("playback:v10:video:")); + Ok(()) + } + + #[test] + fn vod_playback_cache_is_partitioned_by_client_codec_capabilities() -> 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 mut high_profile = web_hls_profile(); + high_profile.media_capabilities[0].transport = PlaybackMediaTransport::Dash; + high_profile.media_capabilities[0].codec_string = Some("avc1.640033,mp4a.40.2".to_string()); + let mut main_profile = high_profile.clone(); + main_profile.media_capabilities[0].codec_string = Some("avc1.64001F,mp4a.40.2".to_string()); + + let (high_key, _) = provider_ok(super::playback_cache_entry( + &config, + "anonymous", + Some(&high_profile), + ))?; + let (main_key, _) = provider_ok(super::playback_cache_entry( + &config, + "anonymous", + Some(&main_profile), + ))?; + + assert_ne!(high_key, main_key); + assert!(!high_key.contains("avc1")); Ok(()) } @@ -4947,11 +5050,29 @@ mod tests { #[test] fn live_playback_uses_the_transport_advertised_by_the_client() -> TestResult { - let hls_profile = super::super::PlaybackClientProfile::default(); + let hls_profile = super::super::PlaybackClientProfile { + media_capabilities: vec![super::super::PlaybackMediaCapability { + transport: super::super::PlaybackMediaTransport::Hls, + container: None, + video_codec: None, + audio_codec: None, + pipeline: super::super::PlaybackMediaPipeline::Native, + codec_string: None, + }], + ..Default::default() + }; assert!(super::bilibili_live_uses_hls(Some(&hls_profile))); let native_profile = super::super::PlaybackClientProfile { supported_live_transports: vec![super::super::PlaybackLiveTransport::Flv], + media_capabilities: vec![super::super::PlaybackMediaCapability { + transport: super::super::PlaybackMediaTransport::Flv, + container: None, + video_codec: None, + audio_codec: None, + pipeline: super::super::PlaybackMediaPipeline::Native, + codec_string: None, + }], ..Default::default() }; assert!(!super::bilibili_live_uses_hls(Some(&native_profile))); @@ -5051,6 +5172,36 @@ mod tests { Ok(()) } + #[test] + fn durl_media_expiry_is_derived_only_from_upstream_urls() -> TestResult { + let undated = provider_ok(bilibili_durl_media( + "MP4", + [( + "https://cdn.example/video.mp4".to_string(), + Vec::new(), + 1_000, + )], + "sm3_test_durl_undated".to_string(), + ))?; + assert_eq!(undated.expire_at, None); + + let upstream_expires_at = 4_102_444_800; + let dated = provider_ok(bilibili_durl_media( + "MP4", + [( + format!("https://cdn.example/video.mp4?deadline={upstream_expires_at}"), + Vec::new(), + 1_000, + )], + "sm3_test_durl_dated".to_string(), + ))?; + assert_eq!( + dated.expire_at.map(|value| value.timestamp()), + Some(upstream_expires_at) + ); + Ok(()) + } + #[test] fn dash_resources_keep_server_side_backup_candidates() -> TestResult { let primary = "https://primary.example/video.m4s?deadline=200"; @@ -5243,7 +5394,7 @@ mod tests { let subtitle_url = "https://subtitle.bilibili.com/track.json"; let playback = PlaybackResult { playback_infos: HashMap::from([( - "proxy_h264".to_string(), + "proxy_dash".to_string(), PlaybackInfo { thumbnail: None, medias: Vec::new(), @@ -5259,7 +5410,7 @@ mod tests { default_danmaku_index: None, }, )]), - default_mode: "proxy_h264".to_string(), + default_mode: "proxy_dash".to_string(), provider: crate::models::SourceProvider::Bilibili, provider_instance_name: None, duration_seconds: None, @@ -5281,7 +5432,7 @@ mod tests { let provider = BilibiliProvider::new_local_only()?; let action = provider - .get_subtitle(Some(&store), "subtitle-test", "proxy_h264", 0, None) + .get_subtitle(Some(&store), "subtitle-test", "proxy_dash", 0, None) .await?; let PlaybackTransportAction::FetchAndForward { @@ -5314,7 +5465,7 @@ mod tests { } #[test] - fn primary_dash_slot_preserves_hevc_only_metadata() { + fn dash_video_codec_family_uses_the_exact_codec_string() { let regular = bilibili_upstream::DashInfo { video_streams: vec![bilibili_upstream::VideoStream::default()], ..Default::default() @@ -5328,18 +5479,21 @@ mod tests { }; assert_eq!( - bilibili_dash_codec_slot(®ular.video_streams[0].codecs), - BilibiliDashManifestSlot::H264 + bilibili_dash_video_codec(®ular.video_streams[0].codecs), + PlaybackVideoCodec::H264 + ); + assert_eq!( + bilibili_dash_video_codec(&hevc.video_streams[0].codecs), + PlaybackVideoCodec::Hevc ); assert_eq!( - bilibili_dash_codec_slot(&hevc.video_streams[0].codecs), - BilibiliDashManifestSlot::Hevc + bilibili_dash_video_codec("vp09.00.21.08"), + PlaybackVideoCodec::Vp9 ); - assert_eq!(bilibili_dash_label(BilibiliDashManifestSlot::Hevc), "HEVC"); } #[test] - fn dash_codecs_are_exposed_as_independent_playback_modes() -> TestResult { + fn dash_codecs_are_exposed_in_one_playback_mode() -> TestResult { let video = |id: u64, codecs: &str, url: &str| BilibiliDashVideoStream { id, codecs: codecs.to_string(), @@ -5364,68 +5518,403 @@ mod tests { "https://cdn.example/hevc.m4s?deadline=100", ), ], + audio_streams: vec![ + BilibiliDashAudioStream { + id: 30_280, + codecs: "mp4a.40.2".to_string(), + base_url: "https://cdn.example/aac.m4s?deadline=100".to_string(), + ..Default::default() + }, + BilibiliDashAudioStream { + id: 30_250, + codecs: "ec-3".to_string(), + base_url: "https://cdn.example/eac3.m4s?deadline=100".to_string(), + ..Default::default() + }, + BilibiliDashAudioStream { + id: 30_251, + codecs: "fLaC".to_string(), + base_url: "https://cdn.example/flac.m4s?deadline=100".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + let (infos, default_mode) = bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &dash, + None, + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: None, + }, + )?; + + assert_eq!(default_mode, "dash"); + assert_eq!(infos.len(), 1); + let medias = &infos["dash"].medias; + assert_eq!(medias.len(), 1); + assert_eq!(medias[0].name, "DASH"); + assert_eq!(medias[0].format, "mpd"); + assert!(matches!( + &medias[0].provider, + PlaybackMediaProvider::Bilibili( + PlaybackBilibiliMedia::DirectDashManifest { mode_name, .. } + ) if mode_name == "dash" + )); + let PlaybackMetadata::Bilibili(metadata) = metadata else { + anyhow::bail!("expected Bilibili metadata"); + }; + let Some(manifest) = metadata.dash_manifest.as_ref() else { + anyhow::bail!("expected unified DASH manifest"); + }; + assert_eq!(manifest.video_streams.len(), 3); + assert_eq!(manifest.audio_streams.len(), 3); + Ok(()) + } + + #[test] + fn unified_dash_merges_unique_video_and_audio_from_additional_response() -> TestResult { + let primary = BilibiliDashManifest { + video_streams: vec![BilibiliDashVideoStream { + id: 80, + codecid: 7, + codecs: "avc1.640032".to_string(), + base_url: "https://cdn.example/h264.m4s".to_string(), + ..Default::default() + }], audio_streams: vec![BilibiliDashAudioStream { - base_url: "https://cdn.example/audio.m4s?deadline=100".to_string(), + id: 30_280, + codecs: "mp4a.40.2".to_string(), + base_url: "https://cdn.example/aac.m4s".to_string(), + ..Default::default() + }], + ..Default::default() + }; + let additional = BilibiliDashManifest { + video_streams: vec![BilibiliDashVideoStream { + id: 80, + codecid: 12, + codecs: "hev1.1.6.L120.90".to_string(), + base_url: "https://cdn.example/hevc.m4s".to_string(), ..Default::default() }], + audio_streams: vec![ + primary.audio_streams[0].clone(), + BilibiliDashAudioStream { + id: 30_251, + codecs: "fLaC".to_string(), + base_url: "https://cdn.example/flac.m4s".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + + bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &primary, + Some(&additional), + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: None, + }, + )?; + + let PlaybackMetadata::Bilibili(metadata) = metadata else { + anyhow::bail!("expected Bilibili metadata"); + }; + let Some(manifest) = metadata.dash_manifest else { + anyhow::bail!("expected unified DASH manifest"); + }; + assert_eq!(manifest.video_streams.len(), 2); + assert_eq!(manifest.audio_streams.len(), 2); + assert_eq!(manifest.audio_streams[1].codecs, "fLaC"); + Ok(()) + } + + #[test] + fn dash_manifest_groups_each_codec_in_a_stable_adaptation_set() -> TestResult { + let video = |id: u64, codecs: &str| BilibiliDashVideoStream { + id, + codecid: u32::try_from(id).expect("test stream id fits u32"), + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/video-{id}.m4s"), + backup_urls: vec![format!("https://backup.example/video-{id}.m4s")], + ..Default::default() + }; + let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/audio-{id}.m4s"), + ..Default::default() + }; + let manifest = BilibiliDashManifest { + video_streams: vec![ + video(1, "avc1.640033"), + video(2, "hev1.1.6.L120.90"), + video(3, "av01.0.08M.08"), + video(4, "vp09.00.21.08"), + ], + audio_streams: vec![ + audio(30_280, "mp4a.40.2"), + audio(30_250, "ec-3"), + audio(30_251, "fLaC"), + ], + duration: 10.0, + min_buffer_time: 1.5, + }; + + let mpd = build_bilibili_mpd_manifest(&manifest, |_index, url| url.to_string())?; + + for (id, label) in [ + (100, "H.264"), + (110, "HEVC"), + (120, "AV1"), + (130, "VP9"), + (200, "AAC"), + (210, "E-AC-3"), + (230, "FLAC"), + ] { + assert!(mpd.contains(&format!("{label}"))); + } + assert_eq!(mpd.matches("")); + assert!(mpd.contains("id=\"video-110-2-2\"")); + assert!(mpd.contains("id=\"audio-230-30251\"")); + Ok(()) + } + + #[test] + fn dash_manifest_keeps_only_codecs_advertised_by_the_client() { + let video = |id: u64, codecs: &str| BilibiliDashVideoStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/video-{id}.m4s"), + ..Default::default() + }; + let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/audio-{id}.m4s"), + ..Default::default() + }; + let dash = BilibiliDashManifest { + video_streams: vec![ + video(32, "avc1.64001F"), + video(80, "avc1.640032"), + video(120, "avc1.640033"), + ], + audio_streams: vec![ + audio(30_280, "mp4a.40.2"), + audio(30_250, "ec-3"), + audio(30_251, "fLaC"), + ], + ..Default::default() + }; + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Aac), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some("avc1.64001F,mp4a.40.2".to_string()), + }], + ..PlaybackClientProfile::default() + }; + + let filtered = filter_bilibili_dash_manifest(&dash, Some(&profile)); + + assert_eq!(filtered.video_streams.len(), 1); + assert_eq!(filtered.video_streams[0].codecs, "avc1.64001F"); + assert_eq!(filtered.audio_streams.len(), 1); + assert_eq!(filtered.audio_streams[0].codecs, "mp4a.40.2"); + } + + #[test] + fn dash_playback_metadata_contains_only_client_compatible_codecs() -> TestResult { + let video = |id: u64, codecs: &str| BilibiliDashVideoStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/video-{id}.m4s?deadline=100"), + ..Default::default() + }; + let audio = |id: u64, codecs: &str| BilibiliDashAudioStream { + id, + codecs: codecs.to_string(), + base_url: format!("https://cdn.example/audio-{id}.m4s?deadline=100"), ..Default::default() }; + let dash = BilibiliDashManifest { + video_streams: vec![ + video(32, "avc1.64001F"), + video(80, "avc1.640032"), + video(120, "av01.0.08M.08"), + ], + audio_streams: vec![audio(30_280, "mp4a.40.2"), audio(30_250, "ec-3")], + ..Default::default() + }; + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Aac), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some("avc1.64001F,mp4a.40.2".to_string()), + }], + ..PlaybackClientProfile::default() + }; let mut metadata = PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + let (infos, default_mode) = bilibili_dash_playback_infos( &mut metadata, "video:bvid:BV1test:cid:42", - None, &dash, None, - &[], - &[], + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: Some(&profile), + }, )?; - assert_eq!(default_mode, "h264"); - assert_eq!(infos.len(), 3); - assert!(infos.contains_key("h264")); - assert!(infos.contains_key("av1")); - assert!(infos.contains_key("hevc")); - for (mode_name, label) in [("h264", "H.264"), ("av1", "AV1"), ("hevc", "HEVC")] { - let medias = &infos[mode_name].medias; - assert_eq!(medias.len(), 1); - assert_eq!(medias[0].name, label); - assert_eq!(medias[0].format, "mpd"); - assert!(matches!( - &medias[0].provider, - PlaybackMediaProvider::Bilibili( - PlaybackBilibiliMedia::DirectDashManifest { - mode_name: manifest_mode, - .. - } - ) if manifest_mode == mode_name - )); - } + assert_eq!(default_mode, "dash"); + assert_eq!(infos.len(), 1); let PlaybackMetadata::Bilibili(metadata) = metadata else { anyhow::bail!("expected Bilibili metadata"); }; - let Some(h264_manifest) = metadata.dash_manifests.h264.as_ref() else { - anyhow::bail!("expected H.264 DASH manifest"); + let Some(manifest) = metadata.dash_manifest else { + anyhow::bail!("expected unified DASH manifest"); }; - let Some(av1_manifest) = metadata.dash_manifests.av1.as_ref() else { - anyhow::bail!("expected AV1 DASH manifest"); + assert_eq!( + manifest + .video_streams + .iter() + .map(|stream| stream.codecs.as_str()) + .collect::>(), + vec!["avc1.64001F"] + ); + assert_eq!( + manifest + .audio_streams + .iter() + .map(|stream| stream.codecs.as_str()) + .collect::>(), + vec!["mp4a.40.2"] + ); + Ok(()) + } + + #[test] + fn dash_playback_reports_incompatible_video_and_audio_separately() { + let dash = BilibiliDashManifest { + video_streams: vec![BilibiliDashVideoStream { + id: 32, + codecs: "avc1.64001F".to_string(), + base_url: "https://cdn.example/video.m4s?deadline=100".to_string(), + ..Default::default() + }], + audio_streams: vec![BilibiliDashAudioStream { + id: 30_280, + codecs: "mp4a.40.2".to_string(), + base_url: "https://cdn.example/audio.m4s?deadline=100".to_string(), + ..Default::default() + }], + ..Default::default() }; - let Some(hevc_manifest) = metadata.dash_manifests.hevc.as_ref() else { - anyhow::bail!("expected HEVC DASH manifest"); + let profile = |video_codec, audio_codec, codec_string: &str| PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(video_codec), + audio_codec: Some(audio_codec), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some(codec_string.to_string()), + }], + ..PlaybackClientProfile::default() }; - assert_eq!(h264_manifest.video_streams.len(), 1); - assert_eq!(av1_manifest.video_streams.len(), 1); - assert_eq!(hevc_manifest.video_streams.len(), 1); - assert!(av1_manifest - .audio_streams - .iter() - .any(|stream| stream.base_url.contains("audio.m4s"))); - Ok(()) + + let unsupported_video = profile( + PlaybackVideoCodec::Av1, + PlaybackAudioCodec::Aac, + "av01.0.08M.08,mp4a.40.2", + ); + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + let video_error = bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &dash, + None, + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: Some(&unsupported_video), + }, + ) + .expect_err("the client does not advertise the upstream video codec"); + assert!(matches!( + video_error, + super::ProviderError::ClientIncompatible { + required_capability: Some(ref capability), + .. + } if capability == "dash_video_codec_string" + )); + + let unsupported_audio = profile( + PlaybackVideoCodec::H264, + PlaybackAudioCodec::Eac3, + "avc1.64001F,ec-3", + ); + let mut metadata = + PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video)); + let audio_error = bilibili_dash_playback_infos( + &mut metadata, + "video:bvid:BV1test:cid:42", + &dash, + None, + BilibiliDashPlaybackOptions { + provider_instance_name: None, + subtitles: &[], + danmakus: &[], + client_profile: Some(&unsupported_audio), + }, + ) + .expect_err("the client does not advertise the upstream audio codec"); + assert!(matches!( + audio_error, + super::ProviderError::ClientIncompatible { + required_capability: Some(ref capability), + .. + } if capability == "dash_audio_codec_string" + )); } #[test] - fn dash_modes_keep_codec_specific_proxy_routes() { + fn unified_dash_keeps_one_proxy_route_and_a_content_level_swarm() { let source = super::playback_media( "DASH".to_string(), "mpd".to_string(), @@ -5438,7 +5927,7 @@ mod tests { ); let mut result = PlaybackResult { playback_infos: HashMap::from([( - "h264".to_string(), + "dash".to_string(), PlaybackInfo { thumbnail: None, medias: vec![source], @@ -5449,7 +5938,7 @@ mod tests { default_danmaku_index: None, }, )]), - default_mode: "h264".to_string(), + default_mode: "dash".to_string(), provider: crate::models::SourceProvider::Bilibili, provider_instance_name: None, duration_seconds: Some(10.0), @@ -5457,26 +5946,33 @@ mod tests { metadata: Some(PlaybackMetadata::Bilibili(BilibiliPlaybackMetadata { bvid: Some("BV1test".to_string()), cid: Some(42), - dash_manifests: BilibiliDashManifests { - h264: Some(BilibiliDashManifest { - video_streams: vec![BilibiliDashVideoStream { + dash_manifest: Some(BilibiliDashManifest { + video_streams: vec![ + BilibiliDashVideoStream { + id: 32, + codecs: "avc1.64001F".to_string(), base_url: "https://cdn.example/dash.m4s?deadline=100".to_string(), ..Default::default() - }], - ..Default::default() - }), - av1: None, - hevc: Some(BilibiliDashManifest { - video_streams: vec![BilibiliDashVideoStream { + }, + BilibiliDashVideoStream { + id: 80, + codecs: "hev1.1.6.L120.90".to_string(), base_url: "https://cdn.example/hevc.m4s?deadline=200".to_string(), ..Default::default() - }], + }, + ], + audio_streams: vec![BilibiliDashAudioStream { + id: 30_250, + codecs: "ec-3".to_string(), + base_url: "https://cdn.example/eac3.m4s?deadline=150".to_string(), ..Default::default() - }), - }, + }], + ..Default::default() + }), ..BilibiliPlaybackMetadata::new(BilibiliPlaybackKind::Video) })), }; + let mut eac3_result = result.clone(); mark_bilibili_playback_resources( &mut result, @@ -5486,11 +5982,11 @@ mod tests { None, ); - assert_eq!(result.default_mode, "proxy_h264"); - assert!(!result.playback_infos.contains_key("h264")); - let proxied = &result.playback_infos["proxy_h264"].medias; + assert_eq!(result.default_mode, "proxy_dash"); + assert!(!result.playback_infos.contains_key("dash")); + let proxied = &result.playback_infos["proxy_dash"].medias; assert_eq!(proxied.len(), 1); - assert_eq!(proxied[0].name, "H.264"); + assert_eq!(proxied[0].name, "DASH"); assert_eq!( proxied[0].expire_at.map(|value| value.timestamp()), Some(100) @@ -5500,9 +5996,37 @@ mod tests { PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::ProxyDashManifest { mode_name, .. - }) if mode_name == "h264" + }) if mode_name == "dash" )); - assert!(proxied.iter().all(|media| media.p2p_swarm_id.is_some())); + assert_eq!( + proxied[0].p2p_swarm_id.as_deref(), + Some(bilibili_dash_manifest_swarm_id(None, "video:bvid:BV1test:cid:42").as_str()) + ); + + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(crate::provider::PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Eac3), + pipeline: PlaybackMediaPipeline::Native, + codec_string: Some("avc1.64001F,ec-3".to_string()), + }], + ..PlaybackClientProfile::default() + }; + let context = ProviderContext::new("test", ProviderActor::System) + .with_playback_client_profile(Some(profile)); + mark_bilibili_playback_resources( + &mut eac3_result, + "version", + 123, + crate::models::PlaybackProxyMode::Prefer, + Some(&context), + ); + + assert!(eac3_result.playback_infos.contains_key("dash")); + assert!(eac3_result.playback_infos.contains_key("proxy_dash")); } #[test] @@ -5839,9 +6363,12 @@ impl BilibiliProvider { manifest_mode: BilibiliDashManifestMode, request_context: Option<&super::ExecutionControl>, ) -> Result { + if mode_name != BILIBILI_DASH_MODE_NAME { + return Err(ProviderError::NotFound); + } let versioned = super::playback_transport::lookup_versioned(store, version, request_context).await?; - let dash = dash_manifest_from_metadata(&versioned.result, mode_name)?; + let dash = dash_manifest_from_metadata(&versioned.result)?; let body = build_bilibili_mpd_manifest(&dash, |_index, url| url.to_string())?; Ok(match manifest_mode { BilibiliDashManifestMode::Direct => { @@ -5879,13 +6406,16 @@ impl BilibiliProvider { }, ) } else { + if request.mode_name != BILIBILI_DASH_MODE_NAME { + return Err(ProviderError::NotFound); + } let versioned = super::playback_transport::lookup_versioned( store, request.version, request_context, ) .await?; - let dash = dash_manifest_from_metadata(&versioned.result, request.mode_name)?; + let dash = dash_manifest_from_metadata(&versioned.result)?; let urls = bilibili_dash_resource_candidates( &dash, request.scope_url, @@ -6096,69 +6626,15 @@ fn bilibili_content_descriptor(result: &PlaybackResult) -> Option { } } -fn bilibili_dash_swarm_id( +fn bilibili_dash_manifest_swarm_id( provider_instance_name: Option<&str>, content_descriptor: &str, - slot: BilibiliDashManifestSlot, - dash: &BilibiliDashManifest, ) -> String { - let mut streams = dash - .video_streams - .iter() - .map(|stream| { - format!( - "video:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", - stream.id, - stream.quality_name, - stream.mime_type, - stream.codecs, - stream.width, - stream.height, - stream.frame_rate, - stream.bandwidth, - stream.codecid, - stream.sar, - stream - .segment_base - .as_ref() - .map_or("", |segment| segment.index_range.as_str()), - stream - .segment_base - .as_ref() - .map_or("", |segment| segment.initialization_range.as_str()), - ) - }) - .chain(dash.audio_streams.iter().map(|stream| { - format!( - "audio:{}:{}:{}:{}:{}:{}:{}:{}:{}", - stream.id, - stream.quality_name, - stream.mime_type, - stream.codecs, - stream.bandwidth, - stream.start_with_sap, - stream.audio_sampling_rate, - stream - .segment_base - .as_ref() - .map_or("", |segment| segment.index_range.as_str()), - stream - .segment_base - .as_ref() - .map_or("", |segment| segment.initialization_range.as_str()), - ) - })) - .collect::>(); - streams.sort_unstable(); super::provider_p2p_swarm_id( BilibiliProvider::NAME, provider_instance_name, - "media", - &format!( - "{content_descriptor}:dash:{}:streams:{}", - slot.as_str(), - streams.join("\n") - ), + "dash-media-set", + &format!("{content_descriptor}:schema:{BILIBILI_DASH_SWARM_SCHEMA_VERSION}"), ) } @@ -6455,7 +6931,8 @@ impl BilibiliProvider { request_bvid.clone(), cid, ); - let dash_resp = match client.get_dash_video_url(request).await { + let dash_resp = client.get_dash_video_url(request).await; + let dash_resp = match dash_resp { Ok(dash_resp) if dash_resp.dash.is_some() => Some(dash_resp), Ok(_) => None, Err(error) if is_bilibili_video_dash_unavailable(&error) => None, @@ -6528,11 +7005,14 @@ impl BilibiliProvider { let (dash_infos, default_mode) = bilibili_dash_playback_infos( &mut metadata, &content_descriptor, - provider_instance_name, &primary_dash, hevc_dash.as_ref(), - &subtitles, - &danmakus, + BilibiliDashPlaybackOptions { + provider_instance_name, + subtitles: &subtitles, + danmakus: &danmakus, + client_profile: ctx.playback_client_profile(), + }, )?; playback_infos.extend(dash_infos); default_mode @@ -6587,7 +7067,25 @@ impl BilibiliProvider { let content_descriptor = bilibili_pgc_content_descriptor(epid, cid); let request = bilibili_dash_pgc_url_request(&sanitized_cookies, epid, cid); - let dash_resp = match client.get_dash_pgcurl(request).await { + let first_dash_resp = client.get_dash_pgcurl(request.clone()).await; + let dash_resp = if matches!( + &first_dash_resp, + Ok(response) if response.dash.is_none() + ) || matches!( + &first_dash_resp, + Err(error) if is_bilibili_pgc_dash_unavailable(error) + ) { + tracing::warn!( + epid, + cid, + "Bilibili PGC DASH response was empty; retrying once before DURL fallback" + ); + tokio::time::sleep(BILIBILI_DASH_RETRY_DELAY).await; + client.get_dash_pgcurl(request).await + } else { + first_dash_resp + }; + let dash_resp = match dash_resp { Ok(dash_resp) if dash_resp.dash.is_some() => Some(dash_resp), Ok(_) => None, Err(error) if is_bilibili_pgc_dash_unavailable(&error) => None, @@ -6659,11 +7157,14 @@ impl BilibiliProvider { let (dash_infos, default_mode) = bilibili_dash_playback_infos( &mut metadata, &content_descriptor, - provider_instance_name, &primary_dash, hevc_dash.as_ref(), - &subtitles, - &danmakus, + BilibiliDashPlaybackOptions { + provider_instance_name, + subtitles: &subtitles, + danmakus: &danmakus, + client_profile: ctx.playback_client_profile(), + }, )?; playback_infos.extend(dash_infos); default_mode diff --git a/synctv-core/src/provider/mod.rs b/synctv-core/src/provider/mod.rs index fcf8aa1c..76d2d4f8 100644 --- a/synctv-core/src/provider/mod.rs +++ b/synctv-core/src/provider/mod.rs @@ -169,7 +169,7 @@ pub(crate) fn playback_media_requirements( PlaybackMediaTransport::Dash, Some(PlaybackContainer::Mp4), None, - Some(PlaybackAudioCodec::Aac), + (!bilibili_unified_dash_media(media)).then_some(PlaybackAudioCodec::Aac), ), "m3u8" | "hls" => ( PlaybackMediaTransport::Hls, @@ -223,12 +223,22 @@ pub(crate) fn playback_media_requirements( }) } +fn bilibili_unified_dash_media(media: &PlaybackMedia) -> bool { + matches!( + &media.provider, + PlaybackMediaProvider::Bilibili( + crate::models::PlaybackBilibiliMedia::DirectDashManifest { mode_name, .. } + | crate::models::PlaybackBilibiliMedia::ProxyDashManifest { mode_name, .. } + ) if mode_name.eq_ignore_ascii_case("dash") + ) +} + pub(crate) fn playback_media_supported_by_client( profile: Option<&PlaybackClientProfile>, mode_name: &str, media: &PlaybackMedia, ) -> bool { - let Some(profile) = profile.filter(|profile| profile.uses_explicit_capabilities()) else { + let Some(profile) = profile else { return true; }; playback_media_requirements(mode_name, media).is_some_and(|requirements| { @@ -246,7 +256,7 @@ pub(crate) fn direct_playback_media_supported_by_client( mode_name: &str, media: &PlaybackMedia, ) -> bool { - let Some(profile) = profile.filter(|profile| profile.uses_explicit_capabilities()) else { + let Some(profile) = profile else { return true; }; if !media.upstream_headers().is_empty() && !profile.supports_custom_http_headers { @@ -296,7 +306,7 @@ pub(crate) fn proxy_playback_media_supported_by_client( media: &PlaybackMedia, ) -> bool { profile.is_none_or(|profile| { - (!profile.uses_explicit_capabilities() || profile.supports_provider_proxy) + profile.supports_provider_proxy && playback_media_supported_by_client(Some(profile), mode_name, media) }) } @@ -309,7 +319,7 @@ pub(crate) fn require_compatible_playback_route( if !result.playback_infos.is_empty() { return Ok(result); } - if profile.is_some_and(PlaybackClientProfile::uses_explicit_capabilities) { + if profile.is_some() { let required_capability = if matches!( proxy_mode, crate::models::PlaybackProxyMode::Only | crate::models::PlaybackProxyMode::Auto @@ -369,7 +379,7 @@ pub(crate) fn build_direct_playback_info_for_client( if source.medias.is_empty() { return None; } - let Some(profile) = profile.filter(|profile| profile.uses_explicit_capabilities()) else { + let Some(profile) = profile else { return Some(source.clone()); }; let (medias, default_media_index) = @@ -1820,8 +1830,9 @@ where mod playback_route_capability_tests { use super::*; use crate::models::media::{ - PlaybackDanmakuProvider, PlaybackDirectUrlDanmaku, PlaybackDirectUrlMedia, - PlaybackDirectUrlSubtitle, PlaybackMediaProvider, PlaybackSubtitleProvider, + PlaybackBilibiliMedia, PlaybackDanmakuProvider, PlaybackDirectUrlDanmaku, + PlaybackDirectUrlMedia, PlaybackDirectUrlSubtitle, PlaybackMediaProvider, + PlaybackSubtitleProvider, }; use std::collections::HashMap; @@ -2059,6 +2070,55 @@ mod playback_route_capability_tests { assert!(filtered.playback_infos.contains_key("hls")); } + #[test] + fn bilibili_unified_dash_route_accepts_a_non_aac_audio_capability() { + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Eac3), + pipeline: PlaybackMediaPipeline::Native, + codec_string: Some("avc1.64001F,ec-3".to_string()), + }], + ..PlaybackClientProfile::default() + }; + let media = media_with_format( + "mpd", + PlaybackMediaProvider::Bilibili(PlaybackBilibiliMedia::DirectDashManifest { + version: "v1".to_string(), + expires_at: 1, + mode_name: "dash".to_string(), + headers: HashMap::new(), + }), + ); + + assert!(direct_playback_media_supported_by_client( + Some(&profile), + "dash", + &media + )); + assert!(proxy_playback_media_supported_by_client( + Some(&profile), + "dash", + &media + )); + + let generic = media_with_format( + "mpd", + PlaybackMediaProvider::DirectUrl(PlaybackDirectUrlMedia::Direct { + url: "https://cdn.example.test/video.mpd".to_string(), + headers: HashMap::new(), + }), + ); + assert!(!direct_playback_media_supported_by_client( + Some(&profile), + "dash", + &generic + )); + } + #[test] fn secure_web_client_replaces_insecure_direct_media_with_proxy() { let direct = media(PlaybackMediaProvider::DirectUrl( diff --git a/synctv-core/src/provider/playback_profile.rs b/synctv-core/src/provider/playback_profile.rs index 541c0b47..70772710 100644 --- a/synctv-core/src/provider/playback_profile.rs +++ b/synctv-core/src/provider/playback_profile.rs @@ -252,7 +252,7 @@ pub struct PlaybackClientProfile { impl Default for PlaybackClientProfile { fn default() -> Self { Self { - profile_version: 0, + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, environment: PlaybackClientEnvironment::Native, stream_preference: PlaybackStreamPreference::Auto, max_streaming_bitrate: None, @@ -280,11 +280,6 @@ impl Default for PlaybackClientProfile { } impl PlaybackClientProfile { - #[must_use] - pub const fn uses_explicit_capabilities(&self) -> bool { - self.profile_version >= CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION - } - #[must_use] pub const fn is_web(&self) -> bool { matches!(self.environment, PlaybackClientEnvironment::Web) @@ -292,47 +287,23 @@ impl PlaybackClientProfile { #[must_use] pub fn supports_transport(&self, transport: PlaybackMediaTransport) -> bool { - if self.uses_explicit_capabilities() { - return self - .media_capabilities - .iter() - .any(|capability| capability.transport == transport); - } - - match transport { - PlaybackMediaTransport::Progressive | PlaybackMediaTransport::Dash => true, - PlaybackMediaTransport::Hls => self - .supported_live_transports - .contains(&PlaybackLiveTransport::Hls), - PlaybackMediaTransport::Flv | PlaybackMediaTransport::MpegTs => self - .supported_live_transports - .contains(&PlaybackLiveTransport::Flv), - PlaybackMediaTransport::WebRtc => self - .supported_live_transports - .contains(&PlaybackLiveTransport::Whep), - } + self.media_capabilities + .iter() + .any(|capability| capability.transport == transport) } #[must_use] pub fn supports_video_codec(&self, codec: PlaybackVideoCodec) -> bool { - if self.uses_explicit_capabilities() { - return self - .media_capabilities - .iter() - .any(|capability| capability.video_codec.is_none_or(|value| value == codec)); - } - self.supported_video_codecs.contains(&codec) + self.media_capabilities + .iter() + .any(|capability| capability.video_codec.is_none_or(|value| value == codec)) } #[must_use] pub fn supports_container(&self, container: PlaybackContainer) -> bool { - if self.uses_explicit_capabilities() { - return self - .media_capabilities - .iter() - .any(|capability| capability.container.is_none_or(|value| value == container)); - } - self.supported_containers.contains(&container) + self.media_capabilities + .iter() + .any(|capability| capability.container.is_none_or(|value| value == container)) } #[must_use] @@ -343,10 +314,31 @@ impl PlaybackClientProfile { video_codec: Option, audio_codec: Option, ) -> bool { - if !self.uses_explicit_capabilities() { - return self.supports_transport(transport) - && container.is_none_or(|value| self.supports_container(value)) - && video_codec.is_none_or(|value| self.supports_video_codec(value)); + self.media_capabilities.iter().any(|capability| { + capability.transport == transport + && container + .is_none_or(|value| capability.container.is_none_or(|item| item == value)) + && video_codec + .is_none_or(|value| capability.video_codec.is_none_or(|item| item == value)) + && audio_codec + .is_none_or(|value| capability.audio_codec.is_none_or(|item| item == value)) + }) + } + + /// Checks one codec token against the exact strings advertised for a media route. + /// Capabilities without a codec string remain family-level wildcards for native clients. + #[must_use] + pub fn supports_codec_string( + &self, + transport: PlaybackMediaTransport, + container: Option, + video_codec: Option, + audio_codec: Option, + codec_string: &str, + ) -> bool { + let codec_string = codec_string.trim(); + if codec_string.is_empty() { + return false; } self.media_capabilities.iter().any(|capability| { @@ -357,6 +349,12 @@ impl PlaybackClientProfile { .is_none_or(|value| capability.video_codec.is_none_or(|item| item == value)) && audio_codec .is_none_or(|value| capability.audio_codec.is_none_or(|item| item == value)) + && capability.codec_string.as_deref().is_none_or(|advertised| { + advertised + .split(',') + .map(str::trim) + .any(|codec| codec.eq_ignore_ascii_case(codec_string)) + }) }) } @@ -369,9 +367,6 @@ impl PlaybackClientProfile { audio_codec: Option, pipeline: PlaybackMediaPipeline, ) -> bool { - if !self.uses_explicit_capabilities() { - return self.supports_media(transport, container, video_codec, audio_codec); - } self.media_capabilities.iter().any(|capability| { capability.transport == transport && capability.pipeline == pipeline @@ -555,4 +550,80 @@ mod tests { Some(PlaybackAudioCodec::Aac), )); } + + #[test] + fn exact_codec_support_matches_individual_tokens_case_insensitively() { + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Aac), + pipeline: PlaybackMediaPipeline::MediaSource, + codec_string: Some("avc1.64001F,mp4a.40.2".to_string()), + }], + ..PlaybackClientProfile::default() + }; + + assert!(profile.supports_codec_string( + PlaybackMediaTransport::Dash, + Some(PlaybackContainer::Mp4), + Some(PlaybackVideoCodec::H264), + None, + "AVC1.64001f", + )); + assert!(profile.supports_codec_string( + PlaybackMediaTransport::Dash, + Some(PlaybackContainer::Mp4), + None, + Some(PlaybackAudioCodec::Aac), + "mp4a.40.2", + )); + assert!(!profile.supports_codec_string( + PlaybackMediaTransport::Dash, + Some(PlaybackContainer::Mp4), + Some(PlaybackVideoCodec::H264), + None, + "avc1.640033", + )); + } + + #[test] + fn missing_codec_string_keeps_family_level_support() { + let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + media_capabilities: vec![PlaybackMediaCapability { + transport: PlaybackMediaTransport::Dash, + container: Some(PlaybackContainer::Mp4), + video_codec: Some(PlaybackVideoCodec::H264), + audio_codec: Some(PlaybackAudioCodec::Aac), + pipeline: PlaybackMediaPipeline::Native, + codec_string: None, + }], + ..PlaybackClientProfile::default() + }; + + assert!(profile.supports_codec_string( + PlaybackMediaTransport::Dash, + Some(PlaybackContainer::Mp4), + Some(PlaybackVideoCodec::H264), + None, + "avc1.640033", + )); + assert!(profile.supports_codec_string( + PlaybackMediaTransport::Dash, + Some(PlaybackContainer::Mp4), + None, + Some(PlaybackAudioCodec::Aac), + "mp4a.40.2", + )); + assert!(!profile.supports_codec_string( + PlaybackMediaTransport::Dash, + Some(PlaybackContainer::Mp4), + Some(PlaybackVideoCodec::Hevc), + None, + "hvc1.1.6.L120.90", + )); + } } diff --git a/synctv-media-providers/src/bilibili/client.rs b/synctv-media-providers/src/bilibili/client.rs index 6c0fdd9e..14e18df0 100644 --- a/synctv-media-providers/src/bilibili/client.rs +++ b/synctv-media-providers/src/bilibili/client.rs @@ -76,6 +76,46 @@ fn video_segments_from_durls(durls: &[types::DurlInfo]) -> Vec { .collect() } +fn dash_video_query_params(aid: u64, bvid: &str, cid: u64) -> Vec<(&'static str, String)> { + let mut params = vec![ + ("cid", cid.to_string()), + ("qn", "127".to_string()), + ("fnver", "0".to_string()), + ("fnval", "4048".to_string()), + ("fourk", "1".to_string()), + ]; + if bvid.is_empty() { + params.push(("aid", aid.to_string())); + } else { + params.push(("bvid", bvid.to_string())); + } + params +} + +fn parse_dash_video_response( + json: types::DashVideoResp, +) -> Result<(DashData, DashData), BilibiliError> { + if json.code != 0 { + return Err(bilibili_api_error(i64::from(json.code), "DASH video URL")); + } + let data = json.data.ok_or_else(|| BilibiliError::Api { + code: i64::from(json.code), + message: "DASH video URL response missing payload".to_string(), + })?; + let dash_info = data.dash.ok_or_else(|| BilibiliError::Api { + code: i64::from(json.code), + message: "DASH video URL response did not include DASH streams".to_string(), + })?; + let (dash, hevc_dash) = parse_dash_info(&dash_info, &data.support_formats); + if dash.video_streams.is_empty() && hevc_dash.video_streams.is_empty() { + return Err(BilibiliError::Api { + code: i64::from(json.code), + message: "DASH video URL response did not include playable video streams".to_string(), + }); + } + Ok((dash, hevc_dash)) +} + fn quality_to_u32(quality: u64, endpoint: &'static str) -> Result { u32::try_from(quality) .map_err(|_| BilibiliError::Parse(format!("{endpoint} quality {quality} exceeds u32"))) @@ -1828,22 +1868,44 @@ impl BilibiliClient { bvid: &str, cid: u64, ) -> Result<(DashData, DashData), BilibiliError> { - // First attempt with cached key let result = self .get_dash_video_url_internal(aid, bvid, cid, false) .await; - // If we get a WBI stale error, retry once with fresh key - if let Err(ref e) = result { - if Self::is_wbi_stale_error(e) { + let result = match result { + Err(ref error) if Self::is_wbi_stale_error(error) => { tracing::warn!("WBI key appears stale, refreshing and retrying"); - return self.get_dash_video_url_internal(aid, bvid, cid, true).await; + self.get_dash_video_url_internal(aid, bvid, cid, true).await } + result => result, + }; + + if result + .as_ref() + .is_err_and(Self::is_missing_dash_streams_error) + { + tracing::warn!( + bvid, + aid, + cid, + "Bilibili WBI playurl response omitted DASH streams; retrying with standard playurl" + ); + return self + .get_dash_video_url_from_standard_endpoint(aid, bvid, cid) + .await; } result } + fn is_missing_dash_streams_error(error: &BilibiliError) -> bool { + matches!( + error, + BilibiliError::Api { code: 0, message } + if message.as_bytes().starts_with(b"DASH video URL response") + ) + } + /// Internal method for DASH video URL with optional key refresh async fn get_dash_video_url_internal( &self, @@ -1857,26 +1919,21 @@ impl BilibiliClient { let client = self.client.clone(); let cookie_header = self.build_cookie_header(); let bvid = bvid.to_string(); + let endpoint = self.endpoints.api_url("/x/player/wbi/playurl"); with_retry(|| { let client = client.clone(); let cookie_header = cookie_header.clone(); let mixin_key = mixin_key.clone(); let bvid = bvid.clone(); + let endpoint = endpoint.clone(); async move { - // Build query parameters - let mut params: Vec<(&str, String)> = - vec![("cid", cid.to_string()), ("fnval", "4048".to_string())]; - if bvid.is_empty() { - params.push(("aid", aid.to_string())); - } else { - params.push(("bvid", bvid.clone())); - } + let params = dash_video_query_params(aid, &bvid, cid); // Sign parameters with WBI (re-signs on each retry for fresh wts) let signed_params = wbi_sign(¶ms, &mixin_key); - let mut req = client.get("https://api.bilibili.com/x/player/wbi/playurl"); + let mut req = client.get(&endpoint); req = req.query(&signed_params); req = req.header("Referer", REFERER); if let Some(ref cookies) = cookie_header { @@ -1884,22 +1941,41 @@ impl BilibiliClient { } let resp = check_response(req.send().await?).await?; let json: types::DashVideoResp = json_with_limit(resp).await?; + parse_dash_video_response(json) + } + }) + .await + } - if json.code != 0 { - return Err(bilibili_api_error(i64::from(json.code), "DASH video URL")); - } + async fn get_dash_video_url_from_standard_endpoint( + &self, + aid: u64, + bvid: &str, + cid: u64, + ) -> Result<(DashData, DashData), BilibiliError> { + let client = self.client.clone(); + let cookie_header = self.build_cookie_header(); + let bvid = bvid.to_string(); + let endpoint = self.endpoints.api_url("/x/player/playurl"); - // Parse DASH data into structured format - let data = json.data.ok_or_else(|| { - BilibiliError::Parse("DASH video URL response missing payload".to_string()) - })?; - let dash_info = data.dash.ok_or_else(|| BilibiliError::Api { - code: i64::from(json.code), - message: "DASH video URL response did not include DASH streams".to_string(), - })?; - let (regular_dash, hevc_dash) = parse_dash_info(&dash_info, &data.support_formats); + with_retry(|| { + let client = client.clone(); + let cookie_header = cookie_header.clone(); + let bvid = bvid.clone(); + let endpoint = endpoint.clone(); + async move { + let params = dash_video_query_params(aid, &bvid, cid); - Ok((regular_dash, hevc_dash)) + let mut req = client + .get(&endpoint) + .query(¶ms) + .header("Referer", REFERER); + if let Some(ref cookies) = cookie_header { + req = req.header("Cookie", cookies.as_str()); + } + let resp = check_response(req.send().await?).await?; + let json: types::DashVideoResp = json_with_limit(resp).await?; + parse_dash_video_response(json) } }) .await diff --git a/synctv-media-providers/src/bilibili/client_tests.rs b/synctv-media-providers/src/bilibili/client_tests.rs index 405e3917..ddad3183 100644 --- a/synctv-media-providers/src/bilibili/client_tests.rs +++ b/synctv-media-providers/src/bilibili/client_tests.rs @@ -76,6 +76,119 @@ fn nav_response_with_wbi_keys(img_key: &str, sub_key: &str) -> serde_json::Value }) } +fn dash_video_response() -> serde_json::Value { + json!({ + "code": 0, + "ttl": 1, + "data": { + "support_formats": [{ + "quality": 80, + "new_description": "1080P" + }], + "dash": { + "duration": 10.0, + "minBufferTime": 1.5, + "video": [{ + "id": 80, + "baseUrl": "https://cdn.example/video.m4s", + "backupUrl": [], + "mimeType": "video/mp4", + "codecs": "avc1.640028", + "width": 1920, + "height": 1080, + "frameRate": "30", + "bandwidth": 1_000_000, + "codecid": 7, + "sar": "1:1", + "startWithSap": 1, + "SegmentBase": { + "Initialization": "0-999", + "indexRange": "1000-1999" + } + }], + "audio": [{ + "id": 30280, + "baseUrl": "https://cdn.example/audio.m4s", + "backupUrl": [], + "mimeType": "audio/mp4", + "codecs": "mp4a.40.2", + "bandwidth": 192_000, + "audioSamplingRate": 48_000, + "startWithSap": 1, + "SegmentBase": { + "Initialization": "0-999", + "indexRange": "1000-1999" + } + }] + } + } + }) +} + +#[tokio::test] +async fn dash_video_falls_back_to_standard_playurl_when_wbi_omits_dash() -> TestResult { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/x/web-interface/nav")) + .respond_with( + ResponseTemplate::new(200).set_body_json(nav_response_with_wbi_keys( + "7cd084941338484aae1ad9425b84077c", + "4932caff0ff746eab6f01bf08b70ac45", + )), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/x/player/wbi/playurl")) + .and(query_param("bvid", "BV1test")) + .and(query_param("cid", "42")) + .and(query_param("qn", "127")) + .and(query_param("fnver", "0")) + .and(query_param("fnval", "4048")) + .and(query_param("fourk", "1")) + .and(header("cookie", "SESSDATA=session")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "code": 0, + "ttl": 1, + "data": { + "support_formats": [], + "dash": null + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/x/player/playurl")) + .and(query_param("bvid", "BV1test")) + .and(query_param("cid", "42")) + .and(query_param("qn", "127")) + .and(query_param("fnver", "0")) + .and(query_param("fnval", "4048")) + .and(query_param("fourk", "1")) + .and(header("cookie", "SESSDATA=session")) + .respond_with(ResponseTemplate::new(200).set_body_json(dash_video_response())) + .expect(1) + .mount(&server) + .await; + + let client = BilibiliClient::with_cookies_and_transport( + HashMap::from([("SESSDATA".to_string(), "session".to_string())]), + test_http_client(), + test_http_client(), + test_endpoints(server.uri()), + Arc::new(WbiState::default()), + SsrfGuard::disabled(), + ); + let (dash, hevc_dash) = client.get_dash_video_url(0, "BV1test", 42).await?; + + assert_eq!(dash.video_streams.len(), 1); + assert_eq!(dash.audio_streams.len(), 1); + assert!(hevc_dash.video_streams.is_empty()); + Ok(()) +} + #[tokio::test] async fn live_danmaku_info_uses_wbi_signature_and_cached_device_cookies() -> TestResult { let server = MockServer::start().await; diff --git a/synctv-proto/proto/client.proto b/synctv-proto/proto/client.proto index 7d340987..3e203fb0 100644 --- a/synctv-proto/proto/client.proto +++ b/synctv-proto/proto/client.proto @@ -2685,8 +2685,7 @@ message PlaybackClientProfile { PlaybackAudioCapability audio_capability = 6 [(buf.validate.field).enum.defined_only = true]; PlaybackSubtitlePreference subtitle_preference = 7 [(buf.validate.field).enum.defined_only = true]; repeated PlaybackLiveTransport supported_live_transports = 8 [(buf.validate.field).repeated.items.enum.defined_only = true]; - // Version 0 preserves the legacy defaults of fields 1-8. Version 2 gives - // repeated capability fields explicit empty-set semantics. + // Must match the server's current playback client profile version. uint32 profile_version = 9; PlaybackClientEnvironment environment = 10 [(buf.validate.field).enum.defined_only = true]; repeated PlaybackMediaCapability media_capabilities = 11 [(buf.validate.field).repeated.max_items = 64];