use crate::models::{CodecFamily, FlvSegment, MediaStream, StreamQuality, StreamSet};
use crate::{Error, Result};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
use prost::{Enumeration, Message};
use reqwest::header::{
AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT,
};
use std::io::{Read, Write};
use std::time::Duration;
pub(crate) const NORMAL_PLAYURL_PATH: &str = "/bilibili.app.playurl.v1.PlayURL/PlayView";
pub(crate) const PGC_PLAYURL_PATH: &str = "/bilibili.pgc.gateway.player.v2.PlayURL/PlayView";
const APP_DALVIK_VERSION: &str = "2.1.0";
const APP_OS_VERSION: &str = "11";
const APP_BRAND: &str = "M2012K11AC";
const APP_MODEL: &str = "Build/RKQ1.200826.002";
const APP_VERSION: &str = "7.32.0";
const APP_BUILD: i32 = 7_320_200;
const APP_CHANNEL: &str = "xiaomi_cn_tv.danmaku.bili_zm20200902";
const APP_CRONET_VERSION: &str = "1.36.1";
const APP_MOBI_APP: &str = "android";
const APP_FAUKES_APPKEY: &str = "android64";
const APP_SESSION_ID: &str = "dedf8669";
const APP_PLATFORM: &str = "android";
const APP_ENV: &str = "prod";
const APP_ID: i32 = 1;
const APP_REGION: &str = "CN";
const APP_LANGUAGE: &str = "zh";
const APP_NETWORK_OID: &str = "46007";
pub(crate) const APP_PREVIEW_ONLY_RESTRICTION_MESSAGE: &str =
"APP playurl returned preview-only streams";
pub(crate) fn play_view_request_body(content_id: u64, cid: u64, is_pgc: bool) -> Result<Vec<u8>> {
let content_id = i64::try_from(content_id)
.map_err(|_| Error::InvalidInput("APP playurl content id is too large".to_owned()))?;
let cid = i64::try_from(cid)
.map_err(|_| Error::InvalidInput("APP playurl cid is too large".to_owned()))?;
let request = PlayViewReq {
ep_id: Some(content_id),
cid: Some(cid),
qn: Some(127),
fnver: None,
fnval: Some(4048),
download: Some(0),
force_host: Some(2),
fourk: Some(true),
spmid: Some("main.ugc-video-detail.0.0".to_owned()),
from_spmid: Some("main.my-history.0.0".to_owned()),
teenagers_mode: None,
prefer_codec_type: Some(AppCodeType::Hevc.into()),
is_preview: None,
room_id: None,
is_need_view_info: is_pgc.then_some(true),
};
encode_grpc_frame(&request.encode_to_vec(), true)
}
pub(crate) fn play_view_headers(
access_key: Option<&str>,
request_timeout: Duration,
) -> Result<HeaderMap> {
let access_key = access_key.map(str::trim).filter(|value| !value.is_empty());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/grpc"));
headers.insert(
USER_AGENT,
HeaderValue::from_str(&app_user_agent())
.map_err(|_| Error::InvalidInput("invalid APP playurl user-agent header".to_owned()))?,
);
insert_header(&mut headers, "te", "trailers")?;
insert_header(
&mut headers,
"x-bili-fawkes-req-bin",
&base64_protobuf(&FawkesReq {
appkey: Some(APP_FAUKES_APPKEY.to_owned()),
env: Some(APP_ENV.to_owned()),
session_id: Some(APP_SESSION_ID.to_owned()),
}),
)?;
insert_header(
&mut headers,
"x-bili-metadata-bin",
&base64_protobuf(&Metadata {
access_key: Some(access_key.unwrap_or_default().to_owned()),
mobi_app: Some(APP_MOBI_APP.to_owned()),
name: None,
build: Some(APP_BUILD),
channel: Some(APP_CHANNEL.to_owned()),
buvid: Some(String::new()),
platform: Some(APP_PLATFORM.to_owned()),
}),
)?;
if let Some(access_key) = access_key {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("identify_v1 {access_key}")).map_err(|_| {
Error::InvalidInput("invalid APP playurl access key header".to_owned())
})?,
);
}
insert_header(
&mut headers,
"x-bili-device-bin",
&base64_protobuf(&Device {
app_id: Some(APP_ID),
build: Some(APP_BUILD),
buvid: Some(String::new()),
mobi_app: Some(APP_MOBI_APP.to_owned()),
platform: Some(APP_PLATFORM.to_owned()),
name: None,
channel: Some(APP_CHANNEL.to_owned()),
brand: Some(APP_BRAND.to_owned()),
model: Some(APP_MODEL.to_owned()),
osver: Some(APP_OS_VERSION.to_owned()),
fp_local: None,
fp_remote: None,
version_name: None,
fp: None,
}),
)?;
insert_header(
&mut headers,
"x-bili-network-bin",
&base64_protobuf(&Network {
r#type: Some(NetworkType::Wifi.into()),
tf: None,
oid: Some(APP_NETWORK_OID.to_owned()),
}),
)?;
insert_header(&mut headers, "x-bili-restriction-bin", "")?;
insert_header(
&mut headers,
"x-bili-locale-bin",
&base64_protobuf(&Locale {
c_locale: Some(LocaleIds {
language: Some(APP_LANGUAGE.to_owned()),
script: None,
region: Some(APP_REGION.to_owned()),
}),
s_locale: None,
}),
)?;
insert_header(&mut headers, "x-bili-exps-bin", "")?;
insert_header(&mut headers, "grpc-encoding", "gzip")?;
insert_header(&mut headers, "grpc-accept-encoding", "identity,gzip")?;
if let Some(timeout) = grpc_timeout_header_value(request_timeout) {
insert_header(&mut headers, "grpc-timeout", &timeout)?;
}
Ok(headers)
}
pub(crate) fn decode_play_view_response(frame: &[u8], is_pgc: bool) -> Result<StreamSet> {
let payload = decode_grpc_frame(frame)?;
let reply = PlayViewReply::decode(payload.as_slice()).map_err(|error| {
Error::InvalidInput(format!("invalid APP playurl protobuf response: {error}"))
})?;
reply.into_stream_set(is_pgc)
}
fn app_user_agent() -> String {
format!(
"Dalvik/{APP_DALVIK_VERSION} (Linux; U; Android {APP_OS_VERSION}; {APP_BRAND} {APP_MODEL}) {APP_VERSION} os/android model/{APP_BRAND} mobi_app/android build/{APP_BUILD} channel/{APP_CHANNEL} innerVer/{APP_BUILD} osVer/{APP_OS_VERSION} network/2 grpc-java-cronet/{APP_CRONET_VERSION}"
)
}
fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &str) -> Result<()> {
headers.insert(
HeaderName::from_static(name),
HeaderValue::from_str(value)
.map_err(|_| Error::InvalidInput(format!("invalid APP playurl header: {name}")))?,
);
Ok(())
}
fn base64_protobuf(message: &impl Message) -> String {
STANDARD.encode(message.encode_to_vec())
}
fn grpc_timeout_header_value(timeout: Duration) -> Option<String> {
const MAX_GRPC_TIMEOUT_VALUE: u128 = 99_999_999;
if timeout.is_zero() {
return None;
}
let nanos = timeout.as_nanos();
for (denominator, unit) in [
(1_000_000_u128, "m"),
(1_000_000_000_u128, "S"),
(60_000_000_000_u128, "M"),
(3_600_000_000_000_u128, "H"),
] {
let value = nanos.div_ceil(denominator);
if value <= MAX_GRPC_TIMEOUT_VALUE {
return Some(format!("{value}{unit}"));
}
}
Some(format!("{MAX_GRPC_TIMEOUT_VALUE}H"))
}
fn encode_grpc_frame(payload: &[u8], compressed: bool) -> Result<Vec<u8>> {
let payload = if compressed {
gzip_compress(payload)?
} else {
payload.to_vec()
};
let len = u32::try_from(payload.len())
.map_err(|_| Error::InvalidInput("APP playurl gRPC payload is too large".to_owned()))?;
let mut frame = Vec::with_capacity(payload.len() + 5);
frame.push(u8::from(compressed));
frame.extend_from_slice(&len.to_be_bytes());
frame.extend_from_slice(&payload);
Ok(frame)
}
fn decode_grpc_frame(frame: &[u8]) -> Result<Vec<u8>> {
if frame.len() < 5 {
return Err(Error::MissingField("APP playurl gRPC frame"));
}
let compressed = frame[0];
let mut len = [0_u8; 4];
len.copy_from_slice(&frame[1..5]);
let len = usize::try_from(u32::from_be_bytes(len))
.map_err(|_| Error::InvalidInput("APP playurl gRPC frame length is invalid".to_owned()))?;
let end = 5_usize
.checked_add(len)
.ok_or_else(|| Error::InvalidInput("APP playurl gRPC frame length overflow".to_owned()))?;
if end != frame.len() {
return Err(Error::InvalidInput(
"APP playurl gRPC response must contain exactly one frame".to_owned(),
));
}
let payload = frame
.get(5..end)
.ok_or(Error::MissingField("APP playurl gRPC payload"))?;
match compressed {
0 => Ok(payload.to_vec()),
1 => gzip_decompress(payload),
value => Err(Error::InvalidInput(format!(
"unsupported APP playurl gRPC compression flag: {value}"
))),
}
}
fn gzip_compress(data: &[u8]) -> Result<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data)?;
Ok(encoder.finish()?)
}
fn gzip_decompress(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = GzDecoder::new(data);
let mut output = Vec::new();
decoder.read_to_end(&mut output)?;
Ok(output)
}
#[derive(Clone, PartialEq, Message)]
struct PlayViewReq {
#[prost(int64, optional, tag = "1")]
ep_id: Option<i64>,
#[prost(int64, optional, tag = "2")]
cid: Option<i64>,
#[prost(int64, optional, tag = "3")]
qn: Option<i64>,
#[prost(int32, optional, tag = "4")]
fnver: Option<i32>,
#[prost(int32, optional, tag = "5")]
fnval: Option<i32>,
#[prost(uint32, optional, tag = "6")]
download: Option<u32>,
#[prost(int32, optional, tag = "7")]
force_host: Option<i32>,
#[prost(bool, optional, tag = "8")]
fourk: Option<bool>,
#[prost(string, optional, tag = "9")]
spmid: Option<String>,
#[prost(string, optional, tag = "10")]
from_spmid: Option<String>,
#[prost(int32, optional, tag = "11")]
teenagers_mode: Option<i32>,
#[prost(enumeration = "AppCodeType", optional, tag = "12")]
prefer_codec_type: Option<i32>,
#[prost(bool, optional, tag = "13")]
is_preview: Option<bool>,
#[prost(int64, optional, tag = "14")]
room_id: Option<i64>,
#[prost(bool, optional, tag = "15")]
is_need_view_info: Option<bool>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Enumeration)]
#[repr(i32)]
enum AppCodeType {
None = 0,
H264 = 1,
Hevc = 2,
Av1 = 3,
}
#[derive(Clone, PartialEq, Message)]
struct VideoInfo {
#[prost(uint32, optional, tag = "1")]
quality: Option<u32>,
#[prost(string, optional, tag = "2")]
format: Option<String>,
#[prost(uint64, optional, tag = "3")]
timelength: Option<u64>,
#[prost(uint32, optional, tag = "4")]
video_codecid: Option<u32>,
#[prost(message, repeated, tag = "5")]
stream_list: Vec<StreamItem>,
#[prost(message, repeated, tag = "6")]
dash_audio: Vec<DashItem>,
#[prost(message, optional, tag = "7")]
dolby: Option<DolbyItem>,
#[prost(message, optional, tag = "9")]
flac: Option<DolbyItem>,
}
#[derive(Clone, PartialEq, Message)]
struct DolbyItem {
#[prost(int32, optional, tag = "1")]
r#type: Option<i32>,
#[prost(message, repeated, tag = "2")]
audio: Vec<DashItem>,
}
#[derive(Clone, PartialEq, Message)]
struct DashItem {
#[prost(uint32, optional, tag = "1")]
id: Option<u32>,
#[prost(string, optional, tag = "2")]
base_url: Option<String>,
#[prost(string, repeated, tag = "3")]
backup_url: Vec<String>,
#[prost(uint32, optional, tag = "4")]
bandwidth: Option<u32>,
#[prost(uint32, optional, tag = "5")]
codecid: Option<u32>,
#[prost(string, optional, tag = "6")]
md5: Option<String>,
#[prost(uint64, optional, tag = "7")]
size: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
struct StreamItem {
#[prost(message, optional, tag = "1")]
stream_info: Option<StreamInfo>,
#[prost(message, optional, tag = "2")]
dash_video: Option<DashVideo>,
#[prost(message, optional, tag = "3")]
segment_video: Option<SegmentVideo>,
}
#[derive(Clone, PartialEq, Message)]
struct StreamInfo {
#[prost(uint32, optional, tag = "1")]
quality: Option<u32>,
#[prost(string, optional, tag = "2")]
format: Option<String>,
#[prost(string, optional, tag = "3")]
description: Option<String>,
#[prost(uint32, optional, tag = "4")]
err_code: Option<u32>,
#[prost(message, optional, tag = "5")]
limit: Option<StreamLimit>,
#[prost(bool, optional, tag = "6")]
need_vip: Option<bool>,
#[prost(bool, optional, tag = "7")]
need_login: Option<bool>,
#[prost(bool, optional, tag = "8")]
intact: Option<bool>,
#[prost(bool, optional, tag = "9")]
no_rexcode: Option<bool>,
#[prost(uint64, optional, tag = "10")]
attribute: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
struct DashVideo {
#[prost(string, optional, tag = "1")]
base_url: Option<String>,
#[prost(string, repeated, tag = "2")]
backup_url: Vec<String>,
#[prost(uint32, optional, tag = "3")]
bandwidth: Option<u32>,
#[prost(uint32, optional, tag = "4")]
codecid: Option<u32>,
#[prost(string, optional, tag = "5")]
md5: Option<String>,
#[prost(uint64, optional, tag = "6")]
size: Option<u64>,
#[prost(uint32, optional, tag = "7")]
audio_id: Option<u32>,
#[prost(bool, optional, tag = "8")]
no_rexcode: Option<bool>,
#[prost(string, optional, tag = "9")]
frame_rate: Option<String>,
#[prost(int32, optional, tag = "10")]
width: Option<i32>,
#[prost(int32, optional, tag = "11")]
height: Option<i32>,
}
#[derive(Clone, PartialEq, Message)]
struct StreamLimit {
#[prost(string, optional, tag = "1")]
title: Option<String>,
#[prost(string, optional, tag = "2")]
uri: Option<String>,
#[prost(string, optional, tag = "3")]
msg: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
struct SegmentVideo {
#[prost(message, repeated, tag = "1")]
segment: Vec<ResponseUrl>,
}
#[derive(Clone, PartialEq, Message)]
struct ResponseUrl {
#[prost(uint32, optional, tag = "1")]
order: Option<u32>,
#[prost(uint64, optional, tag = "2")]
length: Option<u64>,
#[prost(uint64, optional, tag = "3")]
size: Option<u64>,
#[prost(string, optional, tag = "4")]
url: Option<String>,
#[prost(string, repeated, tag = "5")]
backup_url: Vec<String>,
#[prost(string, optional, tag = "6")]
md5: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
struct PlayViewReply {
#[prost(message, optional, tag = "1")]
video_info: Option<VideoInfo>,
#[prost(message, optional, tag = "3")]
business: Option<BusinessInfo>,
#[prost(message, optional, tag = "5")]
view_info: Option<ViewInfo>,
#[prost(message, optional, tag = "9")]
pgc_view_info: Option<ViewInfo>,
}
#[derive(Clone, PartialEq, Message)]
struct BusinessInfo {
#[prost(bool, optional, tag = "1")]
is_preview: Option<bool>,
}
#[derive(Clone, PartialEq, Message)]
struct ViewInfo {
#[prost(message, optional, tag = "1")]
dialog: Option<Dialog>,
#[prost(message, optional, tag = "2")]
toast: Option<Toast>,
}
#[derive(Clone, PartialEq, Message)]
struct Dialog {
#[prost(int64, optional, tag = "1")]
code: Option<i64>,
#[prost(string, optional, tag = "2")]
msg: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
struct Toast {
#[prost(string, optional, tag = "1")]
text: Option<String>,
#[prost(message, optional, tag = "5")]
toast_text: Option<TextInfo>,
}
#[derive(Clone, PartialEq, Message)]
struct TextInfo {
#[prost(string, optional, tag = "1")]
text: Option<String>,
}
impl PlayViewReply {
fn into_stream_set(self, is_pgc: bool) -> Result<StreamSet> {
let access_limit_message = self.access_limit_message();
if self.is_preview_only(is_pgc) {
let message = access_limit_message.map_or_else(
|| APP_PREVIEW_ONLY_RESTRICTION_MESSAGE.to_owned(),
|message| format!("{APP_PREVIEW_ONLY_RESTRICTION_MESSAGE}: {message}"),
);
return Err(Error::AccessRestricted(message));
}
let video_info = self.video_info.ok_or_else(|| {
access_limit_message
.clone()
.map_or(Error::MissingField("videoInfo"), Error::AccessRestricted)
})?;
let fallback_quality = video_info.quality;
let fallback_video_codecid = video_info.video_codecid;
let timelength = video_info.timelength;
let duration_seconds = video_info
.timelength
.and_then(|value| u32::try_from(value / 1000).ok());
let mut qualities = Vec::new();
let (videos, flv_segments) = collect_stream_list(
video_info.stream_list,
fallback_quality,
fallback_video_codecid,
timelength,
&mut qualities,
);
let audios =
collect_audio_streams(video_info.dash_audio, video_info.flac, video_info.dolby);
if videos.is_empty()
&& flv_segments.is_empty()
&& let Some(message) = access_limit_message.clone()
{
return Err(Error::AccessRestricted(message));
}
if videos.is_empty() && audios.is_empty() && flv_segments.is_empty() {
if let Some(message) = access_limit_message {
return Err(Error::AccessRestricted(message));
}
return Err(Error::MissingField("APP playurl streams"));
}
let accept_quality = qualities
.iter()
.map(|quality| quality.id)
.collect::<Vec<_>>();
Ok(StreamSet {
videos,
audios,
flv_segments,
accept_quality,
qualities,
duration_seconds,
})
}
fn access_limit_message(&self) -> Option<String> {
self.view_info
.as_ref()
.and_then(ViewInfo::access_limit_message)
.or_else(|| {
self.pgc_view_info
.as_ref()
.and_then(ViewInfo::access_limit_message)
})
.or_else(|| {
self.video_info.as_ref().and_then(|video_info| {
video_info
.stream_list
.iter()
.filter_map(|item| item.stream_info.as_ref())
.find_map(StreamInfo::access_limit_message)
})
})
}
fn is_preview_only(&self, is_pgc: bool) -> bool {
is_pgc
&& self
.business
.as_ref()
.and_then(|business| business.is_preview)
.unwrap_or(false)
}
}
fn collect_stream_list(
stream_list: Vec<StreamItem>,
fallback_quality: Option<u32>,
fallback_video_codecid: Option<u32>,
timelength: Option<u64>,
qualities: &mut Vec<StreamQuality>,
) -> (Vec<MediaStream>, Vec<FlvSegment>) {
let mut videos = Vec::new();
let mut selected_flv: Option<FlvCandidate> = None;
for item in stream_list {
let stream_info = item.stream_info;
if let Some(dash_video) = item.dash_video
&& let Some(stream) = dash_video.into_media_stream(
stream_info.as_ref(),
fallback_quality,
fallback_video_codecid,
timelength,
)
{
push_stream_quality(
qualities,
stream.id,
stream_info.as_ref().and_then(|info| {
info.description
.as_ref()
.filter(|value| !value.is_empty())
.cloned()
}),
);
videos.push(stream);
}
if let Some(segment_video) = item.segment_video {
let segments = segment_video
.segment
.into_iter()
.enumerate()
.filter_map(|(index, segment)| segment.into_flv_segment(index))
.collect::<Vec<_>>();
if !segments.is_empty() {
let candidate = FlvCandidate {
quality: stream_info
.as_ref()
.and_then(|info| info.quality)
.or(fallback_quality),
description: stream_info.as_ref().and_then(|info| {
info.description
.as_ref()
.filter(|value| !value.is_empty())
.cloned()
}),
segments,
};
if should_replace_flv_candidate(selected_flv.as_ref(), &candidate) {
selected_flv = Some(candidate);
}
}
}
}
let mut flv_segments = Vec::new();
if let Some(candidate) = selected_flv {
if let Some(quality) = candidate.quality {
push_stream_quality(qualities, quality, candidate.description);
}
flv_segments = candidate.segments;
}
(videos, flv_segments)
}
fn collect_audio_streams(
dash_audio: Vec<DashItem>,
flac: Option<DolbyItem>,
dolby: Option<DolbyItem>,
) -> Vec<MediaStream> {
let mut audios = dash_audio
.into_iter()
.filter_map(|audio| audio.into_audio_stream(AudioKind::Aac))
.collect::<Vec<_>>();
if let Some(flac) = flac {
audios.extend(
flac.audio
.into_iter()
.filter_map(|audio| audio.into_audio_stream(AudioKind::Flac)),
);
}
if let Some(dolby) = dolby {
audios.extend(
dolby
.audio
.into_iter()
.filter_map(|audio| audio.into_audio_stream(AudioKind::Dolby)),
);
}
audios
}
impl StreamInfo {
fn access_limit_message(&self) -> Option<String> {
self.limit
.as_ref()
.and_then(StreamLimit::access_limit_message)
}
}
impl StreamLimit {
fn access_limit_message(&self) -> Option<String> {
first_non_empty([self.msg.as_deref(), self.title.as_deref()])
}
}
impl ViewInfo {
fn access_limit_message(&self) -> Option<String> {
self.dialog
.as_ref()
.and_then(Dialog::access_limit_message)
.or_else(|| self.toast.as_ref().and_then(Toast::access_limit_message))
}
}
impl Dialog {
fn access_limit_message(&self) -> Option<String> {
first_non_empty([self.msg.as_deref()])
}
}
impl Toast {
fn access_limit_message(&self) -> Option<String> {
first_non_empty([self.text.as_deref()]).or_else(|| {
self.toast_text
.as_ref()
.and_then(TextInfo::access_limit_message)
})
}
}
impl TextInfo {
fn access_limit_message(&self) -> Option<String> {
first_non_empty([self.text.as_deref()])
}
}
struct FlvCandidate {
quality: Option<u32>,
description: Option<String>,
segments: Vec<FlvSegment>,
}
fn should_replace_flv_candidate(current: Option<&FlvCandidate>, candidate: &FlvCandidate) -> bool {
match current {
None => true,
Some(current) => candidate.quality.unwrap_or(0) > current.quality.unwrap_or(0),
}
}
impl DashVideo {
fn into_media_stream(
self,
stream_info: Option<&StreamInfo>,
fallback_quality: Option<u32>,
fallback_video_codecid: Option<u32>,
timelength: Option<u64>,
) -> Option<MediaStream> {
let base_url = self.base_url.filter(|value| !value.is_empty())?;
let id = stream_info
.and_then(|info| info.quality)
.or(fallback_quality)?;
let bandwidth = self.bandwidth.map(u64::from).or_else(|| {
self.size.zip(timelength).and_then(|(size, time_ms)| {
if time_ms == 0 {
return None;
}
size.checked_mul(8)?.checked_mul(1000)?.checked_div(time_ms)
})
});
Some(MediaStream {
id,
base_url: normalize_media_url(&base_url),
backup_urls: normalize_media_url_list(self.backup_url),
codecs: None,
codec_family: video_codec_family(self.codecid.or(fallback_video_codecid)),
bandwidth,
width: self.width.and_then(|value| u32::try_from(value).ok()),
height: self.height.and_then(|value| u32::try_from(value).ok()),
frame_rate: self.frame_rate.filter(|value| !value.is_empty()),
mime_type: Some("video/mp4".to_owned()),
size: self.size,
})
}
}
impl DashItem {
fn into_audio_stream(self, kind: AudioKind) -> Option<MediaStream> {
let base_url = self.base_url.filter(|value| !value.is_empty())?;
Some(MediaStream {
id: self.id?,
base_url: normalize_media_url(&base_url),
backup_urls: normalize_media_url_list(self.backup_url),
codecs: audio_codec(kind, self.codecid),
codec_family: Some(audio_codec_family(kind)),
bandwidth: self.bandwidth.map(u64::from),
width: None,
height: None,
frame_rate: None,
mime_type: Some(kind.mime_type().to_owned()),
size: self.size,
})
}
}
impl ResponseUrl {
fn into_flv_segment(self, index: usize) -> Option<FlvSegment> {
let url = self.url.filter(|value| !value.is_empty())?;
Some(FlvSegment {
order: self
.order
.or_else(|| u32::try_from(index + 1).ok())
.unwrap_or(1),
url: normalize_media_url(&url),
backup_urls: normalize_media_url_list(self.backup_url),
size: self.size,
length_ms: self.length,
})
}
}
#[derive(Clone, Copy)]
enum AudioKind {
Aac,
Flac,
Dolby,
}
impl AudioKind {
const fn mime_type(self) -> &'static str {
match self {
Self::Aac | Self::Flac | Self::Dolby => "audio/mp4",
}
}
}
fn video_codec_family(codecid: Option<u32>) -> Option<CodecFamily> {
match codecid {
Some(7) => Some(CodecFamily::H264),
Some(12) => Some(CodecFamily::Hevc),
Some(13) => Some(CodecFamily::Av1),
_ => None,
}
}
fn audio_codec(kind: AudioKind, _codecid: Option<u32>) -> Option<String> {
match kind {
AudioKind::Aac => Some("mp4a.40.2".to_owned()),
AudioKind::Flac => None,
AudioKind::Dolby => Some("ec-3".to_owned()),
}
}
const fn audio_codec_family(kind: AudioKind) -> CodecFamily {
match kind {
AudioKind::Aac => CodecFamily::Aac,
AudioKind::Flac => CodecFamily::Flac,
AudioKind::Dolby => CodecFamily::Dolby,
}
}
fn push_stream_quality(qualities: &mut Vec<StreamQuality>, id: u32, description: Option<String>) {
if qualities.iter().any(|quality| quality.id == id) {
return;
}
qualities.push(StreamQuality { id, description });
}
fn normalize_media_url(url: &str) -> String {
if url.starts_with("//") {
format!("https:{url}")
} else {
url.to_owned()
}
}
fn normalize_media_url_list(urls: Vec<String>) -> Vec<String> {
urls.into_iter()
.filter(|url| !url.is_empty())
.map(|url| normalize_media_url(&url))
.collect()
}
fn first_non_empty<'a>(values: impl IntoIterator<Item = Option<&'a str>>) -> Option<String> {
values
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[derive(Clone, PartialEq, Message)]
struct Metadata {
#[prost(string, optional, tag = "1")]
access_key: Option<String>,
#[prost(string, optional, tag = "2")]
mobi_app: Option<String>,
#[prost(string, optional, tag = "3")]
name: Option<String>,
#[prost(int32, optional, tag = "4")]
build: Option<i32>,
#[prost(string, optional, tag = "5")]
channel: Option<String>,
#[prost(string, optional, tag = "6")]
buvid: Option<String>,
#[prost(string, optional, tag = "7")]
platform: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
struct Device {
#[prost(int32, optional, tag = "1")]
app_id: Option<i32>,
#[prost(int32, optional, tag = "2")]
build: Option<i32>,
#[prost(string, optional, tag = "3")]
buvid: Option<String>,
#[prost(string, optional, tag = "4")]
mobi_app: Option<String>,
#[prost(string, optional, tag = "5")]
platform: Option<String>,
#[prost(string, optional, tag = "6")]
name: Option<String>,
#[prost(string, optional, tag = "7")]
channel: Option<String>,
#[prost(string, optional, tag = "8")]
brand: Option<String>,
#[prost(string, optional, tag = "9")]
model: Option<String>,
#[prost(string, optional, tag = "10")]
osver: Option<String>,
#[prost(string, optional, tag = "11")]
fp_local: Option<String>,
#[prost(string, optional, tag = "12")]
fp_remote: Option<String>,
#[prost(string, optional, tag = "13")]
version_name: Option<String>,
#[prost(string, optional, tag = "14")]
fp: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
struct FawkesReq {
#[prost(string, optional, tag = "1")]
appkey: Option<String>,
#[prost(string, optional, tag = "2")]
env: Option<String>,
#[prost(string, optional, tag = "3")]
session_id: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
struct Network {
#[prost(enumeration = "NetworkType", optional, tag = "1")]
r#type: Option<i32>,
#[prost(int32, optional, tag = "2")]
tf: Option<i32>,
#[prost(string, optional, tag = "3")]
oid: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Enumeration)]
#[repr(i32)]
enum NetworkType {
Unknown = 0,
Wifi = 1,
Cellular = 2,
Offline = 3,
Other = 4,
Ethernet = 5,
}
#[derive(Clone, PartialEq, Message)]
struct Locale {
#[prost(message, optional, tag = "1")]
c_locale: Option<LocaleIds>,
#[prost(message, optional, tag = "2")]
s_locale: Option<LocaleIds>,
}
#[derive(Clone, PartialEq, Message)]
struct LocaleIds {
#[prost(string, optional, tag = "1")]
language: Option<String>,
#[prost(string, optional, tag = "2")]
script: Option<String>,
#[prost(string, optional, tag = "3")]
region: Option<String>,
}
#[cfg(test)]
pub(crate) fn test_play_view_response_frame(video_url: &str) -> Result<Vec<u8>> {
let reply = PlayViewReply {
video_info: Some(VideoInfo {
quality: None,
format: None,
timelength: Some(123_000),
video_codecid: None,
stream_list: vec![
StreamItem {
stream_info: Some(StreamInfo {
quality: Some(80),
format: Some("flv".to_owned()),
description: Some("1080P".to_owned()),
err_code: None,
limit: None,
need_vip: None,
need_login: None,
intact: None,
no_rexcode: None,
attribute: None,
}),
dash_video: Some(DashVideo {
base_url: Some(video_url.to_owned()),
backup_url: vec!["https://app.example/video-backup.m4s".to_owned()],
bandwidth: Some(1_200_000),
codecid: Some(12),
md5: None,
size: Some(18_000_000),
audio_id: Some(30280),
no_rexcode: None,
frame_rate: Some("60".to_owned()),
width: Some(1920),
height: Some(1080),
}),
segment_video: None,
},
StreamItem {
stream_info: Some(StreamInfo {
quality: Some(32),
format: Some("flv".to_owned()),
description: Some("FLV".to_owned()),
err_code: None,
limit: None,
need_vip: None,
need_login: None,
intact: None,
no_rexcode: None,
attribute: None,
}),
dash_video: None,
segment_video: Some(SegmentVideo {
segment: vec![ResponseUrl {
order: Some(1),
length: Some(123_000),
size: Some(10_000),
url: Some("https://app.example/segment.flv".to_owned()),
backup_url: vec!["https://app.example/segment-backup.flv".to_owned()],
md5: None,
}],
}),
},
],
dash_audio: vec![DashItem {
id: Some(30280),
base_url: Some("https://app.example/audio.m4s".to_owned()),
backup_url: vec!["https://app.example/audio-backup.m4s".to_owned()],
bandwidth: Some(128_000),
codecid: None,
md5: None,
size: Some(2_000_000),
}],
dolby: Some(DolbyItem {
r#type: Some(1),
audio: vec![DashItem {
id: Some(30250),
base_url: Some("https://app.example/dolby.m4s".to_owned()),
backup_url: Vec::new(),
bandwidth: Some(256_000),
codecid: None,
md5: None,
size: Some(4_000_000),
}],
}),
flac: Some(DolbyItem {
r#type: Some(2),
audio: vec![DashItem {
id: Some(30251),
base_url: Some("https://app.example/flac.m4s".to_owned()),
backup_url: Vec::new(),
bandwidth: Some(800_000),
codecid: None,
md5: None,
size: Some(12_000_000),
}],
}),
}),
business: None,
view_info: None,
pgc_view_info: None,
};
encode_grpc_frame(&reply.encode_to_vec(), false)
}
#[cfg(test)]
pub(crate) fn test_pgc_region_limit_response_frame(message: &str) -> Result<Vec<u8>> {
let reply = PlayViewReply {
video_info: None,
business: None,
view_info: Some(ViewInfo {
dialog: Some(Dialog {
code: Some(10001),
msg: Some(message.to_owned()),
}),
toast: None,
}),
pgc_view_info: None,
};
encode_grpc_frame(&reply.encode_to_vec(), false)
}
#[cfg(test)]
pub(crate) fn test_pgc_preview_only_response_frame() -> Result<Vec<u8>> {
test_pgc_preview_only_response_frame_with_message(None)
}
#[cfg(test)]
pub(crate) fn test_pgc_preview_only_response_frame_with_message(
message: Option<&str>,
) -> Result<Vec<u8>> {
let reply = PlayViewReply {
video_info: Some(VideoInfo {
quality: None,
format: None,
timelength: Some(30_000),
video_codecid: None,
stream_list: vec![StreamItem {
stream_info: Some(StreamInfo {
quality: Some(32),
format: Some("dash".to_owned()),
description: Some("Preview".to_owned()),
err_code: None,
limit: None,
need_vip: None,
need_login: None,
intact: None,
no_rexcode: None,
attribute: None,
}),
dash_video: Some(DashVideo {
base_url: Some("https://app.example/preview.m4s".to_owned()),
backup_url: Vec::new(),
bandwidth: Some(300_000),
codecid: Some(7),
md5: None,
size: Some(1_000_000),
audio_id: Some(30280),
no_rexcode: None,
frame_rate: None,
width: Some(640),
height: Some(360),
}),
segment_video: None,
}],
dash_audio: Vec::new(),
dolby: None,
flac: None,
}),
business: Some(BusinessInfo {
is_preview: Some(true),
}),
view_info: message.map(|message| ViewInfo {
dialog: Some(Dialog {
code: None,
msg: Some(message.to_owned()),
}),
toast: None,
}),
pgc_view_info: None,
};
encode_grpc_frame(&reply.encode_to_vec(), false)
}
#[cfg(test)]
mod tests {
use super::{
BusinessInfo, DashItem, Dialog, DolbyItem, PlayViewReply, PlayViewReq, ResponseUrl,
SegmentVideo, StreamInfo, StreamItem, VideoInfo, ViewInfo, decode_grpc_frame,
encode_grpc_frame, play_view_request_body, test_play_view_response_frame,
};
use crate::{CodecFamily, Error};
use prost::Message as _;
use std::time::Duration;
#[test]
fn grpc_frame_decodes_uncompressed_payload() -> anyhow::Result<()> {
let frame = encode_grpc_frame(b"hello", false)?;
assert_eq!(decode_grpc_frame(&frame)?, b"hello");
Ok(())
}
#[test]
fn grpc_frame_decodes_compressed_payload() -> anyhow::Result<()> {
let frame = encode_grpc_frame(b"hello", true)?;
assert_eq!(decode_grpc_frame(&frame)?, b"hello");
Ok(())
}
#[test]
fn grpc_frame_rejects_trailing_bytes() -> anyhow::Result<()> {
let mut frame = encode_grpc_frame(b"hello", false)?;
frame.extend_from_slice(b"extra");
let error = decode_grpc_frame(&frame)
.err()
.ok_or_else(|| anyhow::anyhow!("trailing bytes should fail"))?;
assert!(
matches!(error, Error::InvalidInput(message) if message == "APP playurl gRPC response must contain exactly one frame")
);
Ok(())
}
#[test]
fn play_view_request_asks_pgc_for_view_info() -> anyhow::Result<()> {
let frame = play_view_request_body(1000, 2000, true)?;
let payload = decode_grpc_frame(&frame)?;
let request = PlayViewReq::decode(payload.as_slice())?;
assert_eq!(request.ep_id, Some(1000));
assert_eq!(request.cid, Some(2000));
assert_eq!(request.is_need_view_info, Some(true));
Ok(())
}
#[test]
fn play_view_request_omits_view_info_for_normal_video() -> anyhow::Result<()> {
let frame = play_view_request_body(170_001, 9988, false)?;
let payload = decode_grpc_frame(&frame)?;
let request = PlayViewReq::decode(payload.as_slice())?;
assert_eq!(request.ep_id, Some(170_001));
assert_eq!(request.cid, Some(9988));
assert_eq!(request.is_need_view_info, None);
Ok(())
}
#[test]
fn play_view_headers_use_configured_grpc_timeout() -> anyhow::Result<()> {
let headers = super::play_view_headers(None, Duration::from_secs(30))?;
assert_eq!(
headers
.get("grpc-timeout")
.and_then(|value| value.to_str().ok()),
Some("30000m")
);
let headers = super::play_view_headers(None, Duration::from_secs(7))?;
assert_eq!(
headers
.get("grpc-timeout")
.and_then(|value| value.to_str().ok()),
Some("7000m")
);
let headers = super::play_view_headers(None, Duration::ZERO)?;
assert!(headers.get("grpc-timeout").is_none());
Ok(())
}
#[test]
fn play_view_reply_maps_to_stream_set() -> anyhow::Result<()> {
let frame = test_play_view_response_frame("https://app.example/video.m4s")?;
let streams = super::decode_play_view_response(&frame, false)?;
assert_eq!(streams.videos[0].id, 80);
assert_eq!(streams.videos[0].codecs, None);
assert_eq!(streams.videos[0].codec_family, Some(CodecFamily::Hevc));
assert_eq!(streams.videos[0].width, Some(1920));
assert_eq!(streams.videos[0].height, Some(1080));
assert_eq!(streams.videos[0].frame_rate.as_deref(), Some("60"));
assert_eq!(streams.audios[0].codecs.as_deref(), Some("mp4a.40.2"));
assert_eq!(streams.audios[0].codec_family, Some(CodecFamily::Aac));
assert_eq!(streams.audios[1].codecs, None);
assert_eq!(streams.audios[1].codec_family, Some(CodecFamily::Flac));
assert_eq!(streams.audios[1].mime_type.as_deref(), Some("audio/mp4"));
assert_eq!(streams.audios[2].codecs.as_deref(), Some("ec-3"));
assert_eq!(streams.audios[2].codec_family, Some(CodecFamily::Dolby));
assert_eq!(streams.flv_segments[0].order, 1);
assert_eq!(streams.duration_seconds, Some(123));
assert_eq!(streams.qualities[0].description.as_deref(), Some("1080P"));
Ok(())
}
#[test]
fn play_view_reply_preserves_repeated_dolby_audio() -> anyhow::Result<()> {
let reply = PlayViewReply {
video_info: Some(VideoInfo {
quality: None,
format: None,
timelength: Some(60_000),
video_codecid: None,
stream_list: Vec::new(),
dash_audio: Vec::new(),
dolby: Some(DolbyItem {
r#type: Some(1),
audio: vec![
DashItem {
id: Some(30250),
base_url: Some("https://app.example/dolby-low.m4s".to_owned()),
backup_url: Vec::new(),
bandwidth: Some(256_000),
codecid: None,
md5: None,
size: Some(4_000_000),
},
DashItem {
id: Some(30251),
base_url: Some("https://app.example/dolby-high.m4s".to_owned()),
backup_url: Vec::new(),
bandwidth: Some(768_000),
codecid: None,
md5: None,
size: Some(8_000_000),
},
],
}),
flac: None,
}),
business: None,
view_info: None,
pgc_view_info: None,
};
let frame = encode_grpc_frame(&reply.encode_to_vec(), false)?;
let streams = super::decode_play_view_response(&frame, false)?;
let dolby = streams
.audios
.iter()
.filter(|stream| stream.codec_family == Some(CodecFamily::Dolby))
.collect::<Vec<_>>();
assert_eq!(dolby.len(), 2);
assert_eq!(dolby[0].id, 30250);
assert_eq!(dolby[0].base_url, "https://app.example/dolby-low.m4s");
assert_eq!(dolby[1].id, 30251);
assert_eq!(dolby[1].base_url, "https://app.example/dolby-high.m4s");
Ok(())
}
#[test]
fn play_view_reply_keeps_one_flv_quality() -> anyhow::Result<()> {
let reply = PlayViewReply {
video_info: Some(VideoInfo {
quality: None,
format: None,
timelength: Some(60_000),
video_codecid: None,
stream_list: vec![
flv_stream_item(32, "480P", "https://app.example/480.flv"),
flv_stream_item(64, "720P", "https://app.example/720.flv"),
],
dash_audio: Vec::new(),
dolby: None,
flac: None,
}),
business: None,
view_info: None,
pgc_view_info: None,
};
let frame = encode_grpc_frame(&reply.encode_to_vec(), false)?;
let streams = super::decode_play_view_response(&frame, false)?;
assert_eq!(streams.flv_segments.len(), 1);
assert_eq!(streams.flv_segments[0].url, "https://app.example/720.flv");
assert_eq!(streams.accept_quality, vec![64]);
assert_eq!(streams.qualities[0].description.as_deref(), Some("720P"));
Ok(())
}
#[test]
fn normal_app_field_3_does_not_trigger_preview_only() -> anyhow::Result<()> {
let reply_payload = decode_grpc_frame(&test_play_view_response_frame(
"https://app.example/video.m4s",
)?)?;
let mut reply = PlayViewReply::decode(reply_payload.as_slice())?;
reply.business = Some(BusinessInfo {
is_preview: Some(true),
});
let frame = encode_grpc_frame(&reply.encode_to_vec(), false)?;
let streams = super::decode_play_view_response(&frame, false)?;
assert_eq!(streams.videos[0].base_url, "https://app.example/video.m4s");
let error = super::decode_play_view_response(&frame, true)
.err()
.ok_or_else(|| anyhow::anyhow!("PGC preview-only response should fail"))?;
assert!(
matches!(error, Error::AccessRestricted(message) if message == super::APP_PREVIEW_ONLY_RESTRICTION_MESSAGE)
);
Ok(())
}
#[test]
fn pgc_view_info_limit_maps_to_access_restricted() -> anyhow::Result<()> {
let reply = PlayViewReply {
video_info: None,
business: None,
view_info: Some(ViewInfo {
dialog: Some(Dialog {
code: Some(10001),
msg: Some("area restricted".to_owned()),
}),
toast: None,
}),
pgc_view_info: None,
};
let frame = encode_grpc_frame(&reply.encode_to_vec(), false)?;
let error = super::decode_play_view_response(&frame, true)
.err()
.ok_or_else(|| anyhow::anyhow!("PGC region-limit response should fail"))?;
assert!(matches!(error, Error::AccessRestricted(message) if message == "area restricted"));
Ok(())
}
#[test]
fn pgc_view_info_field_9_limit_maps_to_access_restricted() -> anyhow::Result<()> {
let reply = PlayViewReply {
video_info: None,
business: None,
view_info: None,
pgc_view_info: Some(ViewInfo {
dialog: Some(Dialog {
code: Some(10001),
msg: Some("area restricted".to_owned()),
}),
toast: None,
}),
};
let frame = encode_grpc_frame(&reply.encode_to_vec(), false)?;
let error = super::decode_play_view_response(&frame, true)
.err()
.ok_or_else(|| anyhow::anyhow!("PGC field-9 region-limit response should fail"))?;
assert!(matches!(error, Error::AccessRestricted(message) if message == "area restricted"));
Ok(())
}
#[test]
fn pgc_view_info_limit_with_audio_only_maps_to_access_restricted() -> anyhow::Result<()> {
let reply = PlayViewReply {
video_info: Some(VideoInfo {
quality: None,
format: None,
timelength: Some(60_000),
video_codecid: None,
stream_list: Vec::new(),
dash_audio: vec![DashItem {
id: Some(30280),
base_url: Some("https://app.example/audio.m4s".to_owned()),
backup_url: Vec::new(),
bandwidth: Some(128_000),
codecid: None,
md5: None,
size: Some(2_000_000),
}],
dolby: None,
flac: None,
}),
business: None,
view_info: Some(ViewInfo {
dialog: Some(Dialog {
code: Some(10001),
msg: Some("area restricted".to_owned()),
}),
toast: None,
}),
pgc_view_info: None,
};
let frame = encode_grpc_frame(&reply.encode_to_vec(), false)?;
let error = super::decode_play_view_response(&frame, true)
.err()
.ok_or_else(|| anyhow::anyhow!("audio-only PGC limit response should fail"))?;
assert!(matches!(error, Error::AccessRestricted(message) if message == "area restricted"));
Ok(())
}
#[test]
fn pgc_preview_only_streams_map_to_access_restricted() -> anyhow::Result<()> {
let frame = super::test_pgc_preview_only_response_frame()?;
let error = super::decode_play_view_response(&frame, true)
.err()
.ok_or_else(|| anyhow::anyhow!("PGC preview-only response should fail"))?;
assert!(
matches!(error, Error::AccessRestricted(message) if message == super::APP_PREVIEW_ONLY_RESTRICTION_MESSAGE)
);
Ok(())
}
#[test]
fn pgc_preview_only_streams_keep_stable_prefix_with_access_message() -> anyhow::Result<()> {
let frame =
super::test_pgc_preview_only_response_frame_with_message(Some("preview ended"))?;
let error = super::decode_play_view_response(&frame, true)
.err()
.ok_or_else(|| anyhow::anyhow!("PGC preview-only response should fail"))?;
assert!(
matches!(error, Error::AccessRestricted(message) if message == "APP playurl returned preview-only streams: preview ended")
);
Ok(())
}
fn flv_stream_item(quality: u32, description: &str, url: &str) -> StreamItem {
StreamItem {
stream_info: Some(StreamInfo {
quality: Some(quality),
format: Some("flv".to_owned()),
description: Some(description.to_owned()),
err_code: None,
limit: None,
need_vip: None,
need_login: None,
intact: None,
no_rexcode: None,
attribute: None,
}),
dash_video: None,
segment_video: Some(SegmentVideo {
segment: vec![ResponseUrl {
order: Some(1),
length: Some(30_000),
size: Some(10_000),
url: Some(url.to_owned()),
backup_url: Vec::new(),
md5: None,
}],
}),
}
}
}