fix: bilibili dash codec filter (#459)

pull/460/merge
zijiren 3 weeks ago committed by GitHub
parent 3a98ef42b0
commit 355b7790b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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::<Result<Vec<_>, _>>()?
};
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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?
};
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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?
};
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::<Result<Vec<_>, _>>()?;
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

@ -25,17 +25,8 @@ pub(super) fn validate_include_message_types(values: Vec<i32>) -> AppResult<Vec<
into_params(parameter_in = Query, rename_all = "camelCase")
)]
pub struct GetPlaybackQuery {
/// Base64url-encoded PlaybackClientProfile protobuf. New clients use this
/// lossless representation; the remaining fields are legacy compatibility.
/// Base64url-encoded PlaybackClientProfile protobuf.
pub client_profile: Option<String>,
pub stream_preference: Option<i32>,
pub max_streaming_bitrate: Option<i64>,
pub max_audio_channels: Option<i32>,
pub video_codecs: Option<String>,
pub containers: Option<String>,
pub live_transports: Option<String>,
pub audio_capability: Option<i32>,
pub subtitle_preference: Option<i32>,
}
#[derive(Debug, Default, serde::Deserialize)]
@ -81,14 +72,6 @@ pub struct WatchPlaybackQuery {
pub delivery_mode: Option<i32>,
pub format: Option<String>,
pub client_profile: Option<String>,
pub stream_preference: Option<i32>,
pub max_streaming_bitrate: Option<i64>,
pub max_audio_channels: Option<i32>,
pub video_codecs: Option<String>,
pub containers: Option<String>,
pub live_transports: Option<String>,
pub audio_capability: Option<i32>,
pub subtitle_preference: Option<i32>,
}
pub(crate) fn parse_watch_delivery_mode(value: Option<i32>) -> AppResult<i32> {
@ -137,100 +120,6 @@ pub(crate) fn watch_after_event_sequence(
}
}
fn parse_stream_preference(
value: Option<i32>,
) -> Result<synctv_proto::client::PlaybackStreamPreference, super::super::AppError> {
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<i32>,
) -> Result<synctv_proto::client::PlaybackSubtitlePreference, super::super::AppError> {
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<Vec<i32>, 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::<i32>().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<Vec<i32>, 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::<i32>().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<Vec<i32>, 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::<i32>().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<i32>,
) -> Result<synctv_proto::client::PlaybackAudioCapability, super::super::AppError> {
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<synctv_proto::client::PlaybackClientProfile, super::super::AppError> {
@ -251,43 +140,13 @@ fn decode_client_profile(
pub(crate) fn build_get_playback_request(
query: &GetPlaybackQuery,
) -> AppResult<GetPlaybackRequest> {
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<Option<synctv_proto::client::PlaybackClientProfile>> {
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)
}

@ -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::<GetPlaybackQuery>("streamPreference=2&extra=true")
serde_urlencoded::from_str::<GetPlaybackQuery>("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::<WatchQuery>("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::<DeleteMediaQuery>("force=definitely").is_err());

@ -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<bool>,
#[serde(default, skip_serializing_if = "BilibiliDashManifests::is_empty")]
pub dash_manifests: BilibiliDashManifests,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dash_manifest: Option<BilibiliDashManifest>,
}
#[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<BilibiliDashManifest>,
/// AV1 DASH representations.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub av1: Option<BilibiliDashManifest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hevc: Option<BilibiliDashManifest>,
}
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<Self> {
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 {

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

File diff suppressed because it is too large Load Diff

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

@ -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<PlaybackVideoCodec>,
audio_codec: Option<PlaybackAudioCodec>,
) -> 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<PlaybackContainer>,
video_codec: Option<PlaybackVideoCodec>,
audio_codec: Option<PlaybackAudioCodec>,
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<PlaybackAudioCodec>,
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",
));
}
}

@ -76,6 +76,46 @@ fn video_segments_from_durls(durls: &[types::DurlInfo]) -> Vec<VideoSegment> {
.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, BilibiliError> {
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(&params, &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(&params)
.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

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

@ -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];

Loading…
Cancel
Save