feat: add playlist extraction
- replace original base.js with dummy
This commit is contained in:
parent
5db85c05e8
commit
5b8c3d646a
30 changed files with 123935 additions and 40441 deletions
|
|
@ -52,7 +52,7 @@ struct QContentPlaybackContext {
|
|||
}
|
||||
|
||||
impl RustyTube {
|
||||
pub async fn get_player(&self, video_id: &str, client_type: ClientType) -> Result<PlayerData> {
|
||||
pub async fn get_player(&self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> {
|
||||
let client = self.get_ytclient(client_type);
|
||||
let (context, deobf) = tokio::join!(
|
||||
client.get_context(false),
|
||||
|
|
@ -315,7 +315,7 @@ fn get_audio_codec(codecs: Vec<&str>) -> AudioCodec {
|
|||
AudioCodec::Unknown
|
||||
}
|
||||
|
||||
fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<PlayerData> {
|
||||
fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<VideoPlayer> {
|
||||
// Check playability status
|
||||
match response.playability_status {
|
||||
response::player::PlayabilityStatus::Ok { live_streamability } => {
|
||||
|
|
@ -363,10 +363,10 @@ fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<P
|
|||
width: t.width,
|
||||
})
|
||||
.collect(),
|
||||
|
||||
channel_id: video_details.channel_id,
|
||||
channel_name: video_details.author,
|
||||
|
||||
channel: Channel {
|
||||
id: video_details.channel_id,
|
||||
name: video_details.author,
|
||||
},
|
||||
publish_date: microformat.as_ref().map(|m| {
|
||||
let ndt = NaiveDateTime::new(m.publish_date, NaiveTime::from_hms(0, 0, 0));
|
||||
DateTime::from_utc(ndt, Utc)
|
||||
|
|
@ -434,7 +434,7 @@ fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<P
|
|||
.collect()
|
||||
});
|
||||
|
||||
Ok(PlayerData {
|
||||
Ok(VideoPlayer {
|
||||
info: video_info,
|
||||
video_streams,
|
||||
video_only_streams,
|
||||
|
|
@ -446,7 +446,7 @@ fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<P
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
||||
use crate::{cache::DeobfData, client::CLIENT_TYPES};
|
||||
|
||||
|
|
@ -491,7 +491,7 @@ mod tests {
|
|||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let mut file = std::fs::File::create(json_path).unwrap();
|
||||
let mut file = File::create(json_path).unwrap();
|
||||
let mut content = std::io::Cursor::new(resp.bytes().await.unwrap());
|
||||
std::io::copy(&mut content, &mut file).unwrap();
|
||||
}
|
||||
|
|
@ -510,23 +510,40 @@ mod tests {
|
|||
}
|
||||
|
||||
let player_data = rt.get_player(id, ClientType::Desktop).await.unwrap();
|
||||
let file = std::fs::File::create(json_path).unwrap();
|
||||
let file = File::create(json_path).unwrap();
|
||||
serde_json::to_writer_pretty(file, &player_data).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::desktop("desktop", include_str!("../../testfiles/player/desktop_video.json"))]
|
||||
#[case::desktop_music("desktop_music", include_str!("../../testfiles/player/desktopmusic_video.json"))]
|
||||
#[case::tv_html5_embed("tvhtml5embed", include_str!("../../testfiles/player/tvhtml5embed_video.json"))]
|
||||
#[case::android("android", include_str!("../../testfiles/player/android_video.json"))]
|
||||
#[case::ios("ios", include_str!("../../testfiles/player/ios_video.json"))]
|
||||
fn t_map_player_data(#[case] name: &str, #[case] json_str: &str) {
|
||||
let resp = serde_json::from_str::<response::Player>(json_str).unwrap();
|
||||
#[case::desktop("desktop")]
|
||||
#[case::desktop_music("desktopmusic")]
|
||||
#[case::tv_html5_embed("tvhtml5embed")]
|
||||
#[case::android("android")]
|
||||
#[case::ios("ios")]
|
||||
fn t_map_player_data(#[case] name: &str) {
|
||||
let filename = format!("testfiles/player/{}_video.json", name);
|
||||
let json_path = Path::new(&filename);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let resp: response::Player = serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let player_data = map_player_data(resp, &DEOBFUSCATOR).unwrap();
|
||||
insta::assert_yaml_snapshot!(format!("map_player_data_{}", name), player_data)
|
||||
}
|
||||
|
||||
/// Assert equality within 10% margin
|
||||
fn assert_approx(left: u32, right: u32) {
|
||||
if left != right {
|
||||
let f = left as f64 / right as f64;
|
||||
assert!(
|
||||
0.9 < f && f < 1.1,
|
||||
"{} not within 10% margin of {}",
|
||||
left,
|
||||
right
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::desktop(ClientType::Desktop)]
|
||||
#[case::tv_html5_embed(ClientType::TvHtml5Embed)]
|
||||
|
|
@ -550,8 +567,8 @@ mod tests {
|
|||
}
|
||||
assert_eq!(player_data.info.length, 259);
|
||||
assert!(!player_data.info.thumbnails.is_empty());
|
||||
assert_eq!(player_data.info.channel_id, "UC_aEa8K-EOJ3D6gOs7HcyNg");
|
||||
assert_eq!(player_data.info.channel_name, "NoCopyrightSounds");
|
||||
assert_eq!(player_data.info.channel.id, "UC_aEa8K-EOJ3D6gOs7HcyNg");
|
||||
assert_eq!(player_data.info.channel.name, "NoCopyrightSounds");
|
||||
assert!(player_data.info.view_count > 146818808);
|
||||
assert_eq!(player_data.info.keywords[0], "spektrem");
|
||||
assert_eq!(player_data.info.is_live_content, false);
|
||||
|
|
@ -577,7 +594,8 @@ mod tests {
|
|||
.find(|s| s.itag == 140)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(video.bitrate, 1507068);
|
||||
// Bitrates may change between requests
|
||||
assert_approx(video.bitrate, 1507068);
|
||||
assert_eq!(video.average_bitrate, 1345149);
|
||||
assert_eq!(video.size, 43553412);
|
||||
assert_eq!(video.width, 1280);
|
||||
|
|
@ -589,7 +607,7 @@ mod tests {
|
|||
assert_eq!(video.format, VideoFormat::Webm);
|
||||
assert_eq!(video.codec, VideoCodec::Vp9);
|
||||
|
||||
assert_eq!(audio.bitrate, 130685);
|
||||
assert_approx(audio.bitrate, 130685);
|
||||
assert_eq!(audio.average_bitrate, 129496);
|
||||
assert_eq!(audio.size, 4193863);
|
||||
assert_eq!(audio.mime, "audio/mp4; codecs=\"mp4a.40.2\"");
|
||||
|
|
@ -607,7 +625,7 @@ mod tests {
|
|||
.find(|s| s.itag == 251)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(video.bitrate, 1340829);
|
||||
assert_approx(video.bitrate, 1340829);
|
||||
assert_eq!(video.average_bitrate, 1233444);
|
||||
assert_eq!(video.size, 39936630);
|
||||
assert_eq!(video.width, 1280);
|
||||
|
|
@ -620,7 +638,7 @@ mod tests {
|
|||
assert_eq!(video.codec, VideoCodec::Av01);
|
||||
assert_eq!(video.throttled, false);
|
||||
|
||||
assert_eq!(audio.bitrate, 142718);
|
||||
assert_approx(audio.bitrate, 142718);
|
||||
assert_eq!(audio.average_bitrate, 130708);
|
||||
assert_eq!(audio.size, 4232344);
|
||||
assert_eq!(audio.mime, "audio/webm; codecs=\"opus\"");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
// REQUEST
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
model::{Channel, Playlist, Thumbnail, Video},
|
||||
serializer::text::{PageType, Text, TextLink},
|
||||
};
|
||||
|
||||
use super::{response, ClientType, ContextYT, RustyTube};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
|
@ -20,13 +25,8 @@ pub struct TmpEntry {
|
|||
}
|
||||
|
||||
impl RustyTube {
|
||||
pub async fn get_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
client_type: ClientType,
|
||||
) -> Result<Vec<TmpEntry>> {
|
||||
// let client = self.desktop_client.clone();
|
||||
let client = self.get_ytclient(client_type);
|
||||
pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> {
|
||||
let client = self.get_ytclient(ClientType::Desktop);
|
||||
let context = client.get_context(true).await;
|
||||
|
||||
let request_body = QPlaylist {
|
||||
|
|
@ -44,57 +44,204 @@ impl RustyTube {
|
|||
|
||||
let playlist_response = resp.json::<response::Playlist>().await?;
|
||||
|
||||
Ok(map_playlist_tmp(playlist_response))
|
||||
map_playlist(&playlist_response)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_playlist_tmp(response: response::Playlist) -> Vec<TmpEntry> {
|
||||
let content = &response
|
||||
.contents
|
||||
.two_column_browse_results_renderer
|
||||
.contents[0]
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents[0];
|
||||
|
||||
match &content.item_section_renderer {
|
||||
Some(items) => items.contents[0]
|
||||
.playlist_video_list_renderer
|
||||
fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
||||
let video_items = &some_or_bail!(
|
||||
some_or_bail!(
|
||||
some_or_bail!(
|
||||
response
|
||||
.contents
|
||||
.two_column_browse_results_renderer
|
||||
.contents
|
||||
.get(0),
|
||||
Err(anyhow!("twoColumnBrowseResultsRenderer empty"))
|
||||
)
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents
|
||||
.get(0),
|
||||
Err(anyhow!("sectionListRenderer empty"))
|
||||
)
|
||||
.item_section_renderer
|
||||
.contents
|
||||
.get(0),
|
||||
Err(anyhow!("itemSectionRenderer empty"))
|
||||
)
|
||||
.playlist_video_list_renderer
|
||||
.contents;
|
||||
|
||||
let mut ctoken: Option<String> = None;
|
||||
let videos = video_items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
response::playlist::PlaylistVideoItem::PlaylistVideoRenderer { video } => {
|
||||
match &video.channel {
|
||||
TextLink::Browse {
|
||||
text,
|
||||
page_type,
|
||||
browse_id,
|
||||
} => match page_type {
|
||||
PageType::Channel => Some(Video {
|
||||
id: video.video_id.to_owned(),
|
||||
title: video.title.to_owned(),
|
||||
length: video.length_seconds,
|
||||
thumbnails: video
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.iter()
|
||||
.map(|t| Thumbnail {
|
||||
url: t.url.to_owned(),
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
})
|
||||
.collect(),
|
||||
channel: Channel {
|
||||
id: browse_id.to_string(),
|
||||
name: text.to_owned(),
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
response::playlist::PlaylistVideoItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => {
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token.to_owned());
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let thumbnail_renderer = some_or_bail!(
|
||||
response
|
||||
.sidebar
|
||||
.playlist_sidebar_renderer
|
||||
.items
|
||||
.iter()
|
||||
.map(|it| TmpEntry {
|
||||
title: it.playlist_video_renderer.title.to_owned(),
|
||||
video_id: it.playlist_video_renderer.video_id.to_owned(),
|
||||
})
|
||||
.collect(),
|
||||
None => todo!(),
|
||||
}
|
||||
.find_map(|s| match s {
|
||||
response::playlist::SidebarRendererItem::PlaylistSidebarPrimaryInfoRenderer {
|
||||
thumbnail_renderer,
|
||||
} => Some(thumbnail_renderer),
|
||||
_ => None,
|
||||
}),
|
||||
Err(anyhow!("no primary sidebar"))
|
||||
);
|
||||
|
||||
let video_owner_wrap = response
|
||||
.sidebar
|
||||
.playlist_sidebar_renderer
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
response::playlist::SidebarRendererItem::PlaylistSidebarSecondaryInfoRenderer {
|
||||
video_owner,
|
||||
} => Some(video_owner),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let n_videos = match ctoken {
|
||||
Some(_) => {
|
||||
some_or_bail!(
|
||||
match &response.header.playlist_header_renderer.num_videos_text {
|
||||
Text::Multiple { runs } =>
|
||||
if runs.len() == 2 && runs[1] == " videos" {
|
||||
runs[0].parse().ok()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
_ => None,
|
||||
},
|
||||
Err(anyhow!("no video count"))
|
||||
)
|
||||
}
|
||||
None => videos.len() as u32,
|
||||
};
|
||||
|
||||
let thumbnails = thumbnail_renderer
|
||||
.playlist_video_thumbnail_renderer
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.iter()
|
||||
.map(|t| Thumbnail {
|
||||
url: t.url.to_owned(),
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let name = response.header.playlist_header_renderer.title.to_owned();
|
||||
let description = response
|
||||
.header
|
||||
.playlist_header_renderer
|
||||
.description_text
|
||||
.to_owned();
|
||||
|
||||
let channel = match video_owner_wrap {
|
||||
Some(o) => match &o.video_owner_renderer.title {
|
||||
TextLink::Browse {
|
||||
text,
|
||||
page_type,
|
||||
browse_id,
|
||||
} => match page_type {
|
||||
PageType::Channel => Some(Channel {
|
||||
id: browse_id.to_owned(),
|
||||
name: text.to_owned(),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Playlist {
|
||||
videos,
|
||||
n_videos,
|
||||
ctoken,
|
||||
name,
|
||||
thumbnails,
|
||||
description,
|
||||
channel,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
||||
use crate::client::ClientType;
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[allow(dead_code)]
|
||||
// #[test_log::test(tokio::test)]
|
||||
#[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);
|
||||
for (name, id) in [
|
||||
("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"),
|
||||
("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"),
|
||||
("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"),
|
||||
] {
|
||||
let mut json_path = tf_dir.to_path_buf();
|
||||
json_path.push(format!("playlist_{}.json", name));
|
||||
if json_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let client = rt.get_ytclient(ClientType::Desktop);
|
||||
let context = client.get_context(false).await;
|
||||
|
||||
let request_body = QPlaylist {
|
||||
context,
|
||||
browse_id: "VL".to_owned() + playlist_id,
|
||||
browse_id: "VL".to_owned() + id,
|
||||
};
|
||||
|
||||
let resp = client
|
||||
|
|
@ -107,40 +254,73 @@ mod tests {
|
|||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::long(
|
||||
"PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ",
|
||||
"Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022",
|
||||
true,
|
||||
None,
|
||||
Some(Channel {
|
||||
id: "UCIekuFeMaV78xYfvpmoCnPg".to_owned(),
|
||||
name: "Best Music".to_owned(),
|
||||
})
|
||||
)]
|
||||
#[case::short(
|
||||
"RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk",
|
||||
"Easy Pop",
|
||||
false,
|
||||
None,
|
||||
None
|
||||
)]
|
||||
#[case::nomusic(
|
||||
"PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe",
|
||||
"Minecraft SHINE",
|
||||
false,
|
||||
Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...".to_owned()),
|
||||
Some(Channel {
|
||||
id: "UCQM0bS4_04-Y4JuYrgmnpZQ".to_owned(),
|
||||
name: "Chaosflo44".to_owned(),
|
||||
})
|
||||
)]
|
||||
#[test_log::test(tokio::test)]
|
||||
async fn t_get_playlist() {
|
||||
async fn t_get_playlist(
|
||||
#[case] id: &str,
|
||||
#[case] name: &str,
|
||||
#[case] is_long: bool,
|
||||
#[case] description: Option<String>,
|
||||
#[case] channel: Option<Channel>,
|
||||
) {
|
||||
let rt = RustyTube::new();
|
||||
let playlist = rt
|
||||
.get_playlist(
|
||||
"RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY",
|
||||
ClientType::Desktop,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let playlist = rt.get_playlist(id).await.unwrap();
|
||||
|
||||
dbg!(playlist);
|
||||
assert_eq!(playlist.name, name);
|
||||
assert!(!playlist.videos.is_empty());
|
||||
assert_eq!(playlist.ctoken.is_some(), is_long);
|
||||
assert!(playlist.n_videos > 10);
|
||||
assert_eq!(playlist.n_videos > 100, is_long);
|
||||
assert_eq!(playlist.description, description);
|
||||
assert_eq!(playlist.channel, channel);
|
||||
assert!(!playlist.thumbnails.is_empty());
|
||||
}
|
||||
|
||||
#[test_log::test(tokio::test)]
|
||||
async fn t_get_playlist_music() {
|
||||
let rt = RustyTube::new();
|
||||
let playlist = rt
|
||||
.get_playlist(
|
||||
"RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY",
|
||||
ClientType::Desktop,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
#[rstest]
|
||||
#[case::long("long")]
|
||||
#[case::short("short")]
|
||||
#[case::nomusic("nomusic")]
|
||||
fn t_map_player_data(#[case] name: &str) {
|
||||
let filename = format!("testfiles/playlist/playlist_{}.json", name);
|
||||
let json_path = Path::new(&filename);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
dbg!(playlist);
|
||||
let playlist: response::Playlist =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let playlist_data = map_playlist(&playlist).unwrap();
|
||||
insta::assert_yaml_snapshot!(format!("map_playlist_data_{}", name), playlist_data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,35 @@
|
|||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod playlist_music;
|
||||
|
||||
pub use player::Player;
|
||||
pub use playlist::Playlist;
|
||||
pub use playlist_music::PlaylistMusic;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, VecSkipError};
|
||||
use serde_with::{serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::text::TextLink;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThumbnailsWrap {
|
||||
pub thumbnail: Thumbnails,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Thumbnails {
|
||||
|
|
@ -23,6 +44,24 @@ pub struct Thumbnail {
|
|||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContinuationItemRenderer {
|
||||
pub continuation_endpoint: ContinuationEndpoint,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContinuationEndpoint {
|
||||
pub continuation_command: ContinuationCommand,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContinuationCommand {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
// YouTube Music
|
||||
|
||||
#[serde_as]
|
||||
|
|
@ -30,7 +69,9 @@ pub struct Thumbnail {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicItem {
|
||||
pub thumbnail: MusicThumbnailRenderer,
|
||||
pub playlist_item_data: PlaylistItemData,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub playlist_item_data: Option<PlaylistItemData>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub flex_columns: Vec<MusicColumn>,
|
||||
|
|
@ -42,13 +83,8 @@ pub struct MusicItem {
|
|||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicThumbnailRenderer {
|
||||
pub music_thumbnail_renderer: MusicThumbnailRenderer2,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicThumbnailRenderer2 {
|
||||
pub thumbnail: Thumbnails,
|
||||
#[serde(alias = "croppedSquareThumbnailRenderer")]
|
||||
pub music_thumbnail_renderer: ThumbnailsWrap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -57,6 +93,15 @@ pub struct PlaylistItemData {
|
|||
pub video_id: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicContentsRenderer<T> {
|
||||
pub contents: Vec<T>,
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub continuations: Option<Vec<MusicContinuation>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MusicColumn {
|
||||
#[serde(
|
||||
|
|
@ -69,6 +114,18 @@ pub struct MusicColumn {
|
|||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MusicColumnRenderer {
|
||||
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||
pub text: TextLink,
|
||||
#[serde_as(as = "crate::serializer::text::TextLinks")]
|
||||
pub text: Vec<TextLink>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicContinuation {
|
||||
pub next_continuation_data: MusicContinuationData,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicContinuationData {
|
||||
pub continuation: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,21 @@ use serde::Deserialize;
|
|||
use serde_with::serde_as;
|
||||
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::text::TextLink;
|
||||
use crate::serializer::text::{Text, TextLink};
|
||||
|
||||
use super::{MusicItem, Thumbnails};
|
||||
use super::{ContentRenderer, ContentsRenderer, ContinuationEndpoint, Thumbnails, ThumbnailsWrap};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Playlist {
|
||||
pub contents: Contents,
|
||||
pub header: Header,
|
||||
pub sidebar: Sidebar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Contents {
|
||||
#[serde(alias = "singleColumnBrowseResultsRenderer")]
|
||||
pub two_column_browse_results_renderer: ContentsRenderer<Tab>,
|
||||
}
|
||||
|
||||
|
|
@ -35,26 +35,35 @@ pub struct SectionList {
|
|||
#[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>>,
|
||||
pub item_section_renderer: ContentsRenderer<PlaylistVideoListRenderer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistVideoListRenderer {
|
||||
pub playlist_video_list_renderer: PlaylistVideoList,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistVideoList {
|
||||
pub playlist_video_list_renderer: ContentsRenderer<PlaylistVideoItem>,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub contents: Vec<PlaylistVideoItem>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistVideoItem {
|
||||
pub playlist_video_renderer: PlaylistVideo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistMusicItem {
|
||||
pub music_responsive_list_item_renderer: MusicItem,
|
||||
pub enum PlaylistVideoItem {
|
||||
PlaylistVideoRenderer {
|
||||
#[serde(flatten)]
|
||||
video: PlaylistVideo,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContinuationItemRenderer {
|
||||
continuation_endpoint: ContinuationEndpoint,
|
||||
},
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
|
@ -76,7 +85,6 @@ pub struct PlaylistVideo {
|
|||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Header {
|
||||
#[serde(alias = "musicDetailHeaderRenderer")]
|
||||
pub playlist_header_renderer: HeaderRenderer,
|
||||
}
|
||||
|
||||
|
|
@ -84,22 +92,63 @@ pub struct Header {
|
|||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HeaderRenderer {
|
||||
pub playlist_id: Option<String>,
|
||||
pub playlist_id: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub title: String,
|
||||
#[serde_as(as = "Option<crate::serializer::text::Text>")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError<Option<crate::serializer::text::Text>>")]
|
||||
pub description_text: Option<String>,
|
||||
/// `"495", " videos"`
|
||||
pub num_videos_text: Text,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentRenderer<T> {
|
||||
pub content: T,
|
||||
pub struct Sidebar {
|
||||
pub playlist_sidebar_renderer: SidebarRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SidebarRenderer {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub items: Vec<SidebarRendererItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentsRenderer<T> {
|
||||
#[serde(alias = "tabs")]
|
||||
pub contents: Vec<T>,
|
||||
pub enum SidebarRendererItem {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PlaylistSidebarPrimaryInfoRenderer {
|
||||
thumbnail_renderer: PlaylistThumbnailRenderer,
|
||||
// - `"495", " videos"`
|
||||
// - `"3,310,996 views"`
|
||||
// - `"Last updated on ", "Aug 7, 2022"`
|
||||
// stats: Vec<Text>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PlaylistSidebarSecondaryInfoRenderer { video_owner: VideoOwnerWrap },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistThumbnailRenderer {
|
||||
// the alternative field name is used by YTM playlists
|
||||
#[serde(alias = "playlistCustomThumbnailRenderer")]
|
||||
pub playlist_video_thumbnail_renderer: ThumbnailsWrap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoOwnerWrap {
|
||||
pub video_owner_renderer: VideoOwner,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoOwner {
|
||||
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||
pub title: TextLink,
|
||||
}
|
||||
|
|
|
|||
95
src/client/response/playlist_music.rs
Normal file
95
src/client/response/playlist_music.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
use serde_with::VecSkipError;
|
||||
|
||||
use crate::serializer::text::Text;
|
||||
|
||||
use super::MusicThumbnailRenderer;
|
||||
use super::{
|
||||
ContentRenderer, ContentsRenderer, MusicContentsRenderer, MusicContinuation, MusicItem,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistMusic {
|
||||
pub contents: Contents,
|
||||
pub header: Header,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Contents {
|
||||
pub single_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 {
|
||||
/// Includes a continuation token for fetching recommendations
|
||||
pub section_list_renderer: MusicContentsRenderer<ItemSection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemSection {
|
||||
#[serde(alias = "musicPlaylistShelfRenderer")]
|
||||
pub music_shelf_renderer: MusicShelf,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicShelf {
|
||||
/// Playlist ID (only for playlists)
|
||||
pub playlist_id: Option<String>,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub contents: Vec<PlaylistMusicItem>,
|
||||
/// Continuation token for fetching more (>100) playlist items
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub continuations: Option<Vec<MusicContinuation>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistMusicItem {
|
||||
pub music_responsive_list_item_renderer: MusicItem,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Header {
|
||||
pub music_detail_header_renderer: HeaderRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HeaderRenderer {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub title: String,
|
||||
/// Content type + Channel/Artist + Year.
|
||||
/// Missing on artist_tracks view.
|
||||
///
|
||||
/// `"Playlist", " • ", <"Best Music">, " • ", "2022"`
|
||||
///
|
||||
/// `"Album", " • ", <"Helene Fischer">, " • ", "2021"`
|
||||
pub subtitle: Option<Text>,
|
||||
/// Playlist description. May contain hashtags which are
|
||||
/// displayed as search links on the YouTube website.
|
||||
#[serde_as(as = "Option<crate::serializer::text::Text>")]
|
||||
pub description: Option<String>,
|
||||
/// Playlist thumbnail / album cover.
|
||||
/// Missing on artist_tracks view.
|
||||
pub thumbnail: Option<MusicThumbnailRenderer>,
|
||||
/// Number of tracks + playtime.
|
||||
/// Missing on artist_tracks view.
|
||||
///
|
||||
/// `"64 songs", " • ", "3 hours, 40 minutes"`
|
||||
pub second_subtitle: Option<Text>,
|
||||
}
|
||||
|
|
@ -20,8 +20,9 @@ info:
|
|||
- url: "https://i.ytimg.com/vi_webp/pPvd8UxmSbQ/sddefault.webp"
|
||||
width: 640
|
||||
height: 480
|
||||
channel_id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
channel_name: RomanSenykMusic - Royalty Free Music
|
||||
channel:
|
||||
id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
name: RomanSenykMusic - Royalty Free Music
|
||||
publish_date: ~
|
||||
view_count: 426567
|
||||
keywords:
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ info:
|
|||
- url: "https://i.ytimg.com/vi_webp/pPvd8UxmSbQ/maxresdefault.webp"
|
||||
width: 1920
|
||||
height: 1080
|
||||
channel_id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
channel_name: RomanSenykMusic - Royalty Free Music
|
||||
channel:
|
||||
id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
name: RomanSenykMusic - Royalty Free Music
|
||||
publish_date: "2019-05-30T00:00:00Z"
|
||||
view_count: 426567
|
||||
keywords:
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ info:
|
|||
- url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AOn4CLA1wPf8NLXqHitgwoNBp4ydFCtDiA"
|
||||
width: 853
|
||||
height: 480
|
||||
channel_id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
channel_name: Romansenykmusic
|
||||
channel:
|
||||
id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
name: Romansenykmusic
|
||||
publish_date: "2019-05-30T00:00:00Z"
|
||||
view_count: 426583
|
||||
keywords:
|
||||
|
|
@ -17,8 +17,9 @@ info:
|
|||
- url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/sddefault.jpg"
|
||||
width: 640
|
||||
height: 480
|
||||
channel_id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
channel_name: RomanSenykMusic - Royalty Free Music
|
||||
channel:
|
||||
id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
name: RomanSenykMusic - Royalty Free Music
|
||||
publish_date: ~
|
||||
view_count: 426567
|
||||
keywords:
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ info:
|
|||
- url: "https://i.ytimg.com/vi/pPvd8UxmSbQ/maxresdefault.jpg"
|
||||
width: 1920
|
||||
height: 1080
|
||||
channel_id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
channel_name: RomanSenykMusic - Royalty Free Music
|
||||
channel:
|
||||
id: UCbxxEi-ImPlbLx5F-fHetEg
|
||||
name: RomanSenykMusic - Royalty Free Music
|
||||
publish_date: ~
|
||||
view_count: 426567
|
||||
keywords:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Reference in a new issue