From 210a72c9cfcbd59d6e92ae067f66cb0d5d30cd00 Mon Sep 17 00:00:00 2001 From: amtoaer Date: Mon, 18 Mar 2024 00:14:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=99=A4=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=87=AD=E6=8D=AE=E5=A4=96=E7=9A=84=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=9C=A8=20main=20=E4=B8=AD=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .rustfmt.toml | 2 + Cargo.lock | 74 ++++++++ Cargo.toml | 5 + src/bilibili.rs | 147 ---------------- src/bilibili/analyzer.rs | 308 ++++++++++++++++++++++++++++++++++ src/bilibili/client.rs | 70 ++++++++ src/bilibili/favorite_list.rs | 101 +++++++++++ src/bilibili/mod.rs | 13 ++ src/bilibili/video.rs | 120 +++++++++++++ src/lib.rs | 2 +- src/main.rs | 46 ++++- 12 files changed, 739 insertions(+), 150 deletions(-) create mode 100644 .rustfmt.toml delete mode 100644 src/bilibili.rs create mode 100644 src/bilibili/analyzer.rs create mode 100644 src/bilibili/client.rs create mode 100644 src/bilibili/favorite_list.rs create mode 100644 src/bilibili/mod.rs create mode 100644 src/bilibili/video.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..a5054ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +auth_data diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..64d94de --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,2 @@ +group_imports = "StdExternalCrate" +imports_granularity = "Module" diff --git a/Cargo.lock b/Cargo.lock index 18d2bbe..88173b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "autocfg" version = "1.1.0" @@ -48,8 +70,13 @@ checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" name = "bili-sync" version = "2.0.0" dependencies = [ + "async-stream", + "futures-core", + "futures-util", "reqwest", + "serde", "serde_json", + "strum", "tokio", ] @@ -181,6 +208,17 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures-sink" version = "0.3.30" @@ -200,9 +238,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -236,6 +276,12 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -649,6 +695,12 @@ dependencies = [ "base64", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "ryu" version = "1.0.17" @@ -770,6 +822,28 @@ dependencies = [ "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]] name = "syn" version = "2.0.52" diff --git a/Cargo.toml b/Cargo.toml index 3d7a307..2e0d581 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,11 @@ version = "2.0.0" edition = "2021" [dependencies] +serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0" reqwest = { version = "0.11", features = ["json"] } 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" diff --git a/src/bilibili.rs b/src/bilibili.rs deleted file mode 100644 index 3b993b2..0000000 --- a/src/bilibili.rs +++ /dev/null @@ -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, - 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, - pub aid: u64, - pub bvid: String, -} - -impl Video { - pub fn new(client: Rc, bvid: String) -> Self { - let aid = bvid_to_aid(&bvid); - Self { client, aid, bvid } - } - - pub async fn get_info(&self) -> Result> { - 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::>(); - (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); - } -} diff --git a/src/bilibili/analyzer.rs b/src/bilibili/analyzer.rs new file mode 100644 index 0000000..3e43e88 --- /dev/null +++ b/src/bilibili/analyzer.rs @@ -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>, + no_dolby_video: bool, + no_dolby_audio: bool, + no_hdr: bool, + no_hires: bool, + ) -> Result> { + 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 = 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, + no_dolby_video: bool, + no_dolby_audio: bool, + no_hdr: bool, + no_hires: bool, + ) -> Result { + 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), + }) + } +} diff --git a/src/bilibili/client.rs b/src/bilibili/client.rs new file mode 100644 index 0000000..f283802 --- /dev/null +++ b/src/bilibili/client.rs @@ -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, + 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)) + } +} diff --git a/src/bilibili/favorite_list.rs b/src/bilibili/favorite_list.rs new file mode 100644 index 0000000..a3e83fd --- /dev/null +++ b/src/bilibili/favorite_list.rs @@ -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, + 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, fid: String) -> Self { + Self { client, fid } + } + + pub async fn get_info(&self) -> Result { + 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::() + .await?; + Ok(serde_json::from_value(res["data"].take())?) + } + + async fn get_videos(&self, page: u32) -> Result { + 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::() + .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 { + stream! { + let mut page = 1; + loop { + let Ok(mut videos) = self.get_videos(page).await else{ + break; + }; + let videos_info: Vec = 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; + } + } + } +} diff --git a/src/bilibili/mod.rs b/src/bilibili/mod.rs new file mode 100644 index 0000000..8b5af79 --- /dev/null +++ b/src/bilibili/mod.rs @@ -0,0 +1,13 @@ +mod analyzer; +mod client; +mod favorite_list; +mod video; + +use std::error; + +type Result = std::result::Result>; + +pub use analyzer::{AudioQuality, PageAnalyzer, VideoCodecs, VideoQuality}; +pub use client::{BiliClient, Credential}; +pub use favorite_list::FavoriteList; +pub use video::Video; diff --git a/src/bilibili/video.rs b/src/bilibili/video.rs new file mode 100644 index 0000000..5c37ce4 --- /dev/null +++ b/src/bilibili/video.rs @@ -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, + 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, bvid: String) -> Self { + let aid = bvid_to_aid(&bvid).to_string(); + Self { client, aid, bvid } + } + + pub async fn get_pages(&self) -> Result> { + 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::() + .await?; + Ok(serde_json::from_value(res["data"].take())?) + } + + pub async fn get_tags(&self) -> Result> { + 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::() + .await?; + Ok(serde_json::from_value(res["data"].take())?) + } + + pub async fn get_page_analyzer(&self, page: &Page) -> Result { + 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::() + .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::>(); + (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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6015199..58c1058 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1 @@ -mod bilibili; +pub mod bilibili; diff --git a/src/main.rs b/src/main.rs index e7a11a9..21a52ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,45 @@ -fn main() { - println!("Hello, world!"); +use std::rc::Rc; + +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(); }