use crate::{
CodecFamily, DownloadEntry, DownloadPlan, Error, FlvSegment, MediaStream, Result,
StreamQuality, StreamSource,
};
use md5::{Digest, Md5};
use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize, de};
use std::{cmp::Ordering, collections::BTreeMap};
use url::Url;
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackPlan {
pub title: String,
pub entries: Vec<PlaybackEntry>,
}
impl PlaybackPlan {
#[must_use]
pub fn from_download_plan(plan: &DownloadPlan, request_headers: &[HttpHeaderSpec]) -> Self {
Self {
title: plan.title.clone(),
entries: plan
.entries
.iter()
.map(|entry| PlaybackEntry::from_download_entry(entry, request_headers))
.collect(),
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct PlaybackEntry {
pub index: u32,
pub aid: u64,
pub bvid: Option<String>,
pub cid: u64,
pub epid: Option<u64>,
pub title: String,
pub cover_url: Option<String>,
pub source: StreamSource,
pub cache_key: PlaybackEntryCacheKey,
pub qualities: Vec<StreamQuality>,
pub duration_seconds: Option<u32>,
pub abr: PlaybackAbrMetadata,
pub variants: Vec<PlaybackVariant>,
}
impl<'de> Deserialize<'de> for PlaybackEntry {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = PlaybackEntryWire::deserialize(deserializer)?;
let cache_key = wire.cache_key.unwrap_or_else(|| {
playback_entry_cache_key_from_parts(wire.bvid.as_deref(), wire.epid, wire.aid, wire.cid)
});
let mut variants = wire.variants;
let abr = apply_abr_metadata(&mut variants);
Ok(Self {
index: wire.index,
aid: wire.aid,
bvid: wire.bvid,
cid: wire.cid,
epid: wire.epid,
title: wire.title,
cover_url: wire.cover_url,
source: wire.source,
cache_key,
qualities: wire.qualities,
duration_seconds: wire.duration_seconds,
abr,
variants,
})
}
}
#[derive(Deserialize)]
struct PlaybackEntryWire {
index: u32,
aid: u64,
bvid: Option<String>,
cid: u64,
epid: Option<u64>,
title: String,
cover_url: Option<String>,
source: StreamSource,
cache_key: Option<PlaybackEntryCacheKey>,
qualities: Vec<StreamQuality>,
duration_seconds: Option<u32>,
#[serde(rename = "abr")]
_abr: Option<PlaybackAbrMetadata>,
variants: Vec<PlaybackVariant>,
}
impl PlaybackEntry {
fn from_download_entry(entry: &DownloadEntry, request_headers: &[HttpHeaderSpec]) -> Self {
let mut variants = playback_variants(entry, request_headers);
let abr = apply_abr_metadata(&mut variants);
Self {
index: entry.index,
aid: entry.aid,
bvid: entry.bvid.clone(),
cid: entry.cid,
epid: entry.epid,
title: entry.title.clone(),
cover_url: entry.cover_url.clone(),
source: entry.source.clone(),
cache_key: playback_entry_cache_key(entry),
qualities: entry.streams.qualities.clone(),
duration_seconds: entry.streams.duration_seconds,
abr,
variants,
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackEntryCacheKey {
pub content_id: String,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackAbrMetadata {
pub groups: Vec<PlaybackAbrGroup>,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackAbrGroup {
pub id: String,
pub kind: PlaybackAbrGroupKind,
pub variant_ids: Vec<String>,
pub level_count: u32,
pub min_bandwidth: Option<u64>,
pub max_bandwidth: Option<u64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlaybackAbrGroupKind {
DashVideo,
DashAudioOnly,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackAbrLevel {
pub group_id: String,
pub level_index: u32,
pub level_count: u32,
pub switchable: bool,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct PlaybackVariant {
pub id: String,
pub kind: PlaybackVariantKind,
pub video: Option<MediaRequestSpec>,
pub audio: Option<MediaRequestSpec>,
pub flv_segments: Vec<MediaRequestSpec>,
pub bandwidth: Option<u64>,
pub codecs: Vec<String>,
pub mime_types: Vec<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub frame_rate: Option<String>,
pub duration_seconds: Option<u32>,
pub cache_key: PlaybackVariantCacheKey,
pub abr: Option<PlaybackAbrLevel>,
pub selection_hints: PlaybackSelectionHints,
}
impl<'de> Deserialize<'de> for PlaybackVariant {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = PlaybackVariantWire::deserialize(deserializer)?;
let selection_hints = wire.selection_hints.map_or_else(
|| selection_hints(wire.kind, wire.video.as_ref(), wire.audio.as_ref()),
|hints| hints.into_selection_hints(wire.kind, wire.video.as_ref(), wire.audio.as_ref()),
);
let cache_key = wire.cache_key.unwrap_or_else(|| {
playback_variant_cache_key(
&wire.id,
wire.kind,
wire.video.as_ref(),
wire.audio.as_ref(),
&wire.flv_segments,
)
});
Ok(Self {
id: wire.id,
kind: wire.kind,
video: wire.video,
audio: wire.audio,
flv_segments: wire.flv_segments,
bandwidth: wire.bandwidth,
codecs: wire.codecs,
mime_types: wire.mime_types,
width: wire.width,
height: wire.height,
frame_rate: wire.frame_rate,
duration_seconds: wire.duration_seconds,
cache_key,
abr: wire.abr,
selection_hints,
})
}
}
#[derive(Deserialize)]
struct PlaybackVariantWire {
id: String,
kind: PlaybackVariantKind,
video: Option<MediaRequestSpec>,
audio: Option<MediaRequestSpec>,
#[serde(default)]
flv_segments: Vec<MediaRequestSpec>,
bandwidth: Option<u64>,
#[serde(default)]
codecs: Vec<String>,
#[serde(default)]
mime_types: Vec<String>,
width: Option<u32>,
height: Option<u32>,
frame_rate: Option<String>,
duration_seconds: Option<u32>,
cache_key: Option<PlaybackVariantCacheKey>,
abr: Option<PlaybackAbrLevel>,
selection_hints: Option<PlaybackSelectionHintsWire>,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackVariantCacheKey {
pub content_id: String,
pub variant_kind: PlaybackVariantKind,
pub variant_id: String,
pub media_keys: Vec<MediaCacheKey>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlaybackVariantKind {
Dash,
Flv,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct PlaybackSelectionHints {
pub avplayer: PlaybackSelectionHint,
}
impl<'de> Deserialize<'de> for PlaybackSelectionHints {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = PlaybackSelectionHintsWire::deserialize(deserializer)?;
let avplayer = wire
.avplayer
.ok_or_else(|| de::Error::missing_field("avplayer"))?;
Ok(Self { avplayer })
}
}
#[derive(Deserialize)]
struct PlaybackSelectionHintsWire {
avplayer: Option<PlaybackSelectionHint>,
#[serde(default, rename = "avplayer_h264_aac")]
_avplayer_h264_aac: Option<serde_json::Value>,
}
impl PlaybackSelectionHintsWire {
fn into_selection_hints(
self,
kind: PlaybackVariantKind,
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
) -> PlaybackSelectionHints {
if let Some(avplayer) = self.avplayer
&& !hint_needs_backfill(&avplayer, kind, video, audio)
{
return PlaybackSelectionHints { avplayer };
}
selection_hints(kind, video, audio)
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackSelectionHint {
#[serde(default = "unknown_format_key")]
pub format_key: String,
#[serde(default)]
pub video_codec: Option<String>,
#[serde(default)]
pub audio_codec: Option<String>,
pub playable: bool,
pub preferred: bool,
pub score: i32,
pub video_codec_family: Option<PlaybackCodecFamily>,
pub audio_codec_family: Option<PlaybackCodecFamily>,
pub reasons: Vec<PlaybackSelectionReason>,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PlaybackCodecPreference {
pub video_codec_families: Vec<PlaybackCodecFamily>,
pub audio_codec_families: Vec<PlaybackCodecFamily>,
}
impl PlaybackCodecPreference {
#[must_use]
pub fn new(
video_codec_families: Vec<PlaybackCodecFamily>,
audio_codec_families: Vec<PlaybackCodecFamily>,
) -> Self {
Self {
video_codec_families,
audio_codec_families,
}
}
#[must_use]
pub fn avplayer_default() -> Self {
Self::new(
vec![
PlaybackCodecFamily::H264,
PlaybackCodecFamily::Hevc,
PlaybackCodecFamily::Av1,
],
vec![PlaybackCodecFamily::Aac],
)
}
#[must_use]
pub fn h264_aac() -> Self {
Self::new(
vec![PlaybackCodecFamily::H264],
vec![PlaybackCodecFamily::Aac],
)
}
#[must_use]
pub fn hevc_aac() -> Self {
Self::new(
vec![PlaybackCodecFamily::Hevc],
vec![PlaybackCodecFamily::Aac],
)
}
#[must_use]
pub fn av1_aac() -> Self {
Self::new(
vec![PlaybackCodecFamily::Av1],
vec![PlaybackCodecFamily::Aac],
)
}
#[must_use]
pub fn rank_hint(&self, hint: &PlaybackSelectionHint) -> Option<usize> {
if !hint.playable {
return None;
}
let video_rank = codec_preference_rank(
hint.video_codec_family,
self.video_codec_families.as_slice(),
)?;
let audio_rank = codec_preference_rank(
hint.audio_codec_family,
self.audio_codec_families.as_slice(),
)?;
Some(video_rank.saturating_mul(1_000).saturating_add(audio_rank))
}
}
impl PlaybackVariant {
#[must_use]
pub fn codec_preference_rank(&self, preference: &PlaybackCodecPreference) -> Option<usize> {
preference.rank_hint(&self.selection_hints.avplayer)
}
#[must_use]
pub fn matches_codec_preference(&self, preference: &PlaybackCodecPreference) -> bool {
self.codec_preference_rank(preference).is_some()
}
}
pub type PlaybackCodecFamily = CodecFamily;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlaybackSelectionReason {
DashContainer,
FlvContainer,
H264Video,
HevcVideo,
Av1Video,
AacAudio,
MissingVideo,
MissingAudio,
UnknownVideoCodec,
UnknownAudioCodec,
UnsupportedVideoCodec,
UnsupportedAudioCodec,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MediaRequestSpec {
pub kind: MediaRequestKind,
pub stream_id: Option<u32>,
pub url: String,
pub backup_urls: Vec<String>,
pub headers: Vec<HttpHeaderSpec>,
pub mime_type: Option<String>,
pub codecs: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codec_family: Option<PlaybackCodecFamily>,
pub bandwidth: Option<u64>,
pub width: Option<u32>,
pub height: Option<u32>,
pub frame_rate: Option<String>,
pub size: Option<u64>,
pub duration_seconds: Option<u32>,
pub cache_key: MediaCacheKey,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MediaRequestKind {
Video,
Audio,
FlvSegment,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MediaCacheKey {
pub content_id: String,
pub media_kind: MediaRequestKind,
pub stream_id: Option<u32>,
pub codecs: Option<String>,
pub source_hash: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct HttpHeaderSpec {
pub name: String,
pub value: String,
}
pub(crate) fn header_specs_from_map(headers: &HeaderMap) -> Result<Vec<HttpHeaderSpec>> {
let mut specs = Vec::new();
for (name, value) in headers {
let value = value.to_str().map_err(|_| {
Error::InvalidInput(format!(
"media request header `{}` is not valid UTF-8",
name.as_str()
))
})?;
specs.push(HttpHeaderSpec {
name: name.as_str().to_owned(),
value: value.to_owned(),
});
}
specs.sort_by(|left, right| left.name.cmp(&right.name));
Ok(specs)
}
fn playback_variants(
entry: &DownloadEntry,
request_headers: &[HttpHeaderSpec],
) -> Vec<PlaybackVariant> {
let video_requests = entry
.streams
.videos
.iter()
.map(|stream| media_stream_request(entry, stream, MediaRequestKind::Video, request_headers))
.collect::<Vec<_>>();
let audio_requests = entry
.streams
.audios
.iter()
.map(|stream| media_stream_request(entry, stream, MediaRequestKind::Audio, request_headers))
.collect::<Vec<_>>();
let mut variants = Vec::new();
if video_requests.is_empty() {
variants.extend(
audio_requests
.iter()
.map(|audio| dash_variant(entry, None, Some(audio))),
);
} else if audio_requests.is_empty() {
variants.extend(
video_requests
.iter()
.map(|video| dash_variant(entry, Some(video), None)),
);
} else {
for video in &video_requests {
variants.extend(
audio_requests
.iter()
.map(|audio| dash_variant(entry, Some(video), Some(audio))),
);
}
}
if !entry.streams.flv_segments.is_empty() {
variants.push(flv_variant(entry, request_headers));
}
variants
}
fn dash_variant(
entry: &DownloadEntry,
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
) -> PlaybackVariant {
let id = dash_variant_id(video, audio);
let cache_key = playback_variant_cache_key(&id, PlaybackVariantKind::Dash, video, audio, &[]);
let mut codecs = Vec::new();
let mut mime_types = Vec::new();
push_unique(
&mut codecs,
video.and_then(|request| request.codecs.as_deref()),
);
push_unique(
&mut codecs,
audio.and_then(|request| request.codecs.as_deref()),
);
push_unique(
&mut mime_types,
video.and_then(|request| request.mime_type.as_deref()),
);
push_unique(
&mut mime_types,
audio.and_then(|request| request.mime_type.as_deref()),
);
PlaybackVariant {
id,
kind: PlaybackVariantKind::Dash,
video: video.cloned(),
audio: audio.cloned(),
flv_segments: Vec::new(),
bandwidth: combined_bandwidth(video, audio),
codecs,
mime_types,
width: video.and_then(|request| request.width),
height: video.and_then(|request| request.height),
frame_rate: video.and_then(|request| request.frame_rate.clone()),
duration_seconds: entry.streams.duration_seconds,
cache_key,
abr: None,
selection_hints: selection_hints(PlaybackVariantKind::Dash, video, audio),
}
}
fn flv_variant(entry: &DownloadEntry, request_headers: &[HttpHeaderSpec]) -> PlaybackVariant {
let flv_segments = entry
.streams
.flv_segments
.iter()
.map(|segment| flv_segment_request(entry, segment, request_headers))
.collect::<Vec<_>>();
let mut mime_types = Vec::new();
for segment in &flv_segments {
push_unique(&mut mime_types, segment.mime_type.as_deref());
}
let id = flv_variant_id(&flv_segments);
let cache_key =
playback_variant_cache_key(&id, PlaybackVariantKind::Flv, None, None, &flv_segments);
PlaybackVariant {
id,
kind: PlaybackVariantKind::Flv,
video: None,
audio: None,
bandwidth: None,
codecs: Vec::new(),
mime_types,
width: None,
height: None,
frame_rate: None,
duration_seconds: entry
.streams
.duration_seconds
.or_else(|| flv_segments_duration_seconds(&entry.streams.flv_segments)),
cache_key,
abr: None,
selection_hints: flv_selection_hints(),
flv_segments,
}
}
fn media_stream_request(
entry: &DownloadEntry,
stream: &MediaStream,
kind: MediaRequestKind,
request_headers: &[HttpHeaderSpec],
) -> MediaRequestSpec {
MediaRequestSpec {
kind,
stream_id: Some(stream.id),
url: stream.base_url.clone(),
backup_urls: stream.backup_urls.clone(),
headers: request_headers.to_vec(),
mime_type: stream.mime_type.clone(),
codecs: stream.codecs.clone(),
codec_family: stream
.codec_family
.or_else(|| stream.codecs.as_deref().map(codec_family)),
bandwidth: stream.bandwidth,
width: stream.width,
height: stream.height,
frame_rate: stream.frame_rate.clone(),
size: stream.size,
duration_seconds: entry.streams.duration_seconds,
cache_key: media_cache_key(
entry,
kind,
Some(stream.id),
stream.codecs.as_deref(),
&stream.base_url,
),
}
}
fn flv_segment_request(
entry: &DownloadEntry,
segment: &FlvSegment,
request_headers: &[HttpHeaderSpec],
) -> MediaRequestSpec {
MediaRequestSpec {
kind: MediaRequestKind::FlvSegment,
stream_id: Some(segment.order),
url: segment.url.clone(),
backup_urls: segment.backup_urls.clone(),
headers: request_headers.to_vec(),
mime_type: Some("video/x-flv".to_owned()),
codecs: None,
codec_family: None,
bandwidth: None,
width: None,
height: None,
frame_rate: None,
size: segment.size,
duration_seconds: segment.length_ms.and_then(ms_to_seconds_ceil_u32),
cache_key: media_cache_key(
entry,
MediaRequestKind::FlvSegment,
Some(segment.order),
None,
&segment.url,
),
}
}
fn media_cache_key(
entry: &DownloadEntry,
kind: MediaRequestKind,
stream_id: Option<u32>,
codecs: Option<&str>,
url: &str,
) -> MediaCacheKey {
MediaCacheKey {
content_id: entry_content_id(entry),
media_kind: kind,
stream_id,
codecs: codecs.map(ToOwned::to_owned),
source_hash: source_hash(url),
}
}
fn playback_entry_cache_key(entry: &DownloadEntry) -> PlaybackEntryCacheKey {
playback_entry_cache_key_from_parts(entry.bvid.as_deref(), entry.epid, entry.aid, entry.cid)
}
fn playback_entry_cache_key_from_parts(
bvid: Option<&str>,
epid: Option<u64>,
aid: u64,
cid: u64,
) -> PlaybackEntryCacheKey {
PlaybackEntryCacheKey {
content_id: entry_content_id_from_parts(bvid, epid, aid, cid),
}
}
fn playback_variant_cache_key(
variant_id: &str,
kind: PlaybackVariantKind,
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
flv_segments: &[MediaRequestSpec],
) -> PlaybackVariantCacheKey {
let media_keys = variant_media_keys(video, audio, flv_segments);
PlaybackVariantCacheKey {
content_id: media_keys
.first()
.map_or_else(String::new, |key| key.content_id.clone()),
variant_kind: kind,
variant_id: variant_id.to_owned(),
media_keys,
}
}
fn variant_media_keys(
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
flv_segments: &[MediaRequestSpec],
) -> Vec<MediaCacheKey> {
let mut media_keys = Vec::new();
if let Some(video) = video {
media_keys.push(video.cache_key.clone());
}
if let Some(audio) = audio {
media_keys.push(audio.cache_key.clone());
}
media_keys.extend(flv_segments.iter().map(|segment| segment.cache_key.clone()));
media_keys
}
fn entry_content_id(entry: &DownloadEntry) -> String {
entry_content_id_from_parts(entry.bvid.as_deref(), entry.epid, entry.aid, entry.cid)
}
fn entry_content_id_from_parts(
bvid: Option<&str>,
epid: Option<u64>,
aid: u64,
cid: u64,
) -> String {
let primary = bvid.filter(|bvid| !bvid.is_empty()).map_or_else(
|| epid.map_or_else(|| format!("av{aid}"), |epid| format!("ep{epid}")),
ToOwned::to_owned,
);
format!("{primary}-cid{cid}")
}
fn source_hash(url: &str) -> String {
let source = source_url_identity(url);
let digest = Md5::digest(source.as_bytes());
format!("{digest:x}")
}
fn source_url_identity(url: &str) -> String {
Url::parse(url).map_or_else(
|_| {
url.split_once('#').map_or_else(
|| url.to_owned(),
|(before_fragment, _fragment)| before_fragment.to_owned(),
)
},
|mut parsed| {
parsed.set_fragment(None);
parsed.to_string()
},
)
}
fn dash_variant_id(video: Option<&MediaRequestSpec>, audio: Option<&MediaRequestSpec>) -> String {
match (video, audio) {
(Some(video), Some(audio)) => {
format!(
"dash-v{}-{}-a{}-{}",
media_id_token(video),
short_source_hash(video),
media_id_token(audio),
short_source_hash(audio)
)
}
(Some(video), None) => {
format!(
"dash-v{}-{}",
media_id_token(video),
short_source_hash(video)
)
}
(None, Some(audio)) => {
format!(
"dash-a{}-{}",
media_id_token(audio),
short_source_hash(audio)
)
}
(None, None) => "dash-empty".to_owned(),
}
}
fn flv_variant_id(segments: &[MediaRequestSpec]) -> String {
segments.first().map_or_else(
|| "flv-empty".to_owned(),
|segment| {
format!(
"flv-s{}-{}",
media_id_token(segment),
short_source_hash(segment)
)
},
)
}
fn media_id_token(request: &MediaRequestSpec) -> String {
request
.stream_id
.map_or_else(|| "unknown".to_owned(), |id| id.to_string())
}
fn short_source_hash(request: &MediaRequestSpec) -> String {
request.cache_key.source_hash.chars().take(8).collect()
}
fn apply_abr_metadata(variants: &mut [PlaybackVariant]) -> PlaybackAbrMetadata {
for variant in variants.iter_mut() {
variant.abr = None;
}
let mut grouped = BTreeMap::<String, (PlaybackAbrGroupKind, Vec<usize>)>::new();
for (index, variant) in variants.iter().enumerate() {
if let Some((group_id, group_kind)) = abr_group_identity(variant) {
grouped
.entry(group_id)
.and_modify(|(_kind, indices)| indices.push(index))
.or_insert_with(|| (group_kind, vec![index]));
}
}
let mut groups = Vec::new();
for (group_id, (group_kind, mut indices)) in grouped {
indices.sort_by(|left, right| abr_variant_cmp(&variants[*left], &variants[*right]));
let level_count = u32::try_from(indices.len()).unwrap_or(u32::MAX);
let switchable = indices.len() > 1;
let min_bandwidth = indices
.iter()
.filter_map(|index| variants[*index].bandwidth)
.min();
let max_bandwidth = indices
.iter()
.filter_map(|index| variants[*index].bandwidth)
.max();
let variant_ids = indices
.iter()
.map(|index| variants[*index].id.clone())
.collect::<Vec<_>>();
for (level_index, variant_index) in indices.iter().enumerate() {
variants[*variant_index].abr = Some(PlaybackAbrLevel {
group_id: group_id.clone(),
level_index: u32::try_from(level_index).unwrap_or(u32::MAX),
level_count,
switchable,
});
}
groups.push(PlaybackAbrGroup {
id: group_id,
kind: group_kind,
variant_ids,
level_count,
min_bandwidth,
max_bandwidth,
});
}
PlaybackAbrMetadata { groups }
}
fn abr_group_identity(variant: &PlaybackVariant) -> Option<(String, PlaybackAbrGroupKind)> {
if variant.kind != PlaybackVariantKind::Dash {
return None;
}
if variant.video.is_some() {
let video_identity = variant
.video
.as_ref()
.map_or_else(|| "novideo".to_owned(), media_compatibility_component);
let audio_identity = variant
.audio
.as_ref()
.map_or_else(|| "noaudio".to_owned(), media_cache_component);
return Some((
format!(
"dash-video-{}-{video_identity}-{audio_identity}",
variant.cache_key.content_id
),
PlaybackAbrGroupKind::DashVideo,
));
}
if variant.audio.is_some() {
let audio_identity = variant
.audio
.as_ref()
.map_or_else(|| "noaudio".to_owned(), media_compatibility_component);
return Some((
format!(
"dash-audio-{}-{audio_identity}",
variant.cache_key.content_id
),
PlaybackAbrGroupKind::DashAudioOnly,
));
}
None
}
fn media_cache_component(request: &MediaRequestSpec) -> String {
format!(
"{}{}-{}",
media_kind_prefix(request.kind),
media_id_token(request),
request.cache_key.source_hash
)
}
fn media_compatibility_component(request: &MediaRequestSpec) -> String {
let codec = media_compatibility_codec_component(request);
let mime = request
.mime_type
.as_deref()
.filter(|value| !value.is_empty())
.map_or_else(|| "unknown".to_owned(), abr_identity_part);
format!("{codec}-{mime}")
}
fn media_compatibility_codec_component(request: &MediaRequestSpec) -> String {
if let Some(family) = request.codec_family {
return match family {
PlaybackCodecFamily::Unknown => format!("unknown-{}", media_cache_component(request)),
PlaybackCodecFamily::Other => request.codecs.as_deref().map_or_else(
|| "other".to_owned(),
|codec| format!("other-{}", abr_identity_part(codec)),
),
family => codec_family_key(family).to_owned(),
};
}
request.codecs.as_deref().map_or_else(
|| format!("unknown-{}", media_cache_component(request)),
|codec| {
let normalized = codec.trim().to_ascii_lowercase();
match codec_family(&normalized) {
PlaybackCodecFamily::Unknown => {
format!("unknown-{}", media_cache_component(request))
}
PlaybackCodecFamily::Other => format!("other-{}", abr_identity_part(&normalized)),
family => codec_family_key(family).to_owned(),
}
},
)
}
fn abr_identity_part(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect()
}
fn media_kind_prefix(kind: MediaRequestKind) -> &'static str {
match kind {
MediaRequestKind::Video => "v",
MediaRequestKind::Audio => "a",
MediaRequestKind::FlvSegment => "s",
}
}
fn abr_variant_cmp(left: &PlaybackVariant, right: &PlaybackVariant) -> Ordering {
abr_bandwidth_sort_key(left)
.cmp(&abr_bandwidth_sort_key(right))
.then_with(|| {
left.height
.unwrap_or_default()
.cmp(&right.height.unwrap_or_default())
})
.then_with(|| {
left.width
.unwrap_or_default()
.cmp(&right.width.unwrap_or_default())
})
.then_with(|| left.id.cmp(&right.id))
}
fn abr_bandwidth_sort_key(variant: &PlaybackVariant) -> u64 {
variant.bandwidth.unwrap_or(u64::MAX)
}
fn selection_hints(
kind: PlaybackVariantKind,
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
) -> PlaybackSelectionHints {
PlaybackSelectionHints {
avplayer: avplayer_hint(kind, video, audio),
}
}
fn flv_selection_hints() -> PlaybackSelectionHints {
PlaybackSelectionHints {
avplayer: PlaybackSelectionHint {
format_key: "flv".to_owned(),
video_codec: None,
audio_codec: None,
playable: false,
preferred: false,
score: -100,
video_codec_family: None,
audio_codec_family: None,
reasons: vec![PlaybackSelectionReason::FlvContainer],
},
}
}
fn avplayer_hint(
kind: PlaybackVariantKind,
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
) -> PlaybackSelectionHint {
if kind != PlaybackVariantKind::Dash {
return PlaybackSelectionHint {
format_key: "flv".to_owned(),
video_codec: None,
audio_codec: None,
playable: false,
preferred: false,
score: -100,
video_codec_family: None,
audio_codec_family: None,
reasons: vec![PlaybackSelectionReason::FlvContainer],
};
}
let video_family = request_codec_family(video);
let audio_family = request_codec_family(audio);
let video_codec = video.and_then(|request| request.codecs.clone());
let audio_codec = audio.and_then(|request| request.codecs.clone());
let mut playable = video.is_some() || audio.is_some();
let mut score = 0_i32;
let mut reasons = vec![PlaybackSelectionReason::DashContainer];
let format_key = format_key(kind, video_family, audio_family);
match (video, video_family) {
(Some(_), Some(PlaybackCodecFamily::H264)) => {
score += 60;
reasons.push(PlaybackSelectionReason::H264Video);
}
(Some(_), Some(PlaybackCodecFamily::Hevc)) => {
score += 55;
reasons.push(PlaybackSelectionReason::HevcVideo);
}
(Some(_), Some(PlaybackCodecFamily::Av1)) => {
score += 50;
reasons.push(PlaybackSelectionReason::Av1Video);
}
(Some(_), Some(PlaybackCodecFamily::Unknown) | None) => {
playable = false;
score -= 30;
reasons.push(PlaybackSelectionReason::UnknownVideoCodec);
}
(Some(_), Some(_)) => {
playable = false;
score -= 40;
reasons.push(PlaybackSelectionReason::UnsupportedVideoCodec);
}
(None, _) => {
score -= 20;
reasons.push(PlaybackSelectionReason::MissingVideo);
}
}
match (audio, audio_family) {
(Some(_), Some(PlaybackCodecFamily::Aac)) => {
score += 30;
reasons.push(PlaybackSelectionReason::AacAudio);
}
(Some(_), Some(PlaybackCodecFamily::Unknown) | None) => {
playable = false;
score -= 20;
reasons.push(PlaybackSelectionReason::UnknownAudioCodec);
}
(Some(_), Some(_)) => {
playable = false;
score -= 30;
reasons.push(PlaybackSelectionReason::UnsupportedAudioCodec);
}
(None, _) => {
score -= 10;
reasons.push(PlaybackSelectionReason::MissingAudio);
}
}
PlaybackSelectionHint {
format_key,
video_codec,
audio_codec,
playable,
preferred: playable && score >= 80,
score,
video_codec_family: video_family,
audio_codec_family: audio_family,
reasons,
}
}
fn codec_preference_rank(
family: Option<PlaybackCodecFamily>,
preferred: &[PlaybackCodecFamily],
) -> Option<usize> {
if preferred.is_empty() {
return Some(0);
}
let family = family?;
preferred
.iter()
.position(|candidate| *candidate == family)
.map(|rank| rank + 1)
}
fn hint_needs_backfill(
hint: &PlaybackSelectionHint,
kind: PlaybackVariantKind,
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
) -> bool {
let video_codec = request_codec(video);
let audio_codec = request_codec(audio);
let video_family = request_codec_family(video);
let audio_family = request_codec_family(audio);
let expected_format_key = format_key(kind, video_family, audio_family);
hint.format_key != expected_format_key
|| hint.video_codec_family != video_family
|| hint.audio_codec_family != audio_family
|| video_codec.is_some() && hint.video_codec.as_deref() != video_codec
|| audio_codec.is_some() && hint.audio_codec.as_deref() != audio_codec
}
fn request_codec(request: Option<&MediaRequestSpec>) -> Option<&str> {
request.and_then(|request| request.codecs.as_deref())
}
fn request_codec_family(request: Option<&MediaRequestSpec>) -> Option<PlaybackCodecFamily> {
request.and_then(|request| {
request
.codec_family
.or_else(|| request.codecs.as_deref().map(codec_family))
})
}
fn format_key(
kind: PlaybackVariantKind,
video_family: Option<PlaybackCodecFamily>,
audio_family: Option<PlaybackCodecFamily>,
) -> String {
if kind == PlaybackVariantKind::Flv {
return "flv".to_owned();
}
let mut parts = Vec::new();
if let Some(family) = video_family {
parts.push(codec_family_key(family));
}
if let Some(family) = audio_family {
parts.push(codec_family_key(family));
}
if parts.is_empty() {
"unknown".to_owned()
} else {
parts.join("+")
}
}
fn codec_family_key(family: PlaybackCodecFamily) -> &'static str {
match family {
PlaybackCodecFamily::H264 => "h264",
PlaybackCodecFamily::Hevc => "hevc",
PlaybackCodecFamily::Av1 => "av1",
PlaybackCodecFamily::Vp9 => "vp9",
PlaybackCodecFamily::Aac => "aac",
PlaybackCodecFamily::Flac => "flac",
PlaybackCodecFamily::Dolby => "dolby",
PlaybackCodecFamily::Unknown => "unknown",
PlaybackCodecFamily::Other => "other",
}
}
fn unknown_format_key() -> String {
"unknown".to_owned()
}
fn codec_family(codec: &str) -> PlaybackCodecFamily {
let normalized = codec.trim().to_ascii_lowercase();
if normalized.is_empty() {
return PlaybackCodecFamily::Unknown;
}
if normalized.starts_with("avc1") || normalized.starts_with("avc3") {
PlaybackCodecFamily::H264
} else if normalized.starts_with("hev1") || normalized.starts_with("hvc1") {
PlaybackCodecFamily::Hevc
} else if normalized.starts_with("av01") {
PlaybackCodecFamily::Av1
} else if normalized.starts_with("vp09") || normalized.starts_with("vp9") {
PlaybackCodecFamily::Vp9
} else if is_aac_mp4a_codec(&normalized) {
PlaybackCodecFamily::Aac
} else if normalized.starts_with("flac") {
PlaybackCodecFamily::Flac
} else if normalized.starts_with("ec-3")
|| normalized.starts_with("ec3")
|| normalized.starts_with("ac-3")
|| normalized.starts_with("ac3")
{
PlaybackCodecFamily::Dolby
} else {
PlaybackCodecFamily::Other
}
}
fn is_aac_mp4a_codec(codec: &str) -> bool {
matches!(
codec,
"mp4a.40.2" | "mp4a.40.5" | "mp4a.40.29" | "mp4a.40.42"
)
}
fn push_unique(values: &mut Vec<String>, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty())
&& !values.iter().any(|existing| existing == value)
{
values.push(value.to_owned());
}
}
fn combined_bandwidth(
video: Option<&MediaRequestSpec>,
audio: Option<&MediaRequestSpec>,
) -> Option<u64> {
match (video, audio) {
(Some(video), Some(audio)) => Some(video.bandwidth?.saturating_add(audio.bandwidth?)),
(Some(video), None) => video.bandwidth,
(None, Some(audio)) => audio.bandwidth,
(None, None) => None,
}
}
fn flv_segments_duration_seconds(segments: &[FlvSegment]) -> Option<u32> {
if segments.is_empty() {
return None;
}
let mut total_ms = 0_u64;
for segment in segments {
total_ms = total_ms.checked_add(segment.length_ms?)?;
}
ms_to_seconds_ceil_u32(total_ms)
}
fn ms_to_seconds_ceil_u32(milliseconds: u64) -> Option<u32> {
let seconds = milliseconds.saturating_add(999) / 1000;
u32::try_from(seconds).ok()
}
#[cfg(test)]
mod tests {
use super::{
HttpHeaderSpec, MediaRequestKind, PlaybackAbrGroupKind, PlaybackCodecFamily,
PlaybackCodecPreference, PlaybackEntry, PlaybackPlan, PlaybackSelectionReason,
PlaybackVariant, PlaybackVariantKind, codec_family, source_hash,
};
use crate::{
CodecFamily, DanmakuTrack, DownloadEntry, DownloadPlan, FlvSegment, MediaStream,
StreamDiagnostics, StreamQuality, StreamSet, StreamSource,
};
#[test]
fn builds_dash_playback_variants_from_download_plan() -> anyhow::Result<()> {
let mut primary_video = media_stream(80, "avc1.640028", 1_200_000);
primary_video.backup_urls = vec!["https://backup.example/80.m4s".to_owned()];
primary_video.width = Some(1920);
primary_video.height = Some(1080);
primary_video.frame_rate = Some("60".to_owned());
primary_video.size = Some(10_000);
let mut audio = audio_stream("mp4a.40.2", 128_000);
audio.backup_urls = vec!["https://backup.example/30280.m4s".to_owned()];
let plan = test_plan(StreamSet {
videos: vec![
primary_video,
media_stream(64, "hev1.1.6.L120.90", 800_000),
media_stream(120, "av01.0.08M.08", 600_000),
],
audios: vec![audio],
flv_segments: Vec::new(),
accept_quality: vec![80, 64, 120],
qualities: vec![StreamQuality {
id: 80,
description: Some("1080P".to_owned()),
}],
duration_seconds: Some(90),
});
let headers = vec![
HttpHeaderSpec {
name: "referer".to_owned(),
value: "https://www.bilibili.com/".to_owned(),
},
HttpHeaderSpec {
name: "user-agent".to_owned(),
value: "bbdown-rs/0.1".to_owned(),
},
];
let playback = PlaybackPlan::from_download_plan(&plan, &headers);
assert_eq!(playback.title, "Mock video");
assert_eq!(
playback.entries[0].cache_key.content_id,
"BV1xx411c7mD-cid2"
);
assert_eq!(playback.entries[0].abr.groups.len(), 3);
assert_eq!(playback.entries[0].variants.len(), 3);
let variant = &playback.entries[0].variants[0];
let hevc_variant = &playback.entries[0].variants[1];
let av1_variant = &playback.entries[0].variants[2];
assert_eq!(variant.kind, PlaybackVariantKind::Dash);
assert_eq!(variant.bandwidth, Some(1_328_000));
assert_eq!(variant.codecs, ["avc1.640028", "mp4a.40.2"]);
assert_eq!(variant.mime_types, ["video/mp4", "audio/mp4"]);
assert_eq!(variant.width, Some(1920));
assert_eq!(variant.height, Some(1080));
assert_eq!(variant.cache_key.content_id, "BV1xx411c7mD-cid2");
assert_eq!(variant.cache_key.variant_kind, PlaybackVariantKind::Dash);
assert_eq!(variant.cache_key.variant_id, variant.id);
assert_eq!(variant.cache_key.media_keys.len(), 2);
let abr = variant
.abr
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing ABR level"))?;
let abr_group = playback.entries[0]
.abr
.groups
.iter()
.find(|group| group.id == abr.group_id)
.ok_or_else(|| anyhow::anyhow!("missing ABR group"))?;
assert_eq!(abr_group.kind, PlaybackAbrGroupKind::DashVideo);
assert_eq!(abr_group.level_count, 1);
assert_eq!(abr_group.min_bandwidth, Some(1_328_000));
assert_eq!(abr_group.max_bandwidth, Some(1_328_000));
assert_eq!(abr_group.variant_ids, vec![variant.id.clone()]);
assert_eq!(abr.group_id, abr_group.id);
assert_eq!(abr.level_index, 0);
assert_eq!(abr.level_count, 1);
assert!(!abr.switchable);
assert_preferred_avplayer_hint(variant);
let video = variant
.video
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing video request"))?;
assert_eq!(video.url, "https://video.example/80.m4s?token=secret");
assert_eq!(video.backup_urls, ["https://backup.example/80.m4s"]);
assert_eq!(video.headers, headers);
assert_eq!(video.cache_key.media_kind, MediaRequestKind::Video);
assert_eq!(video.cache_key.content_id, "BV1xx411c7mD-cid2");
assert_eq!(video.cache_key.stream_id, Some(80));
assert_eq!(
video.cache_key.source_hash,
source_hash("https://video.example/80.m4s?token=secret")
);
let audio = variant
.audio
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing audio request"))?;
assert_eq!(audio.cache_key.media_kind, MediaRequestKind::Audio);
assert!(variant.id.starts_with("dash-v80-"));
assert_hevc_avplayer_hint(hevc_variant);
assert_av1_avplayer_hint(av1_variant);
Ok(())
}
#[test]
fn builds_flv_playback_variant_with_segment_specs() {
let plan = test_plan(StreamSet {
videos: Vec::new(),
audios: Vec::new(),
flv_segments: vec![
FlvSegment {
order: 1,
url: "https://flv.example/1.flv?token=secret".to_owned(),
backup_urls: vec!["https://backup.example/1.flv".to_owned()],
size: Some(1_000),
length_ms: Some(1_500),
},
FlvSegment {
order: 2,
url: "https://flv.example/2.flv?token=secret".to_owned(),
backup_urls: Vec::new(),
size: Some(2_000),
length_ms: Some(2_500),
},
],
accept_quality: Vec::new(),
qualities: Vec::new(),
duration_seconds: None,
});
let playback = PlaybackPlan::from_download_plan(&plan, &[]);
assert!(playback.entries[0].abr.groups.is_empty());
assert_eq!(playback.entries[0].variants.len(), 1);
let variant = &playback.entries[0].variants[0];
assert_eq!(variant.kind, PlaybackVariantKind::Flv);
assert_eq!(variant.mime_types, ["video/x-flv"]);
assert!(variant.abr.is_none());
assert_eq!(variant.cache_key.media_keys.len(), 2);
assert_flv_avplayer_hint(variant);
assert!(
!variant
.matches_codec_preference(&PlaybackCodecPreference::new(Vec::new(), Vec::new()))
);
assert_eq!(variant.duration_seconds, Some(4));
assert_eq!(variant.flv_segments.len(), 2);
assert_eq!(variant.flv_segments[0].stream_id, Some(1));
assert_eq!(variant.flv_segments[0].duration_seconds, Some(2));
assert_eq!(
variant.flv_segments[0].mime_type.as_deref(),
Some("video/x-flv")
);
assert_eq!(
variant.flv_segments[0].cache_key.source_hash,
source_hash("https://flv.example/1.flv?token=secret")
);
}
#[test]
fn source_hash_preserves_query_identity_and_ignores_fragments() {
assert_ne!(
source_hash("https://proxy.example/media?u=one"),
source_hash("https://proxy.example/media?u=two")
);
assert_eq!(
source_hash("https://video.example/80.m4s?token=secret#frag"),
source_hash("https://video.example/80.m4s?token=secret")
);
}
#[test]
fn deserializes_legacy_variant_without_selection_hints() -> anyhow::Result<()> {
let variant: PlaybackVariant = serde_json::from_value(serde_json::json!({
"id": "legacy",
"kind": "dash",
"video": legacy_request_json("video", 80, "video/mp4", "avc1.640028"),
"audio": legacy_request_json("audio", 30280, "audio/mp4", "mp4a.40.2"),
"flv_segments": [],
"bandwidth": 1_328_000,
"codecs": ["avc1.640028", "mp4a.40.2"],
"mime_types": ["video/mp4", "audio/mp4"],
"width": 1920,
"height": 1080,
"frame_rate": "60",
"duration_seconds": 90
}))?;
assert_preferred_avplayer_hint(&variant);
Ok(())
}
#[test]
fn deserializes_legacy_avplayer_h264_aac_hint_by_rebuilding_current_hint() -> anyhow::Result<()>
{
let variant: PlaybackVariant = serde_json::from_value(serde_json::json!({
"id": "legacy-hevc",
"kind": "dash",
"video": legacy_request_json("video", 64, "video/mp4", "hev1.1.6.L120.90"),
"audio": legacy_request_json("audio", 30280, "audio/mp4", "mp4a.40.2"),
"flv_segments": [],
"bandwidth": 928_000,
"codecs": ["hev1.1.6.L120.90", "mp4a.40.2"],
"mime_types": ["video/mp4", "audio/mp4"],
"width": 1280,
"height": 720,
"frame_rate": "30",
"duration_seconds": 90,
"selection_hints": {
"avplayer_h264_aac": {
"playable": false,
"preferred": false,
"score": -10,
"video_codec_family": "hevc",
"audio_codec_family": "aac"
}
}
}))?;
assert_hevc_avplayer_hint(&variant);
Ok(())
}
#[test]
fn deserializes_legacy_entry_without_cache_or_abr_metadata() -> anyhow::Result<()> {
let entry: PlaybackEntry = serde_json::from_value(serde_json::json!({
"index": 1,
"aid": 170_001,
"bvid": "BV1xx411c7mD",
"cid": 2,
"epid": null,
"title": "Main",
"cover_url": null,
"source": "normal_web",
"qualities": [],
"duration_seconds": 90,
"variants": [
{
"id": "legacy-h264",
"kind": "dash",
"video": legacy_request_json("video", 80, "video/mp4", "avc1.640028"),
"audio": legacy_request_json("audio", 30280, "audio/mp4", "mp4a.40.2"),
"flv_segments": [],
"bandwidth": 1_328_000,
"codecs": ["avc1.640028", "mp4a.40.2"],
"mime_types": ["video/mp4", "audio/mp4"],
"width": 1920,
"height": 1080,
"frame_rate": "60",
"duration_seconds": 90
},
{
"id": "legacy-hevc",
"kind": "dash",
"video": legacy_request_json("video", 64, "video/mp4", "hev1.1.6.L120.90"),
"audio": legacy_request_json("audio", 30280, "audio/mp4", "mp4a.40.2"),
"flv_segments": [],
"bandwidth": 928_000,
"codecs": ["hev1.1.6.L120.90", "mp4a.40.2"],
"mime_types": ["video/mp4", "audio/mp4"],
"width": 1280,
"height": 720,
"frame_rate": "30",
"duration_seconds": 90
}
]
}))?;
assert_eq!(entry.cache_key.content_id, "BV1xx411c7mD-cid2");
assert_eq!(entry.abr.groups.len(), 2);
assert!(entry.abr.groups.iter().all(|group| group.level_count == 1));
assert_eq!(entry.variants[0].cache_key.media_keys.len(), 2);
assert_eq!(
entry.variants[0].abr.as_ref().map(|abr| abr.level_index),
Some(0)
);
assert_eq!(
entry.variants[1].abr.as_ref().map(|abr| abr.level_index),
Some(0)
);
assert!(
entry
.variants
.iter()
.all(|variant| !variant.abr.as_ref().is_some_and(|abr| abr.switchable))
);
Ok(())
}
#[test]
fn abr_order_treats_partial_combined_bandwidth_as_unknown() {
let mut unknown_high_video = media_stream(80, "avc1.640028", 1_200_000);
unknown_high_video.bandwidth = None;
unknown_high_video.width = Some(1920);
unknown_high_video.height = Some(1080);
let known_low_video = media_stream(64, "avc1.640028", 800_000);
let plan = test_plan(StreamSet {
videos: vec![unknown_high_video, known_low_video],
audios: vec![audio_stream("mp4a.40.2", 128_000)],
flv_segments: Vec::new(),
accept_quality: vec![80, 64],
qualities: Vec::new(),
duration_seconds: Some(90),
});
let playback = PlaybackPlan::from_download_plan(&plan, &[]);
let variants = &playback.entries[0].variants;
let abr_group = &playback.entries[0].abr.groups[0];
assert_eq!(variants[0].bandwidth, None);
assert_eq!(variants[1].bandwidth, Some(928_000));
assert_eq!(abr_group.min_bandwidth, Some(928_000));
assert_eq!(abr_group.max_bandwidth, Some(928_000));
assert_eq!(
abr_group.variant_ids,
vec![variants[1].id.clone(), variants[0].id.clone()]
);
assert_eq!(abr_group.level_count, 2);
assert_eq!(variants[0].abr.as_ref().map(|abr| abr.level_index), Some(1));
assert_eq!(variants[1].abr.as_ref().map(|abr| abr.level_index), Some(0));
assert!(variants[0].abr.as_ref().is_some_and(|abr| abr.switchable));
assert!(variants[1].abr.as_ref().is_some_and(|abr| abr.switchable));
}
#[test]
fn abr_groups_isolate_missing_video_codecs() {
let mut first_unknown = media_stream(80, "avc1.640028", 1_200_000);
first_unknown.codecs = None;
let mut second_unknown = media_stream(64, "avc1.640028", 800_000);
second_unknown.codecs = None;
let plan = test_plan(StreamSet {
videos: vec![first_unknown, second_unknown],
audios: vec![audio_stream("mp4a.40.2", 128_000)],
flv_segments: Vec::new(),
accept_quality: vec![80, 64],
qualities: Vec::new(),
duration_seconds: Some(90),
});
let playback = PlaybackPlan::from_download_plan(&plan, &[]);
let variants = &playback.entries[0].variants;
assert_eq!(playback.entries[0].abr.groups.len(), 2);
assert!(
playback.entries[0]
.abr
.groups
.iter()
.all(|group| group.level_count == 1)
);
assert_ne!(
variants[0].abr.as_ref().map(|abr| abr.group_id.as_str()),
variants[1].abr.as_ref().map(|abr| abr.group_id.as_str())
);
assert!(
variants
.iter()
.all(|variant| !variant.abr.as_ref().is_some_and(|abr| abr.switchable))
);
}
#[test]
fn codec_family_keeps_family_only_app_streams_selectable() {
let mut hevc_video = media_stream(80, "hev1.1.6.L120.90", 1_200_000);
hevc_video.codecs = None;
hevc_video.codec_family = Some(CodecFamily::Hevc);
let mut lower_hevc_video = media_stream(64, "hev1.1.6.L120.90", 800_000);
lower_hevc_video.codecs = None;
lower_hevc_video.codec_family = Some(CodecFamily::Hevc);
let plan = test_plan(StreamSet {
videos: vec![hevc_video, lower_hevc_video],
audios: vec![audio_stream("mp4a.40.2", 128_000)],
flv_segments: Vec::new(),
accept_quality: vec![80, 64],
qualities: Vec::new(),
duration_seconds: Some(90),
});
let playback = PlaybackPlan::from_download_plan(&plan, &[]);
let variants = &playback.entries[0].variants;
let hint = &variants[0].selection_hints.avplayer;
assert_eq!(playback.entries[0].abr.groups.len(), 1);
assert_eq!(playback.entries[0].abr.groups[0].level_count, 2);
assert_eq!(
variants[0]
.video
.as_ref()
.and_then(|video| video.codecs.clone()),
None
);
assert_eq!(
variants[0]
.video
.as_ref()
.and_then(|video| video.codec_family),
Some(PlaybackCodecFamily::Hevc)
);
assert_eq!(hint.video_codec, None);
assert_eq!(hint.video_codec_family, Some(PlaybackCodecFamily::Hevc));
assert_eq!(
variants[0].codec_preference_rank(&PlaybackCodecPreference::hevc_aac()),
Some(1_001)
);
assert!(variants[0].abr.as_ref().is_some_and(|abr| abr.switchable));
}
#[test]
fn codec_family_keeps_family_only_app_audio_visible() -> anyhow::Result<()> {
let mut flac_audio = audio_stream("flac", 800_000);
flac_audio.codecs = None;
flac_audio.codec_family = Some(CodecFamily::Flac);
let plan = test_plan(StreamSet {
videos: vec![media_stream(80, "avc1.640028", 1_200_000)],
audios: vec![flac_audio],
flv_segments: Vec::new(),
accept_quality: vec![80],
qualities: Vec::new(),
duration_seconds: Some(90),
});
let playback = PlaybackPlan::from_download_plan(&plan, &[]);
let variant = &playback.entries[0].variants[0];
let audio = variant
.audio
.as_ref()
.ok_or_else(|| anyhow::anyhow!("audio request"))?;
let hint = &variant.selection_hints.avplayer;
assert_eq!(audio.codecs, None);
assert_eq!(audio.codec_family, Some(PlaybackCodecFamily::Flac));
assert_eq!(hint.audio_codec, None);
assert_eq!(hint.audio_codec_family, Some(PlaybackCodecFamily::Flac));
assert_eq!(hint.format_key, "h264+flac");
assert!(!hint.playable);
assert!(
hint.reasons
.contains(&PlaybackSelectionReason::UnsupportedAudioCodec)
);
Ok(())
}
#[test]
fn abr_groups_separate_other_video_codec_strings() {
let first_other = media_stream(80, "future.1", 1_200_000);
let second_other = media_stream(64, "future.2", 800_000);
let plan = test_plan(StreamSet {
videos: vec![first_other, second_other],
audios: vec![audio_stream("mp4a.40.2", 128_000)],
flv_segments: Vec::new(),
accept_quality: vec![80, 64],
qualities: Vec::new(),
duration_seconds: Some(90),
});
let playback = PlaybackPlan::from_download_plan(&plan, &[]);
let variants = &playback.entries[0].variants;
assert_eq!(playback.entries[0].abr.groups.len(), 2);
assert_ne!(
variants[0].abr.as_ref().map(|abr| abr.group_id.as_str()),
variants[1].abr.as_ref().map(|abr| abr.group_id.as_str())
);
}
#[test]
fn codec_family_distinguishes_mp4a_object_types() {
assert_eq!(codec_family("mp4a.40.2"), PlaybackCodecFamily::Aac);
assert_eq!(codec_family("mp4a.40.5"), PlaybackCodecFamily::Aac);
assert_eq!(codec_family("mp4a.40.29"), PlaybackCodecFamily::Aac);
assert_eq!(codec_family("mp4a.40.42"), PlaybackCodecFamily::Aac);
assert_eq!(codec_family("mp4a.40"), PlaybackCodecFamily::Other);
assert_eq!(codec_family("mp4a.40.34"), PlaybackCodecFamily::Other);
assert_eq!(codec_family("mp4a.69"), PlaybackCodecFamily::Other);
assert_eq!(codec_family("mp4a.6b"), PlaybackCodecFamily::Other);
}
#[test]
fn codec_preference_can_prioritize_non_h264_variants() {
let plan = test_plan(StreamSet {
videos: vec![
media_stream(80, "avc1.640028", 1_200_000),
media_stream(64, "hev1.1.6.L120.90", 800_000),
media_stream(120, "av01.0.08M.08", 600_000),
],
audios: vec![audio_stream("mp4a.40.2", 128_000)],
flv_segments: Vec::new(),
accept_quality: vec![80, 64, 120],
qualities: Vec::new(),
duration_seconds: Some(90),
});
let playback = PlaybackPlan::from_download_plan(&plan, &[]);
let variants = &playback.entries[0].variants;
let hevc_first = PlaybackCodecPreference::new(
vec![
PlaybackCodecFamily::Hevc,
PlaybackCodecFamily::H264,
PlaybackCodecFamily::Av1,
],
vec![PlaybackCodecFamily::Aac],
);
let av1_only = PlaybackCodecPreference::av1_aac();
assert_eq!(variants[0].codec_preference_rank(&hevc_first), Some(2_001));
assert_eq!(variants[1].codec_preference_rank(&hevc_first), Some(1_001));
assert_eq!(variants[2].codec_preference_rank(&hevc_first), Some(3_001));
assert!(!variants[0].matches_codec_preference(&av1_only));
assert!(variants[2].matches_codec_preference(&av1_only));
assert_eq!(
variants[1].selection_hints.avplayer.video_codec.as_deref(),
Some("hev1.1.6.L120.90")
);
}
fn legacy_request_json(
kind: &str,
stream_id: u32,
mime_type: &str,
codecs: &str,
) -> serde_json::Value {
serde_json::json!({
"kind": kind,
"stream_id": stream_id,
"url": format!("https://media.example/{stream_id}.m4s"),
"backup_urls": [],
"headers": [],
"mime_type": mime_type,
"codecs": codecs,
"bandwidth": 1_000,
"width": null,
"height": null,
"frame_rate": null,
"size": 1_000,
"duration_seconds": 90,
"cache_key": {
"content_id": "BV1xx411c7mD-cid2",
"media_kind": kind,
"stream_id": stream_id,
"codecs": codecs,
"source_hash": "0123456789abcdef0123456789abcdef"
}
})
}
fn assert_preferred_avplayer_hint(variant: &PlaybackVariant) {
let hint = &variant.selection_hints.avplayer;
assert!(hint.playable);
assert!(hint.preferred);
assert_eq!(hint.score, 90);
assert_eq!(hint.format_key, "h264+aac");
assert_eq!(hint.video_codec.as_deref(), Some("avc1.640028"));
assert_eq!(hint.audio_codec.as_deref(), Some("mp4a.40.2"));
assert_eq!(hint.video_codec_family, Some(PlaybackCodecFamily::H264));
assert_eq!(hint.audio_codec_family, Some(PlaybackCodecFamily::Aac));
assert!(hint.reasons.contains(&PlaybackSelectionReason::H264Video));
assert!(hint.reasons.contains(&PlaybackSelectionReason::AacAudio));
}
fn assert_hevc_avplayer_hint(variant: &PlaybackVariant) {
let hint = &variant.selection_hints.avplayer;
assert!(hint.playable);
assert!(hint.preferred);
assert_eq!(hint.score, 85);
assert_eq!(hint.format_key, "hevc+aac");
assert_eq!(hint.video_codec.as_deref(), Some("hev1.1.6.L120.90"));
assert_eq!(hint.video_codec_family, Some(PlaybackCodecFamily::Hevc));
assert!(hint.reasons.contains(&PlaybackSelectionReason::HevcVideo));
}
fn assert_av1_avplayer_hint(variant: &PlaybackVariant) {
let hint = &variant.selection_hints.avplayer;
assert!(hint.playable);
assert!(hint.preferred);
assert_eq!(hint.score, 80);
assert_eq!(hint.format_key, "av1+aac");
assert_eq!(hint.video_codec.as_deref(), Some("av01.0.08M.08"));
assert_eq!(hint.video_codec_family, Some(PlaybackCodecFamily::Av1));
assert!(hint.reasons.contains(&PlaybackSelectionReason::Av1Video));
}
fn assert_flv_avplayer_hint(variant: &PlaybackVariant) {
let hint = &variant.selection_hints.avplayer;
assert!(!hint.playable);
assert!(!hint.preferred);
assert_eq!(hint.score, -100);
assert_eq!(hint.format_key, "flv");
assert!(
hint.reasons
.contains(&PlaybackSelectionReason::FlvContainer)
);
}
fn test_plan(streams: StreamSet) -> DownloadPlan {
DownloadPlan {
title: "Mock video".to_owned(),
entries: vec![DownloadEntry {
index: 1,
aid: 170_001,
bvid: Some("BV1xx411c7mD".to_owned()),
cid: 2,
epid: None,
title: "Main".to_owned(),
cover_url: Some("https://example.invalid/cover.jpg".to_owned()),
source: StreamSource::NormalWeb,
streams,
diagnostics: StreamDiagnostics::default(),
subtitles: Vec::new(),
danmaku: DanmakuTrack {
cid: 2,
xml_url: "https://comment.example/2.xml".to_owned(),
},
}],
}
}
fn media_stream(id: u32, codecs: &str, bandwidth: u64) -> MediaStream {
MediaStream {
id,
base_url: format!("https://video.example/{id}.m4s?token=secret"),
backup_urls: Vec::new(),
codecs: Some(codecs.to_owned()),
codec_family: None,
bandwidth: Some(bandwidth),
width: Some(1280),
height: Some(720),
frame_rate: Some("30".to_owned()),
mime_type: Some("video/mp4".to_owned()),
size: Some(8_000),
}
}
fn audio_stream(codecs: &str, bandwidth: u64) -> MediaStream {
MediaStream {
id: 30280,
base_url: "https://audio.example/30280.m4s?token=secret".to_owned(),
backup_urls: Vec::new(),
codecs: Some(codecs.to_owned()),
codec_family: None,
bandwidth: Some(bandwidth),
width: None,
height: None,
frame_rate: None,
mime_type: Some("audio/mp4".to_owned()),
size: Some(2_000),
}
}
}