feat: 实现除刷新凭据外的功能,在 main 中给出使用示例
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
auth_data
|
||||||
|
|||||||
2
.rustfmt.toml
Normal file
2
.rustfmt.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
imports_granularity = "Module"
|
||||||
74
Cargo.lock
generated
74
Cargo.lock
generated
@@ -17,6 +17,28 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51"
|
||||||
|
dependencies = [
|
||||||
|
"async-stream-impl",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream-impl"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -48,8 +70,13 @@ checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
|||||||
name = "bili-sync"
|
name = "bili-sync"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-stream",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"strum",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -181,6 +208,17 @@ version = "0.3.30"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
|
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.30"
|
version = "0.3.30"
|
||||||
@@ -200,9 +238,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
|
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -236,6 +276,12 @@ version = "0.14.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
|
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -649,6 +695,12 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -770,6 +822,28 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.52"
|
version = "2.0.52"
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ version = "2.0.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
strum = { version = "0.26", features = ["derive"] }
|
||||||
|
async-stream = "0.3.5"
|
||||||
|
futures-core = "0.3"
|
||||||
|
futures-util = "0.3.30"
|
||||||
|
|||||||
147
src/bilibili.rs
147
src/bilibili.rs
@@ -1,147 +0,0 @@
|
|||||||
// 暂时保留,避免黄色波浪线
|
|
||||||
#![allow(dead_code, unused_imports)]
|
|
||||||
use reqwest::Method;
|
|
||||||
use serde_json;
|
|
||||||
use std::error;
|
|
||||||
use std::ops::Index;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
static MASK_CODE: u64 = 2251799813685247;
|
|
||||||
static XOR_CODE: u64 = 23442827791579;
|
|
||||||
static BASE: u64 = 58;
|
|
||||||
static DATA: &[char] = &[
|
|
||||||
'F', 'c', 'w', 'A', 'P', 'N', 'K', 'T', 'M', 'u', 'g', '3', 'G', 'V', '5', 'L', 'j', '7', 'E',
|
|
||||||
'J', 'n', 'H', 'p', 'W', 's', 'x', '4', 't', 'b', '8', 'h', 'a', 'Y', 'e', 'v', 'i', 'q', 'B',
|
|
||||||
'z', '6', 'r', 'k', 'C', 'y', '1', '2', 'm', 'U', 'S', 'D', 'Q', 'X', '9', 'R', 'd', 'o', 'Z',
|
|
||||||
'f',
|
|
||||||
];
|
|
||||||
|
|
||||||
pub struct Credential {
|
|
||||||
sessdata: String,
|
|
||||||
bili_jct: String,
|
|
||||||
buvid3: String,
|
|
||||||
dedeuserid: String,
|
|
||||||
ac_time_value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Credential {
|
|
||||||
pub fn new(
|
|
||||||
sessdata: String,
|
|
||||||
bili_jct: String,
|
|
||||||
buvid3: String,
|
|
||||||
dedeuserid: String,
|
|
||||||
ac_time_value: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
sessdata,
|
|
||||||
bili_jct,
|
|
||||||
buvid3,
|
|
||||||
dedeuserid,
|
|
||||||
ac_time_value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct BiliClient {
|
|
||||||
credential: Option<Credential>,
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BiliClient {
|
|
||||||
pub fn anonymous() -> Self {
|
|
||||||
let credential = None;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
Self { credential, client }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn authenticated(credential: Credential) -> Self {
|
|
||||||
let credential = Some(credential);
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
Self { credential, client }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
||||||
let req =req.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.54")
|
|
||||||
.header("Referer", "https://www.bilibili.com");
|
|
||||||
if let Some(credential) = &self.credential {
|
|
||||||
return req.header("cookie", format!("SESSDATA={}", credential.sessdata))
|
|
||||||
.header("cookie", format!("bili_jct={}", credential.bili_jct))
|
|
||||||
.header("cookie", format!("buvid3={}", credential.buvid3))
|
|
||||||
.header(
|
|
||||||
"cookie",
|
|
||||||
format!("DedeUserID={}", credential.dedeuserid),
|
|
||||||
)
|
|
||||||
.header(
|
|
||||||
"cookie",
|
|
||||||
format!("ac_time_value={}", credential.ac_time_value),
|
|
||||||
).header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.54")
|
|
||||||
.header("Referer", "https://www.bilibili.com");
|
|
||||||
}
|
|
||||||
req
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn request(&self, method: Method, url: &str) -> reqwest::RequestBuilder {
|
|
||||||
self.set_header(self.client.request(method, url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Video {
|
|
||||||
client: Rc<BiliClient>,
|
|
||||||
pub aid: u64,
|
|
||||||
pub bvid: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Video {
|
|
||||||
pub fn new(client: Rc<BiliClient>, bvid: String) -> Self {
|
|
||||||
let aid = bvid_to_aid(&bvid);
|
|
||||||
Self { client, aid, bvid }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_info(&self) -> Result<serde_json::Value, Box<dyn error::Error>> {
|
|
||||||
let res = self
|
|
||||||
.client
|
|
||||||
.request(
|
|
||||||
Method::GET,
|
|
||||||
&"https://api.bilibili.com/x/web-interface/view",
|
|
||||||
)
|
|
||||||
.query(&[("aid", self.aid.to_string()), ("bvid", self.bvid.clone())])
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.text()
|
|
||||||
.await?;
|
|
||||||
let json: serde_json::Value = serde_json::from_str(&res)?;
|
|
||||||
Ok(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bvid_to_aid(bvid: &str) -> u64 {
|
|
||||||
let mut bvid = bvid.chars().collect::<Vec<_>>();
|
|
||||||
(bvid[3], bvid[9]) = (bvid[9], bvid[3]);
|
|
||||||
(bvid[4], bvid[7]) = (bvid[7], bvid[4]);
|
|
||||||
let mut tmp = 0u64;
|
|
||||||
for i in 3..bvid.len() {
|
|
||||||
let idx = DATA.iter().position(|&x| x == bvid[i]).unwrap();
|
|
||||||
tmp = tmp * BASE + idx as u64;
|
|
||||||
}
|
|
||||||
return (tmp & MASK_CODE) ^ XOR_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_bvid_to_aid() {
|
|
||||||
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
|
|
||||||
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ignore = "only for manual test, need to connect to the internet"]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_video_info() {
|
|
||||||
let client = Rc::new(BiliClient::anonymous());
|
|
||||||
let video = Video::new(client, "BV1sH4y1s7fe".to_string());
|
|
||||||
let info = video.get_info().await.unwrap();
|
|
||||||
assert_eq!(info["code"], 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
308
src/bilibili/analyzer.rs
Normal file
308
src/bilibili/analyzer.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::bilibili::Result;
|
||||||
|
|
||||||
|
pub struct PageAnalyzer {
|
||||||
|
info: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, strum::FromRepr, PartialEq, PartialOrd)]
|
||||||
|
pub enum VideoQuality {
|
||||||
|
Quality360p = 16,
|
||||||
|
Quality480p = 32,
|
||||||
|
Quality720p = 64,
|
||||||
|
Quality1080p = 80,
|
||||||
|
Quality1080pPLUS = 112,
|
||||||
|
Quality1080p60 = 116,
|
||||||
|
Quality4k = 120,
|
||||||
|
QualityHdr = 125,
|
||||||
|
QualityDolby = 126,
|
||||||
|
Quality8k = 127,
|
||||||
|
}
|
||||||
|
#[derive(Debug, strum::FromRepr, PartialEq, PartialOrd)]
|
||||||
|
pub enum AudioQuality {
|
||||||
|
Quality64k = 30216,
|
||||||
|
Quality132k = 30232,
|
||||||
|
QualityDolby = 30250,
|
||||||
|
QualityHiRES = 30251,
|
||||||
|
Quality192k = 30280,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, strum::EnumString, strum::Display, PartialEq, PartialOrd)]
|
||||||
|
pub enum VideoCodecs {
|
||||||
|
#[strum(serialize = "hev")]
|
||||||
|
HEV,
|
||||||
|
#[strum(serialize = "avc")]
|
||||||
|
AVC,
|
||||||
|
#[strum(serialize = "av01")]
|
||||||
|
AV1,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, PartialOrd)]
|
||||||
|
pub enum Stream {
|
||||||
|
FlvStream(String),
|
||||||
|
Html5Mp4Stream(String),
|
||||||
|
EpositeTryMp4Stream(String),
|
||||||
|
DashVideoStream {
|
||||||
|
url: String,
|
||||||
|
quality: VideoQuality,
|
||||||
|
codecs: VideoCodecs,
|
||||||
|
},
|
||||||
|
DashAudioStream {
|
||||||
|
url: String,
|
||||||
|
quality: AudioQuality,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream {
|
||||||
|
pub fn url(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::FlvStream(url) => url,
|
||||||
|
Self::Html5Mp4Stream(url) => url,
|
||||||
|
Self::EpositeTryMp4Stream(url) => url,
|
||||||
|
Self::DashVideoStream { url, .. } => url,
|
||||||
|
Self::DashAudioStream { url, .. } => url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BestStream {
|
||||||
|
VideoAudioStream { video: Stream, audio: Stream },
|
||||||
|
MixedStream(Stream),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageAnalyzer {
|
||||||
|
pub fn new(info: serde_json::Value) -> Self {
|
||||||
|
Self { info }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_flv_stream(&self) -> bool {
|
||||||
|
self.info.get("durl").is_some()
|
||||||
|
&& self.info["format"].is_string()
|
||||||
|
&& self.info["format"].as_str().unwrap().starts_with("flv")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_html5_mp4_stream(&self) -> bool {
|
||||||
|
self.info.get("durl").is_some()
|
||||||
|
&& self.info["format"].is_string()
|
||||||
|
&& self.info["format"].as_str().unwrap().starts_with("mp4")
|
||||||
|
&& self.info["is_html5"].is_boolean()
|
||||||
|
&& self.info["is_html5"].as_bool().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_episode_try_mp4_stream(&self) -> bool {
|
||||||
|
self.info.get("durl").is_some()
|
||||||
|
&& self.info["format"].is_string()
|
||||||
|
&& self.info["format"].as_str().unwrap().starts_with("mp4")
|
||||||
|
&& !(self.info["is_html5"].is_boolean() && self.info["is_html5"].as_bool().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn streams(
|
||||||
|
&mut self,
|
||||||
|
video_max_quality: VideoQuality,
|
||||||
|
video_min_quality: VideoQuality,
|
||||||
|
audio_max_quality: AudioQuality,
|
||||||
|
audio_min_quality: AudioQuality,
|
||||||
|
codecs: Rc<Vec<VideoCodecs>>,
|
||||||
|
no_dolby_video: bool,
|
||||||
|
no_dolby_audio: bool,
|
||||||
|
no_hdr: bool,
|
||||||
|
no_hires: bool,
|
||||||
|
) -> Result<Vec<Stream>> {
|
||||||
|
if self.is_flv_stream() {
|
||||||
|
return Ok(vec![Stream::FlvStream(
|
||||||
|
self.info["durl"][0]["url"].as_str().unwrap().to_string(),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
if self.is_html5_mp4_stream() {
|
||||||
|
return Ok(vec![Stream::Html5Mp4Stream(
|
||||||
|
self.info["durl"][0]["url"].as_str().unwrap().to_string(),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
if self.is_episode_try_mp4_stream() {
|
||||||
|
return Ok(vec![Stream::EpositeTryMp4Stream(
|
||||||
|
self.info["durl"][0]["url"].as_str().unwrap().to_string(),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
let mut streams: Vec<Stream> = Vec::new();
|
||||||
|
let videos_data = self.info["dash"]["video"].take();
|
||||||
|
let audios_data = self.info["dash"]["audio"].take();
|
||||||
|
let flac_data = self.info["dash"]["flac"].take();
|
||||||
|
let dolby_data = self.info["dash"]["dolby"].take();
|
||||||
|
for video_data in videos_data.as_array().unwrap().iter() {
|
||||||
|
let video_stream_url = video_data["baseUrl"].as_str().unwrap().to_string();
|
||||||
|
let video_stream_quality =
|
||||||
|
VideoQuality::from_repr(video_data["id"].as_u64().unwrap() as usize)
|
||||||
|
.ok_or_else(|| "invalid video stream quality")?;
|
||||||
|
if (video_stream_quality == VideoQuality::QualityHdr && no_hdr) // NO HDR
|
||||||
|
|| (video_stream_quality == VideoQuality::QualityDolby && no_dolby_video) // NO DOLBY
|
||||||
|
|| (video_stream_quality != VideoQuality::QualityDolby
|
||||||
|
&& video_stream_quality != VideoQuality::QualityHdr
|
||||||
|
&& (video_stream_quality < video_min_quality
|
||||||
|
|| video_stream_quality > video_max_quality))
|
||||||
|
// NOT IN RANGE
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let video_codecs = video_data["codecs"].as_str().unwrap();
|
||||||
|
|
||||||
|
let video_codecs = vec![VideoCodecs::HEV, VideoCodecs::AVC, VideoCodecs::AV1]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| video_codecs.contains(c.to_string().as_str()))
|
||||||
|
.next();
|
||||||
|
|
||||||
|
let Some(video_codecs) = video_codecs else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !codecs.contains(&video_codecs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
streams.push(Stream::DashVideoStream {
|
||||||
|
url: video_stream_url,
|
||||||
|
quality: video_stream_quality,
|
||||||
|
codecs: video_codecs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if audios_data.is_array() {
|
||||||
|
for audio_data in audios_data.as_array().unwrap().iter() {
|
||||||
|
let audio_stream_url = audio_data["baseUrl"].as_str().unwrap().to_string();
|
||||||
|
let audio_stream_quality =
|
||||||
|
AudioQuality::from_repr(audio_data["id"].as_u64().unwrap() as usize);
|
||||||
|
let Some(audio_stream_quality) = audio_stream_quality else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if audio_stream_quality > audio_max_quality
|
||||||
|
|| audio_stream_quality < audio_min_quality
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
streams.push(Stream::DashAudioStream {
|
||||||
|
url: audio_stream_url,
|
||||||
|
quality: audio_stream_quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !(no_hires || flac_data["audio"].is_null()) {
|
||||||
|
let flac_stream_url = flac_data["audio"]["baseUrl"].as_str().unwrap().to_string();
|
||||||
|
let flac_stream_quality =
|
||||||
|
AudioQuality::from_repr(flac_data["audio"]["id"].as_u64().unwrap() as usize)
|
||||||
|
.unwrap();
|
||||||
|
streams.push(Stream::DashAudioStream {
|
||||||
|
url: flac_stream_url,
|
||||||
|
quality: flac_stream_quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !(no_dolby_audio || dolby_data["audio"].is_null()) {
|
||||||
|
let dolby_stream_data = dolby_data["audio"].as_array().and_then(|v| v.get(0));
|
||||||
|
if dolby_stream_data.is_some() {
|
||||||
|
let dolby_stream_data = dolby_stream_data.unwrap();
|
||||||
|
let dolby_stream_url = dolby_stream_data["baseUrl"].as_str().unwrap().to_string();
|
||||||
|
let dolby_stream_quality =
|
||||||
|
AudioQuality::from_repr(dolby_stream_data["id"].as_u64().unwrap() as usize)
|
||||||
|
.unwrap();
|
||||||
|
streams.push(Stream::DashAudioStream {
|
||||||
|
url: dolby_stream_url,
|
||||||
|
quality: dolby_stream_quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(streams)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn best_stream(
|
||||||
|
&mut self,
|
||||||
|
video_max_quality: VideoQuality,
|
||||||
|
video_min_quality: VideoQuality,
|
||||||
|
audio_max_quality: AudioQuality,
|
||||||
|
audio_min_quality: AudioQuality,
|
||||||
|
codecs: Vec<VideoCodecs>,
|
||||||
|
no_dolby_video: bool,
|
||||||
|
no_dolby_audio: bool,
|
||||||
|
no_hdr: bool,
|
||||||
|
no_hires: bool,
|
||||||
|
) -> Result<BestStream> {
|
||||||
|
let codecs = Rc::new(codecs);
|
||||||
|
let streams = dbg!(self.streams(
|
||||||
|
video_max_quality,
|
||||||
|
video_min_quality,
|
||||||
|
audio_max_quality,
|
||||||
|
audio_min_quality,
|
||||||
|
codecs.clone(),
|
||||||
|
no_dolby_video,
|
||||||
|
no_dolby_audio,
|
||||||
|
no_hdr,
|
||||||
|
no_hires
|
||||||
|
))?;
|
||||||
|
if self.is_flv_stream() || self.is_html5_mp4_stream() || self.is_episode_try_mp4_stream() {
|
||||||
|
return Ok(BestStream::MixedStream(
|
||||||
|
streams.into_iter().next().ok_or("no stream found")?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (mut video_streams, mut audio_streams): (Vec<_>, Vec<_>) = streams
|
||||||
|
.into_iter()
|
||||||
|
.partition(|s| matches!(s, Stream::DashVideoStream { .. }));
|
||||||
|
video_streams.sort_by(|a, b| match (a, b) {
|
||||||
|
(
|
||||||
|
Stream::DashVideoStream {
|
||||||
|
quality: a_quality,
|
||||||
|
codecs: a_codecs,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
Stream::DashVideoStream {
|
||||||
|
quality: b_quality,
|
||||||
|
codecs: b_codecs,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if a_quality == &VideoQuality::QualityDolby && !no_dolby_video {
|
||||||
|
return std::cmp::Ordering::Greater;
|
||||||
|
}
|
||||||
|
if b_quality == &VideoQuality::QualityDolby && !no_dolby_video {
|
||||||
|
return std::cmp::Ordering::Less;
|
||||||
|
}
|
||||||
|
if a_quality == &VideoQuality::QualityHdr && !no_hdr {
|
||||||
|
return std::cmp::Ordering::Greater;
|
||||||
|
}
|
||||||
|
if b_quality == &VideoQuality::QualityHdr && !no_hdr {
|
||||||
|
return std::cmp::Ordering::Less;
|
||||||
|
}
|
||||||
|
if a_quality != b_quality {
|
||||||
|
return a_quality.partial_cmp(b_quality).unwrap();
|
||||||
|
}
|
||||||
|
codecs
|
||||||
|
.iter()
|
||||||
|
.position(|c| c == b_codecs)
|
||||||
|
.cmp(&codecs.iter().position(|c| c == a_codecs))
|
||||||
|
}
|
||||||
|
_ => std::cmp::Ordering::Equal,
|
||||||
|
});
|
||||||
|
audio_streams.sort_by(|a, b| match (a, b) {
|
||||||
|
(
|
||||||
|
Stream::DashAudioStream {
|
||||||
|
quality: a_quality, ..
|
||||||
|
},
|
||||||
|
Stream::DashAudioStream {
|
||||||
|
quality: b_quality, ..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if a_quality == &AudioQuality::QualityDolby && !no_dolby_audio {
|
||||||
|
return std::cmp::Ordering::Greater;
|
||||||
|
}
|
||||||
|
if b_quality == &AudioQuality::QualityDolby && !no_dolby_audio {
|
||||||
|
return std::cmp::Ordering::Less;
|
||||||
|
}
|
||||||
|
a_quality.partial_cmp(b_quality).unwrap()
|
||||||
|
}
|
||||||
|
_ => std::cmp::Ordering::Equal,
|
||||||
|
});
|
||||||
|
if video_streams.is_empty() || audio_streams.is_empty() {
|
||||||
|
return Err("no stream found".into());
|
||||||
|
}
|
||||||
|
Ok(BestStream::VideoAudioStream {
|
||||||
|
video: video_streams.remove(video_streams.len() - 1),
|
||||||
|
audio: audio_streams.remove(audio_streams.len() - 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/bilibili/client.rs
Normal file
70
src/bilibili/client.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use reqwest::Method;
|
||||||
|
|
||||||
|
pub struct Credential {
|
||||||
|
sessdata: String,
|
||||||
|
bili_jct: String,
|
||||||
|
buvid3: String,
|
||||||
|
dedeuserid: String,
|
||||||
|
ac_time_value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Credential {
|
||||||
|
pub fn new(
|
||||||
|
sessdata: String,
|
||||||
|
bili_jct: String,
|
||||||
|
buvid3: String,
|
||||||
|
dedeuserid: String,
|
||||||
|
ac_time_value: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
sessdata,
|
||||||
|
bili_jct,
|
||||||
|
buvid3,
|
||||||
|
dedeuserid,
|
||||||
|
ac_time_value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BiliClient {
|
||||||
|
credential: Option<Credential>,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BiliClient {
|
||||||
|
pub fn anonymous() -> Self {
|
||||||
|
let credential = None;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
Self { credential, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticated(credential: Credential) -> Self {
|
||||||
|
let credential = Some(credential);
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
Self { credential, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
|
let req =req.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.54")
|
||||||
|
.header("Referer", "https://www.bilibili.com");
|
||||||
|
if let Some(credential) = &self.credential {
|
||||||
|
return req.header("cookie", format!("SESSDATA={}", credential.sessdata))
|
||||||
|
.header("cookie", format!("bili_jct={}", credential.bili_jct))
|
||||||
|
.header("cookie", format!("buvid3={}", credential.buvid3))
|
||||||
|
.header(
|
||||||
|
"cookie",
|
||||||
|
format!("DedeUserID={}", credential.dedeuserid),
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"cookie",
|
||||||
|
format!("ac_time_value={}", credential.ac_time_value),
|
||||||
|
).header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.54")
|
||||||
|
.header("Referer", "https://www.bilibili.com");
|
||||||
|
}
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request(&self, method: Method, url: &str) -> reqwest::RequestBuilder {
|
||||||
|
self.set_header(self.client.request(method, url))
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/bilibili/favorite_list.rs
Normal file
101
src/bilibili/favorite_list.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use async_stream::stream;
|
||||||
|
use futures_core::stream::Stream;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::bilibili::{BiliClient, Result};
|
||||||
|
pub struct FavoriteList {
|
||||||
|
client: Rc<BiliClient>,
|
||||||
|
fid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct FavoriteListInfo {
|
||||||
|
id: u64,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct VideoInfo {
|
||||||
|
pub title: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub vtype: u64,
|
||||||
|
pub bvid: String,
|
||||||
|
pub intro: String,
|
||||||
|
pub cover: String,
|
||||||
|
pub upper: Upper,
|
||||||
|
pub ctime: u64,
|
||||||
|
pub fav_time: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Upper {
|
||||||
|
pub mid: u64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
impl FavoriteList {
|
||||||
|
pub fn new(client: Rc<BiliClient>, fid: String) -> Self {
|
||||||
|
Self { client, fid }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_info(&self) -> Result<FavoriteListInfo> {
|
||||||
|
let mut res = self
|
||||||
|
.client
|
||||||
|
.request(
|
||||||
|
reqwest::Method::GET,
|
||||||
|
&"https://api.bilibili.com/x/v3/fav/folder/info",
|
||||||
|
)
|
||||||
|
.query(&[("media_id", &self.fid)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::from_value(res["data"].take())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_videos(&self, page: u32) -> Result<Value> {
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.request(
|
||||||
|
reqwest::Method::GET,
|
||||||
|
&"https://api.bilibili.com/x/v3/fav/resource/list",
|
||||||
|
)
|
||||||
|
.query(&[
|
||||||
|
("media_id", &self.fid),
|
||||||
|
("pn", &page.to_string()),
|
||||||
|
("ps", &"20".to_string()),
|
||||||
|
("order", &"mtime".to_string()),
|
||||||
|
("type", &"0".to_string()),
|
||||||
|
("tid", &"0".to_string()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await?;
|
||||||
|
if res["code"] != 0 {
|
||||||
|
return Err(format!("get favorite videos failed: {}", res["message"]).into());
|
||||||
|
}
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_video_stream(self) -> impl Stream<Item = VideoInfo> {
|
||||||
|
stream! {
|
||||||
|
let mut page = 1;
|
||||||
|
loop {
|
||||||
|
let Ok(mut videos) = self.get_videos(page).await else{
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let videos_info: Vec<VideoInfo> = serde_json::from_value(videos["data"]["medias"].take()).unwrap();
|
||||||
|
for video_info in videos_info.into_iter(){
|
||||||
|
yield video_info;
|
||||||
|
}
|
||||||
|
if videos["data"]["has_more"].is_boolean() && videos["data"]["has_more"].as_bool().unwrap(){
|
||||||
|
page += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/bilibili/mod.rs
Normal file
13
src/bilibili/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
mod analyzer;
|
||||||
|
mod client;
|
||||||
|
mod favorite_list;
|
||||||
|
mod video;
|
||||||
|
|
||||||
|
use std::error;
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Box<dyn error::Error>>;
|
||||||
|
|
||||||
|
pub use analyzer::{AudioQuality, PageAnalyzer, VideoCodecs, VideoQuality};
|
||||||
|
pub use client::{BiliClient, Credential};
|
||||||
|
pub use favorite_list::FavoriteList;
|
||||||
|
pub use video::Video;
|
||||||
120
src/bilibili/video.rs
Normal file
120
src/bilibili/video.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
|
||||||
|
use crate::bilibili::analyzer::PageAnalyzer;
|
||||||
|
use crate::bilibili::client::BiliClient;
|
||||||
|
use crate::bilibili::Result;
|
||||||
|
|
||||||
|
static MASK_CODE: u64 = 2251799813685247;
|
||||||
|
static XOR_CODE: u64 = 23442827791579;
|
||||||
|
static BASE: u64 = 58;
|
||||||
|
static DATA: &[char] = &[
|
||||||
|
'F', 'c', 'w', 'A', 'P', 'N', 'K', 'T', 'M', 'u', 'g', '3', 'G', 'V', '5', 'L', 'j', '7', 'E',
|
||||||
|
'J', 'n', 'H', 'p', 'W', 's', 'x', '4', 't', 'b', '8', 'h', 'a', 'Y', 'e', 'v', 'i', 'q', 'B',
|
||||||
|
'z', '6', 'r', 'k', 'C', 'y', '1', '2', 'm', 'U', 'S', 'D', 'Q', 'X', '9', 'R', 'd', 'o', 'Z',
|
||||||
|
'f',
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct Video {
|
||||||
|
client: Rc<BiliClient>,
|
||||||
|
pub aid: String,
|
||||||
|
pub bvid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Tag {
|
||||||
|
pub tag_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Page {
|
||||||
|
cid: u64,
|
||||||
|
page: u32,
|
||||||
|
#[serde(rename = "part")]
|
||||||
|
name: String,
|
||||||
|
#[serde(default = "String::new")]
|
||||||
|
first_frame: String, // may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Video {
|
||||||
|
pub fn new(client: Rc<BiliClient>, bvid: String) -> Self {
|
||||||
|
let aid = bvid_to_aid(&bvid).to_string();
|
||||||
|
Self { client, aid, bvid }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_pages(&self) -> Result<Vec<Page>> {
|
||||||
|
let mut res = self
|
||||||
|
.client
|
||||||
|
.request(Method::GET, &"https://api.bilibili.com/x/player/pagelist")
|
||||||
|
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::from_value(res["data"].take())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tags(&self) -> Result<Vec<Tag>> {
|
||||||
|
let mut res = self
|
||||||
|
.client
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&"https://api.bilibili.com/x/web-interface/view/detail/tag",
|
||||||
|
)
|
||||||
|
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::from_value(res["data"].take())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_page_analyzer(&self, page: &Page) -> Result<PageAnalyzer> {
|
||||||
|
let mut res = self
|
||||||
|
.client
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&"https://api.bilibili.com/x/player/wbi/playurl",
|
||||||
|
)
|
||||||
|
.query(&[
|
||||||
|
("avid", self.aid.as_str()),
|
||||||
|
("cid", page.cid.to_string().as_str()),
|
||||||
|
("qn", "127"),
|
||||||
|
("otype", "json"),
|
||||||
|
("fnval", "4048"),
|
||||||
|
("fourk", "1"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await?;
|
||||||
|
if res["code"] != 0 {
|
||||||
|
return Err(format!("get page analyzer failed: {}", res["message"]).into());
|
||||||
|
}
|
||||||
|
Ok(PageAnalyzer::new(res["data"].take()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bvid_to_aid(bvid: &str) -> u64 {
|
||||||
|
let mut bvid = bvid.chars().collect::<Vec<_>>();
|
||||||
|
(bvid[3], bvid[9]) = (bvid[9], bvid[3]);
|
||||||
|
(bvid[4], bvid[7]) = (bvid[7], bvid[4]);
|
||||||
|
let mut tmp = 0u64;
|
||||||
|
for i in 3..bvid.len() {
|
||||||
|
let idx = DATA.iter().position(|&x| x == bvid[i]).unwrap();
|
||||||
|
tmp = tmp * BASE + idx as u64;
|
||||||
|
}
|
||||||
|
return (tmp & MASK_CODE) ^ XOR_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bvid_to_aid() {
|
||||||
|
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
|
||||||
|
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
mod bilibili;
|
pub mod bilibili;
|
||||||
|
|||||||
46
src/main.rs
46
src/main.rs
@@ -1,3 +1,45 @@
|
|||||||
fn main() {
|
use std::rc::Rc;
|
||||||
println!("Hello, world!");
|
|
||||||
|
use bili_sync::bilibili::{
|
||||||
|
AudioQuality, BiliClient, FavoriteList, Video, VideoCodecs, VideoQuality,
|
||||||
|
};
|
||||||
|
use futures_util::{pin_mut, StreamExt};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let bili_client = Rc::new(BiliClient::anonymous());
|
||||||
|
let favorite_list = FavoriteList::new(bili_client.clone(), "52642258".to_string());
|
||||||
|
dbg!(favorite_list.get_info().await.unwrap());
|
||||||
|
let video_stream = favorite_list.into_video_stream();
|
||||||
|
// from doc: https://docs.rs/async-stream/latest/async_stream/
|
||||||
|
pin_mut!(video_stream);
|
||||||
|
let mut count = 3;
|
||||||
|
let mut third_video = None;
|
||||||
|
while let Some(mut video) = video_stream.next().await {
|
||||||
|
count -= 1;
|
||||||
|
video = dbg!(video);
|
||||||
|
if count <= 0 {
|
||||||
|
third_video = Some(video);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let third_video = Video::new(bili_client.clone(), third_video.unwrap().bvid);
|
||||||
|
dbg!(third_video.get_tags().await.unwrap());
|
||||||
|
let pages = dbg!(third_video.get_pages().await.unwrap());
|
||||||
|
dbg!(third_video
|
||||||
|
.get_page_analyzer(&pages[0])
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.best_stream(
|
||||||
|
VideoQuality::QualityDolby,
|
||||||
|
VideoQuality::Quality360p,
|
||||||
|
AudioQuality::QualityDolby,
|
||||||
|
AudioQuality::Quality64k,
|
||||||
|
vec![VideoCodecs::HEV, VideoCodecs::AVC],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user