add playlist response mapping
This commit is contained in:
parent
77675209d5
commit
a6041a013b
12 changed files with 42363 additions and 58 deletions
|
|
@ -25,8 +25,7 @@ Playlist:
|
||||||
{
|
{
|
||||||
context: ...,
|
context: ...,
|
||||||
|
|
||||||
# Playlist-ID
|
browseId: "VL" + "RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY" (Playlist-ID),
|
||||||
browseId: "UCHnyfMqiRRG1u-2MsSQLbXA",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,5 @@ Censored: 6SJNVb0GnPI
|
||||||
Geoblocked: sJL6WA-aGkQ (Japan only)
|
Geoblocked: sJL6WA-aGkQ (Japan only)
|
||||||
Private: s7_qI6_mIXc
|
Private: s7_qI6_mIXc
|
||||||
DRM: 1bfOsni7EgI
|
DRM: 1bfOsni7EgI
|
||||||
|
|
||||||
|
Album with unknown artists: https://music.youtube.com/playlist?list=OLAK5uy_mEX9ljZeeEWgTM1xLL1isyiGaWXoPyoOk
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ impl Cache {
|
||||||
{
|
{
|
||||||
let mut cache = self.data.lock().await;
|
let mut cache = self.data.lock().await;
|
||||||
if cache.deobf.is_none()
|
if cache.deobf.is_none()
|
||||||
|| cache.deobf.as_ref().unwrap().last_update < Utc::now() - Duration::hours(1)
|
|| cache.deobf.as_ref().unwrap().last_update < Utc::now() - Duration::hours(24)
|
||||||
{
|
{
|
||||||
let deobf_data = updater.await?;
|
let deobf_data = updater.await?;
|
||||||
cache.deobf = Some(CacheEntry::from(deobf_data.clone()));
|
cache.deobf = Some(CacheEntry::from(deobf_data.clone()));
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod player;
|
pub mod player;
|
||||||
|
pub mod playlist;
|
||||||
mod response;
|
mod response;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -122,7 +123,7 @@ const TVHTML5_CLIENT_VERSION: &str = "2.0";
|
||||||
const DESKTOP_MUSIC_API_KEY: &str = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30";
|
const DESKTOP_MUSIC_API_KEY: &str = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30";
|
||||||
const DESKTOP_MUSIC_CLIENT_VERSION: &str = "1.20220727.01.00";
|
const DESKTOP_MUSIC_CLIENT_VERSION: &str = "1.20220727.01.00";
|
||||||
|
|
||||||
const MOBILE_CLIENT_VERSION: &str = "17.10.35";
|
const MOBILE_CLIENT_VERSION: &str = "17.29.35";
|
||||||
const ANDROID_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
|
const ANDROID_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
|
||||||
const IOS_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc";
|
const IOS_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc";
|
||||||
const IOS_DEVICE_MODEL: &str = "iPhone14,5";
|
const IOS_DEVICE_MODEL: &str = "iPhone14,5";
|
||||||
|
|
@ -174,7 +175,7 @@ impl RustyTube {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_ytclient(&self, client_type: ClientType) -> Arc<dyn YTClient> {
|
fn get_ytclient(&self, client_type: ClientType) -> Arc<dyn YTClient> {
|
||||||
match client_type {
|
match client_type {
|
||||||
ClientType::Desktop => self.desktop_client.clone(),
|
ClientType::Desktop => self.desktop_client.clone(),
|
||||||
ClientType::DesktopMusic => self.desktop_music_client.clone(),
|
ClientType::DesktopMusic => self.desktop_music_client.clone(),
|
||||||
|
|
@ -205,8 +206,7 @@ pub struct DesktopClient {
|
||||||
locale: Arc<Locale>,
|
locale: Arc<Locale>,
|
||||||
http: Client,
|
http: Client,
|
||||||
cache: Cache,
|
cache: Cache,
|
||||||
consent_cookie_yes: String,
|
consent_cookie: String,
|
||||||
consent_cookie_no: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -246,7 +246,7 @@ impl YTClient for DesktopClient {
|
||||||
)
|
)
|
||||||
.header(header::ORIGIN, "https://www.youtube.com")
|
.header(header::ORIGIN, "https://www.youtube.com")
|
||||||
.header(header::REFERER, "https://www.youtube.com")
|
.header(header::REFERER, "https://www.youtube.com")
|
||||||
.header(header::COOKIE, self.consent_cookie_no.to_owned())
|
.header(header::COOKIE, self.consent_cookie.to_owned())
|
||||||
.header("X-YouTube-Client-Name", "1")
|
.header("X-YouTube-Client-Name", "1")
|
||||||
.header("X-YouTube-Client-Version", self.get_client_version().await)
|
.header("X-YouTube-Client-Version", self.get_client_version().await)
|
||||||
}
|
}
|
||||||
|
|
@ -275,18 +275,12 @@ impl DesktopClient {
|
||||||
locale,
|
locale,
|
||||||
http,
|
http,
|
||||||
cache,
|
cache,
|
||||||
consent_cookie_yes: format!(
|
consent_cookie: format!(
|
||||||
"{}={}{}",
|
"{}={}{}",
|
||||||
CONSENT_COOKIE,
|
CONSENT_COOKIE,
|
||||||
CONSENT_COOKIE_YES,
|
CONSENT_COOKIE_YES,
|
||||||
rng.gen_range(100..1000)
|
rng.gen_range(100..1000)
|
||||||
),
|
),
|
||||||
consent_cookie_no: format!(
|
|
||||||
"{}={}{}",
|
|
||||||
CONSENT_COOKIE,
|
|
||||||
CONSENT_COOKIE_NO,
|
|
||||||
rng.gen_range(100..1000)
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,7 +306,7 @@ impl DesktopClient {
|
||||||
|
|
||||||
async fn get_client_version(&self) -> String {
|
async fn get_client_version(&self) -> String {
|
||||||
let http = self.http.clone();
|
let http = self.http.clone();
|
||||||
let consent_cookie = self.consent_cookie_yes.clone();
|
let consent_cookie = self.consent_cookie.clone();
|
||||||
|
|
||||||
let client_data = self
|
let client_data = self
|
||||||
.cache
|
.cache
|
||||||
|
|
@ -549,8 +543,7 @@ pub struct DesktopMusicClient {
|
||||||
locale: Arc<Locale>,
|
locale: Arc<Locale>,
|
||||||
http: Client,
|
http: Client,
|
||||||
cache: Cache,
|
cache: Cache,
|
||||||
consent_cookie_yes: String,
|
consent_cookie: String,
|
||||||
consent_cookie_no: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -593,7 +586,7 @@ impl YTClient for DesktopMusicClient {
|
||||||
)
|
)
|
||||||
.header(header::ORIGIN, "https://music.youtube.com")
|
.header(header::ORIGIN, "https://music.youtube.com")
|
||||||
.header(header::REFERER, "https://music.youtube.com")
|
.header(header::REFERER, "https://music.youtube.com")
|
||||||
.header(header::COOKIE, self.consent_cookie_no.to_owned())
|
.header(header::COOKIE, self.consent_cookie.to_owned())
|
||||||
.header("X-YouTube-Client-Name", "67")
|
.header("X-YouTube-Client-Name", "67")
|
||||||
.header("X-YouTube-Client-Version", self.get_client_version().await)
|
.header("X-YouTube-Client-Version", self.get_client_version().await)
|
||||||
}
|
}
|
||||||
|
|
@ -622,18 +615,12 @@ impl DesktopMusicClient {
|
||||||
locale,
|
locale,
|
||||||
http,
|
http,
|
||||||
cache,
|
cache,
|
||||||
consent_cookie_yes: format!(
|
consent_cookie: format!(
|
||||||
"{}={}{}",
|
"{}={}{}",
|
||||||
CONSENT_COOKIE,
|
CONSENT_COOKIE,
|
||||||
CONSENT_COOKIE_YES,
|
CONSENT_COOKIE_YES,
|
||||||
rng.gen_range(100..1000)
|
rng.gen_range(100..1000)
|
||||||
),
|
),
|
||||||
consent_cookie_no: format!(
|
|
||||||
"{}={}{}",
|
|
||||||
CONSENT_COOKIE,
|
|
||||||
CONSENT_COOKIE_NO,
|
|
||||||
rng.gen_range(100..1000)
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -659,7 +646,7 @@ impl DesktopMusicClient {
|
||||||
|
|
||||||
async fn get_client_version(&self) -> String {
|
async fn get_client_version(&self) -> String {
|
||||||
let http = self.http.clone();
|
let http = self.http.clone();
|
||||||
let consent_cookie = self.consent_cookie_yes.clone();
|
let consent_cookie = self.consent_cookie.clone();
|
||||||
|
|
||||||
let client_data = self
|
let client_data = self
|
||||||
.cache
|
.cache
|
||||||
|
|
@ -696,7 +683,7 @@ mod tests {
|
||||||
let client = rt.desktop_client;
|
let client = rt.desktop_client;
|
||||||
let version = DesktopClient::extract_client_version_from_swjs(
|
let version = DesktopClient::extract_client_version_from_swjs(
|
||||||
client.http.clone(),
|
client.http.clone(),
|
||||||
&client.consent_cookie_yes,
|
&client.consent_cookie,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -719,7 +706,7 @@ mod tests {
|
||||||
let client = rt.desktop_music_client;
|
let client = rt.desktop_music_client;
|
||||||
let version = DesktopMusicClient::extract_client_version_from_swjs(
|
let version = DesktopMusicClient::extract_client_version_from_swjs(
|
||||||
client.http.clone(),
|
client.http.clone(),
|
||||||
&client.consent_cookie_yes,
|
&client.consent_cookie,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use serde::Serialize;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
response::{self, Player},
|
response,
|
||||||
ClientType, ContextYT, RustyTube, YTClient,
|
ClientType, ContextYT, RustyTube, YTClient,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -64,11 +64,7 @@ struct QContentPlaybackContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RustyTube {
|
impl RustyTube {
|
||||||
pub async fn fetch_player(
|
pub async fn get_player(&self, video_id: &str, client_type: ClientType) -> Result<PlayerData> {
|
||||||
&self,
|
|
||||||
video_id: &str,
|
|
||||||
client_type: ClientType,
|
|
||||||
) -> Result<PlayerData> {
|
|
||||||
let client = self.get_ytclient(client_type);
|
let client = self.get_ytclient(client_type);
|
||||||
let (context, deobf) = tokio::join!(
|
let (context, deobf) = tokio::join!(
|
||||||
client.get_context(false),
|
client.get_context(false),
|
||||||
|
|
@ -285,7 +281,7 @@ fn cmp_video_streams(a: &VideoStream, b: &VideoStream) -> Ordering {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_player_data(response: Player, deobf: &Deobfuscator) -> Result<PlayerData> {
|
fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<PlayerData> {
|
||||||
// Check playability status
|
// Check playability status
|
||||||
match response.playability_status {
|
match response.playability_status {
|
||||||
response::player::PlayabilityStatus::Ok { live_streamability } => {
|
response::player::PlayabilityStatus::Ok { live_streamability } => {
|
||||||
|
|
@ -417,7 +413,7 @@ fn map_player_data(response: Player, deobf: &Deobfuscator) -> Result<PlayerData>
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{fs, io::Cursor, path::Path};
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::{cache::DeobfData, client::CLIENT_TYPES};
|
use crate::{cache::DeobfData, client::CLIENT_TYPES};
|
||||||
|
|
||||||
|
|
@ -460,8 +456,8 @@ mod tests {
|
||||||
let mut json_path = tf_dir.to_path_buf();
|
let mut json_path = tf_dir.to_path_buf();
|
||||||
json_path.push(format!("{:?}_video.json", client_type).to_lowercase());
|
json_path.push(format!("{:?}_video.json", client_type).to_lowercase());
|
||||||
|
|
||||||
let mut file = fs::File::create(json_path).unwrap();
|
let mut file = std::fs::File::create(json_path).unwrap();
|
||||||
let mut content = Cursor::new(resp.bytes().await.unwrap());
|
let mut content = std::io::Cursor::new(resp.bytes().await.unwrap());
|
||||||
std::io::copy(&mut content, &mut file).unwrap();
|
std::io::copy(&mut content, &mut file).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -485,9 +481,9 @@ mod tests {
|
||||||
#[case::android(ClientType::Android)]
|
#[case::android(ClientType::Android)]
|
||||||
#[case::ios(ClientType::Ios)]
|
#[case::ios(ClientType::Ios)]
|
||||||
#[test_log::test(tokio::test)]
|
#[test_log::test(tokio::test)]
|
||||||
async fn t_fetch_stream(#[case] client_type: ClientType) {
|
async fn t_get_player(#[case] client_type: ClientType) {
|
||||||
let rt = RustyTube::new();
|
let rt = RustyTube::new();
|
||||||
let player_data = rt.fetch_player("n4tK7LYFxI0", client_type).await.unwrap();
|
let player_data = rt.get_player("n4tK7LYFxI0", client_type).await.unwrap();
|
||||||
|
|
||||||
// dbg!(player_data.clone());
|
// dbg!(player_data.clone());
|
||||||
|
|
||||||
|
|
|
||||||
116
src/client/playlist.rs
Normal file
116
src/client/playlist.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// REQUEST
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::{response, ClientType, ContextYT, RustyTube};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct QPlaylist {
|
||||||
|
context: ContextYT,
|
||||||
|
browse_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RustyTube {
|
||||||
|
pub async fn get_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
client_type: ClientType,
|
||||||
|
) -> Result<response::Playlist> {
|
||||||
|
// let client = self.desktop_client.clone();
|
||||||
|
let client = self.get_ytclient(client_type);
|
||||||
|
let context = client.get_context(true).await;
|
||||||
|
|
||||||
|
let request_body = QPlaylist {
|
||||||
|
context,
|
||||||
|
browse_id: "VL".to_owned() + playlist_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.request_builder(Method::POST, "browse")
|
||||||
|
.await
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
|
||||||
|
let playlist_response = resp.json::<response::Playlist>().await?;
|
||||||
|
|
||||||
|
Ok(playlist_response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::client::ClientType;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
// #[test_log::test(tokio::test)]
|
||||||
|
async fn download_testfiles() {
|
||||||
|
let tf_dir = Path::new("testfiles/playlist");
|
||||||
|
let playlist_id = "RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY";
|
||||||
|
|
||||||
|
let rt = RustyTube::new();
|
||||||
|
|
||||||
|
for client_type in [ClientType::Desktop, ClientType::DesktopMusic] {
|
||||||
|
let client = rt.get_ytclient(client_type);
|
||||||
|
let context = client.get_context(false).await;
|
||||||
|
|
||||||
|
let request_body = QPlaylist {
|
||||||
|
context,
|
||||||
|
browse_id: "VL".to_owned() + playlist_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.request_builder(Method::POST, "browse")
|
||||||
|
.await
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.error_for_status()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut json_path = tf_dir.to_path_buf();
|
||||||
|
json_path.push(format!("{:?}_playlist.json", client_type).to_lowercase());
|
||||||
|
|
||||||
|
let mut file = std::fs::File::create(json_path).unwrap();
|
||||||
|
let mut content = std::io::Cursor::new(resp.bytes().await.unwrap());
|
||||||
|
std::io::copy(&mut content, &mut file).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_log::test(tokio::test)]
|
||||||
|
async fn t_get_playlist() {
|
||||||
|
let rt = RustyTube::new();
|
||||||
|
let playlist = rt
|
||||||
|
.get_playlist(
|
||||||
|
"RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY",
|
||||||
|
ClientType::Desktop,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
dbg!(playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_log::test(tokio::test)]
|
||||||
|
async fn t_get_playlist_music() {
|
||||||
|
let rt = RustyTube::new();
|
||||||
|
let playlist = rt
|
||||||
|
.get_playlist(
|
||||||
|
"RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY",
|
||||||
|
ClientType::DesktopMusic,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
dbg!(playlist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,74 @@
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod playlist;
|
||||||
|
|
||||||
pub use player::Player;
|
pub use player::Player;
|
||||||
|
pub use playlist::Playlist;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_with::{serde_as, VecSkipError};
|
||||||
|
|
||||||
|
use crate::serializer::text::TextLink;
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Thumbnails {
|
||||||
|
pub thumbnails: Vec<Thumbnail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Thumbnail {
|
||||||
|
pub url: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube Music
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MusicItem {
|
||||||
|
thumbnail: MusicThumbnailRenderer,
|
||||||
|
playlist_item_data: PlaylistItemData,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
flex_columns: Vec<MusicColumn>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
fixed_columns: Vec<MusicColumn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MusicThumbnailRenderer {
|
||||||
|
music_thumbnail_renderer: MusicThumbnailRenderer2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MusicThumbnailRenderer2 {
|
||||||
|
thumbnail: Thumbnails,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistItemData {
|
||||||
|
video_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct MusicColumn {
|
||||||
|
#[serde(
|
||||||
|
rename = "musicResponsiveListItemFlexColumnRenderer",
|
||||||
|
alias = "musicResponsiveListItemFixedColumnRenderer"
|
||||||
|
)]
|
||||||
|
renderer: MusicColumnRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct MusicColumnRenderer {
|
||||||
|
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||||
|
text: TextLink,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ use serde::Deserialize;
|
||||||
use serde_with::serde_as;
|
use serde_with::serde_as;
|
||||||
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
|
use super::Thumbnails;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
|
|
@ -217,20 +219,6 @@ pub struct VideoDetails {
|
||||||
pub is_live_content: bool,
|
pub is_live_content: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Thumbnails {
|
|
||||||
pub thumbnails: Vec<Thumbnail>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Thumbnail {
|
|
||||||
pub url: String,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Microformat {
|
pub struct Microformat {
|
||||||
|
|
|
||||||
105
src/client/response/playlist.rs
Normal file
105
src/client/response/playlist.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_with::serde_as;
|
||||||
|
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
|
use crate::serializer::text::TextLink;
|
||||||
|
|
||||||
|
use super::{Thumbnails, MusicItem};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub contents: Contents,
|
||||||
|
pub header: Header,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Contents {
|
||||||
|
#[serde(alias = "singleColumnBrowseResultsRenderer")]
|
||||||
|
pub two_column_browse_results_renderer: ContentsRenderer<Tab>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Tab {
|
||||||
|
pub tab_renderer: ContentRenderer<SectionList>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SectionList {
|
||||||
|
pub section_list_renderer: ContentsRenderer<ItemSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ItemSection {
|
||||||
|
pub item_section_renderer: Option<ContentsRenderer<PlaylistVideoList>>,
|
||||||
|
pub music_playlist_shelf_renderer: Option<ContentsRenderer<PlaylistMusicItem>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistVideoList {
|
||||||
|
pub playlist_video_list_renderer: ContentsRenderer<PlaylistVideoItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistVideoItem {
|
||||||
|
playlist_video_renderer: PlaylistVideo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistMusicItem {
|
||||||
|
music_responsive_list_item_renderer: MusicItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistVideo {
|
||||||
|
pub video_id: String,
|
||||||
|
pub thumbnail: Thumbnails,
|
||||||
|
#[serde_as(as = "crate::serializer::text::Text")]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(rename = "shortBylineText")]
|
||||||
|
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||||
|
pub channel: TextLink,
|
||||||
|
#[serde_as(as = "JsonString")]
|
||||||
|
pub length_seconds: u32,
|
||||||
|
pub is_playable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Header {
|
||||||
|
#[serde(alias = "musicDetailHeaderRenderer")]
|
||||||
|
pub playlist_header_renderer: HeaderRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HeaderRenderer {
|
||||||
|
pub playlist_id: Option<String>,
|
||||||
|
#[serde_as(as = "crate::serializer::text::Text")]
|
||||||
|
pub title: String,
|
||||||
|
#[serde_as(as = "Option<crate::serializer::text::Text>")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContentRenderer<T> {
|
||||||
|
pub content: T
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContentsRenderer<T> {
|
||||||
|
#[serde(alias = "tabs")]
|
||||||
|
pub contents: Vec<T>
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use serde::{Deserialize, Deserializer};
|
use serde::{Deserialize, Deserializer};
|
||||||
use serde_with::{serde_as, DeserializeAs};
|
use serde_with::{serde_as, DefaultOnError, DeserializeAs};
|
||||||
|
|
||||||
/// The YouTube API has multiple ways of outputting text. This deserializer
|
/// The YouTube API has multiple ways of outputting text. This deserializer
|
||||||
/// is an attempt to unify them.
|
/// is an attempt to unify them.
|
||||||
|
|
@ -54,8 +54,147 @@ impl<'de> DeserializeAs<'de, String> for Text {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TextLink {
|
||||||
|
Video {
|
||||||
|
title: String,
|
||||||
|
video_id: String,
|
||||||
|
},
|
||||||
|
Browse {
|
||||||
|
text: String,
|
||||||
|
page_type: PageType,
|
||||||
|
browse_id: String,
|
||||||
|
},
|
||||||
|
None {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TextLinkInternal {
|
||||||
|
runs: Vec<TextLinkRun>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TextLinkRun {
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
navigation_endpoint: NavigationEndpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct NavigationEndpoint {
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
|
watch_endpoint: Option<WatchEndpoint>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
|
browse_endpoint: Option<BrowseEndpoint>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
|
command_metadata: Option<CommandMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WatchEndpoint {
|
||||||
|
video_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BrowseEndpoint {
|
||||||
|
browse_id: String,
|
||||||
|
browse_endpoint_context_supported_configs: Option<BrowseEndpointConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BrowseEndpointConfig {
|
||||||
|
browse_endpoint_context_music_config: BrowseEndpointMusicConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BrowseEndpointMusicConfig {
|
||||||
|
page_type: PageType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CommandMetadata {
|
||||||
|
web_command_metadata: WebCommandMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WebCommandMetadata {
|
||||||
|
web_page_type: PageType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum PageType {
|
||||||
|
#[serde(rename = "MUSIC_PAGE_TYPE_ARTIST")]
|
||||||
|
Artist,
|
||||||
|
#[serde(rename = "MUSIC_PAGE_TYPE_ALBUM")]
|
||||||
|
Album,
|
||||||
|
#[serde(
|
||||||
|
rename = "MUSIC_PAGE_TYPE_USER_CHANNEL",
|
||||||
|
alias = "WEB_PAGE_TYPE_CHANNEL"
|
||||||
|
)]
|
||||||
|
Channel,
|
||||||
|
#[serde(rename = "MUSIC_PAGE_TYPE_PLAYLIST", alias = "WEB_PAGE_TYPE_PLAYLIST")]
|
||||||
|
Playlist,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeAs<'de, TextLink> for TextLink {
|
||||||
|
fn deserialize_as<D>(deserializer: D) -> Result<TextLink, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let link = TextLinkInternal::deserialize(deserializer)?;
|
||||||
|
if link.runs.len() != 1 {
|
||||||
|
return Err(serde::de::Error::invalid_length(link.runs.len(), &"1 run"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = link.runs[0].text.to_owned();
|
||||||
|
let nav = &link.runs[0].navigation_endpoint;
|
||||||
|
|
||||||
|
Ok(match &nav.watch_endpoint {
|
||||||
|
Some(w) => TextLink::Video {
|
||||||
|
title: text,
|
||||||
|
video_id: w.video_id.to_owned(),
|
||||||
|
},
|
||||||
|
None => match &nav.browse_endpoint {
|
||||||
|
Some(b) => TextLink::Browse {
|
||||||
|
text,
|
||||||
|
page_type: match &b.browse_endpoint_context_supported_configs {
|
||||||
|
Some(bc) => bc.browse_endpoint_context_music_config.page_type,
|
||||||
|
None => match &nav.command_metadata {
|
||||||
|
Some(cm) => cm.web_command_metadata.web_page_type,
|
||||||
|
None => {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"missing/invalid browse endpoint",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
browse_id: b.browse_id.to_owned(),
|
||||||
|
},
|
||||||
|
None => TextLink::None { text },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crate::serializer::text::PageType;
|
||||||
|
|
||||||
|
use super::TextLink;
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::serde_as;
|
use serde_with::serde_as;
|
||||||
|
|
@ -95,7 +234,7 @@ mod tests {
|
||||||
}"#,
|
}"#,
|
||||||
"Abo für MBCkpop beenden?"
|
"Abo für MBCkpop beenden?"
|
||||||
)]
|
)]
|
||||||
fn t_deserialize(#[case] test_json: &str, #[case] exp: &str) {
|
fn t_deserialize_text(#[case] test_json: &str, #[case] exp: &str) {
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct S {
|
struct S {
|
||||||
|
|
@ -106,4 +245,135 @@ mod tests {
|
||||||
let res = serde_json::from_str::<S>(&test_json).unwrap();
|
let res = serde_json::from_str::<S>(&test_json).unwrap();
|
||||||
assert_eq!(res.txt, exp)
|
assert_eq!(res.txt, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SLink {
|
||||||
|
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||||
|
ln: TextLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_link_video() {
|
||||||
|
let test_json = r#"{
|
||||||
|
"ln": {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"text": "DEEP",
|
||||||
|
"navigationEndpoint": {
|
||||||
|
"watchEndpoint": {
|
||||||
|
"videoId": "wZIoIgz5mbs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let res = serde_json::from_str::<SLink>(&test_json).unwrap();
|
||||||
|
|
||||||
|
if let TextLink::Video { title, video_id } = res.ln {
|
||||||
|
assert_eq!(title, "DEEP");
|
||||||
|
assert_eq!(video_id, "wZIoIgz5mbs");
|
||||||
|
} else {
|
||||||
|
panic!("not a video");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_link_album() {
|
||||||
|
let test_json = r#"{
|
||||||
|
"ln": {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"text": "DEEP - The 1st Mini Album",
|
||||||
|
"navigationEndpoint": {
|
||||||
|
"browseEndpoint": {
|
||||||
|
"browseId": "MPREb_TKV2ccxsj5i",
|
||||||
|
"browseEndpointContextSupportedConfigs": {
|
||||||
|
"browseEndpointContextMusicConfig": {
|
||||||
|
"pageType": "MUSIC_PAGE_TYPE_ALBUM"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let res = serde_json::from_str::<SLink>(&test_json).unwrap();
|
||||||
|
|
||||||
|
if let TextLink::Browse {
|
||||||
|
text,
|
||||||
|
page_type,
|
||||||
|
browse_id,
|
||||||
|
} = res.ln
|
||||||
|
{
|
||||||
|
assert_eq!(text, "DEEP - The 1st Mini Album");
|
||||||
|
assert_eq!(page_type, PageType::Album);
|
||||||
|
assert_eq!(browse_id, "MPREb_TKV2ccxsj5i");
|
||||||
|
} else {
|
||||||
|
panic!("not a browse item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_link_channel() {
|
||||||
|
let test_json = r#"{
|
||||||
|
"ln": {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"text": "laserluca",
|
||||||
|
"navigationEndpoint": {
|
||||||
|
"commandMetadata": {
|
||||||
|
"webCommandMetadata": {
|
||||||
|
"webPageType": "WEB_PAGE_TYPE_CHANNEL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browseEndpoint": {
|
||||||
|
"browseId": "UCmxc6kXbU1J-0pR2F3wIx9A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let res = serde_json::from_str::<SLink>(&test_json).unwrap();
|
||||||
|
|
||||||
|
if let TextLink::Browse {
|
||||||
|
text,
|
||||||
|
page_type,
|
||||||
|
browse_id,
|
||||||
|
} = res.ln
|
||||||
|
{
|
||||||
|
assert_eq!(text, "laserluca");
|
||||||
|
assert_eq!(page_type, PageType::Channel);
|
||||||
|
assert_eq!(browse_id, "UCmxc6kXbU1J-0pR2F3wIx9A");
|
||||||
|
} else {
|
||||||
|
panic!("not a browse item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_link_none() {
|
||||||
|
let test_json = r#"{
|
||||||
|
"ln": {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"text": "Hello World"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let res = serde_json::from_str::<SLink>(&test_json).unwrap();
|
||||||
|
|
||||||
|
if let TextLink::None { text } = res.ln {
|
||||||
|
assert_eq!(text, "Hello World");
|
||||||
|
} else {
|
||||||
|
panic!("not none");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12967
testfiles/playlist/desktop_playlist.json
Normal file
12967
testfiles/playlist/desktop_playlist.json
Normal file
File diff suppressed because it is too large
Load diff
28804
testfiles/playlist/desktopmusic_playlist.json
Normal file
28804
testfiles/playlist/desktopmusic_playlist.json
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue