feat: add music playlist
This commit is contained in:
parent
b64aabb6b6
commit
566b3e5bfc
24 changed files with 238892 additions and 61192 deletions
|
|
@ -3,6 +3,7 @@
|
|||
pub(crate) mod response;
|
||||
|
||||
mod channel;
|
||||
mod music_playlist;
|
||||
mod pagination;
|
||||
mod player;
|
||||
mod playlist;
|
||||
|
|
|
|||
207
src/client/music_playlist.rs
Normal file
207
src/client/music_playlist.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
error::{Error, ExtractionError},
|
||||
model::{ChannelId, MusicPlaylist, Paginator, TrackItem},
|
||||
serializer::MapResult,
|
||||
util::{self, TryRemove},
|
||||
};
|
||||
|
||||
use super::{
|
||||
response::{self, music_item::MusicListMapper},
|
||||
ClientType, MapResponse, QBrowse, QContinuation, RustyPipeQuery,
|
||||
};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
pub async fn music_playlist(&self, playlist_id: &str) -> Result<MusicPlaylist, Error> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QBrowse {
|
||||
context,
|
||||
browse_id: "VL".to_owned() + playlist_id,
|
||||
};
|
||||
|
||||
self.execute_request::<response::MusicPlaylist, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_playlist",
|
||||
playlist_id,
|
||||
"browse",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn music_playlist_continuation(
|
||||
&self,
|
||||
ctoken: &str,
|
||||
) -> Result<Paginator<TrackItem>, Error> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QContinuation {
|
||||
context,
|
||||
continuation: ctoken,
|
||||
};
|
||||
|
||||
self.execute_request::<response::MusicPlaylistCont, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_playlist_continuation",
|
||||
ctoken,
|
||||
"browse",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
|
||||
fn map_response(
|
||||
self,
|
||||
id: &str,
|
||||
_lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<MapResult<MusicPlaylist>, ExtractionError> {
|
||||
// dbg!(&self);
|
||||
|
||||
let header = self.header.music_detail_header_renderer;
|
||||
|
||||
let mut content = self.contents.single_column_browse_results_renderer.contents;
|
||||
let mut shelf = content
|
||||
.try_swap_remove(0)
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents
|
||||
.try_swap_remove(0)
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
|
||||
"no sectionListRenderer content",
|
||||
)))?
|
||||
.music_shelf_renderer;
|
||||
|
||||
let playlist_id = shelf
|
||||
.playlist_id
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
|
||||
"no playlist id",
|
||||
)))?;
|
||||
|
||||
if playlist_id != id {
|
||||
return Err(ExtractionError::WrongResult(format!(
|
||||
"got wrong playlist id {}, expected {}",
|
||||
playlist_id, id
|
||||
)));
|
||||
}
|
||||
|
||||
let from_ytm = header
|
||||
.subtitle
|
||||
.0
|
||||
.iter()
|
||||
.any(|c| c.as_str() == "YouTube Music");
|
||||
|
||||
let channel = header
|
||||
.subtitle
|
||||
.0
|
||||
.into_iter()
|
||||
.find_map(|c| ChannelId::try_from(c).ok());
|
||||
|
||||
let mut mapper = MusicListMapper::<TrackItem>::new();
|
||||
mapper.map_response(shelf.contents);
|
||||
|
||||
let ctoken = shelf
|
||||
.continuations
|
||||
.try_swap_remove(0)
|
||||
.map(|cont| cont.next_continuation_data.continuation);
|
||||
|
||||
let track_count = match ctoken {
|
||||
Some(_) => header
|
||||
.second_subtitle
|
||||
.first()
|
||||
.and_then(|txt| util::parse_numeric::<u64>(txt).ok()),
|
||||
None => Some(mapper.items.len() as u64),
|
||||
};
|
||||
|
||||
Ok(MapResult {
|
||||
c: MusicPlaylist {
|
||||
id: playlist_id,
|
||||
name: header.title,
|
||||
thumbnail: header.thumbnail.into(),
|
||||
channel,
|
||||
description: header.description,
|
||||
track_count,
|
||||
from_ytm,
|
||||
tracks: Paginator::new(track_count, mapper.items, ctoken),
|
||||
},
|
||||
warnings: mapper.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<TrackItem>> for response::MusicPlaylistCont {
|
||||
fn map_response(
|
||||
self,
|
||||
_id: &str,
|
||||
_lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
|
||||
let mut mapper = MusicListMapper::<TrackItem>::new();
|
||||
let mut shelf = self.continuation_contents.music_playlist_shelf_continuation;
|
||||
mapper.map_response(shelf.contents);
|
||||
|
||||
let ctoken = shelf
|
||||
.continuations
|
||||
.try_swap_remove(0)
|
||||
.map(|cont| cont.next_continuation_data.continuation);
|
||||
|
||||
Ok(MapResult {
|
||||
c: Paginator::new(None, mapper.items, ctoken),
|
||||
warnings: mapper.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::param::Language;
|
||||
|
||||
#[rstest]
|
||||
#[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")]
|
||||
#[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")]
|
||||
#[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
|
||||
fn map_music_playlist(#[case] name: &str, #[case] id: &str) {
|
||||
let filename = format!("testfiles/music_playlist/playlist_{}.json", name);
|
||||
let json_path = Path::new(&filename);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let playlist: response::MusicPlaylist =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res = playlist.map_response(id, Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
insta::assert_ron_snapshot!(format!("map_music_playlist_{}", name), map_res.c, {
|
||||
".last_update" => "[date]"
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_music_playlist_cont() {
|
||||
let json_path = Path::new("testfiles/music_playlist/playlist_cont.json");
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let playlist: response::MusicPlaylistCont =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res = playlist.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
insta::assert_ron_snapshot!("map_music_playlist_cont", map_res.c);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use crate::error::{Error, ExtractionError};
|
||||
use crate::model::{Comment, Paginator, PlaylistVideo, YouTubeItem};
|
||||
use crate::model::{Comment, Paginator, PlaylistVideo, TrackItem, YouTubeItem};
|
||||
use crate::param::ContinuationEndpoint;
|
||||
use crate::serializer::MapResult;
|
||||
use crate::util::TryRemove;
|
||||
|
|
@ -165,6 +165,15 @@ impl Paginator<PlaylistVideo> {
|
|||
}
|
||||
}
|
||||
|
||||
impl Paginator<TrackItem> {
|
||||
pub async fn next(&self, query: &RustyPipeQuery) -> Result<Option<Self>, Error> {
|
||||
Ok(match &self.ctoken {
|
||||
Some(ctoken) => Some(query.music_playlist_continuation(ctoken).await?),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! paginator {
|
||||
($entity_type:ty) => {
|
||||
impl Paginator<$entity_type> {
|
||||
|
|
@ -216,6 +225,7 @@ macro_rules! paginator {
|
|||
|
||||
paginator!(Comment);
|
||||
paginator!(PlaylistVideo);
|
||||
paginator!(TrackItem);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
pub(crate) mod channel;
|
||||
pub(crate) mod music_item;
|
||||
pub(crate) mod music_playlist;
|
||||
pub(crate) mod player;
|
||||
pub(crate) mod playlist;
|
||||
// pub(crate) mod playlist_music;
|
||||
pub(crate) mod search;
|
||||
pub(crate) mod trends;
|
||||
pub(crate) mod url_endpoint;
|
||||
|
|
@ -9,10 +10,11 @@ pub(crate) mod video_details;
|
|||
pub(crate) mod video_item;
|
||||
|
||||
pub(crate) use channel::Channel;
|
||||
pub(crate) use music_playlist::MusicPlaylist;
|
||||
pub(crate) use music_playlist::MusicPlaylistCont;
|
||||
pub(crate) use player::Player;
|
||||
pub(crate) use playlist::Playlist;
|
||||
pub(crate) use playlist::PlaylistCont;
|
||||
// pub(crate) use playlist_music::PlaylistMusic;
|
||||
pub(crate) use search::Search;
|
||||
pub(crate) use trends::Startpage;
|
||||
pub(crate) use trends::Trending;
|
||||
|
|
@ -47,7 +49,7 @@ pub(crate) struct ContentsRenderer<T> {
|
|||
pub contents: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Default, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ThumbnailsWrap {
|
||||
#[serde(default)]
|
||||
|
|
@ -195,61 +197,6 @@ pub(crate) struct RichGridContinuation {
|
|||
pub contents: MapResult<Vec<YouTubeListItem>>,
|
||||
}
|
||||
|
||||
// YouTube Music
|
||||
|
||||
/*
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicItem {
|
||||
pub thumbnail: MusicThumbnailRenderer,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub playlist_item_data: Option<PlaylistItemData>,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub flex_columns: Vec<MusicColumn>,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub fixed_columns: Vec<MusicColumn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicThumbnailRenderer {
|
||||
#[serde(alias = "croppedSquareThumbnailRenderer")]
|
||||
pub music_thumbnail_renderer: ThumbnailsWrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PlaylistItemData {
|
||||
pub video_id: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicContentsRenderer<T> {
|
||||
pub contents: Vec<T>,
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub continuations: Option<Vec<MusicContinuation>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MusicColumn {
|
||||
#[serde(
|
||||
rename = "musicResponsiveListItemFlexColumnRenderer",
|
||||
alias = "musicResponsiveListItemFixedColumnRenderer"
|
||||
)]
|
||||
pub renderer: MusicColumnRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MusicColumnRenderer {
|
||||
pub text: TextComponent,
|
||||
}
|
||||
*/
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicContinuation {
|
||||
|
|
|
|||
220
src/client/response/music_item.rs
Normal file
220
src/client/response/music_item.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, DefaultOnError};
|
||||
|
||||
use crate::{
|
||||
model::{self, ChannelId},
|
||||
serializer::{text::TextComponents, MapResult},
|
||||
util::{self, TryRemove},
|
||||
};
|
||||
|
||||
use super::ThumbnailsWrap;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicItem {
|
||||
pub music_responsive_list_item_renderer: InnerMusicItem,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InnerMusicItem {
|
||||
pub thumbnail: MusicThumbnailRenderer,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub playlist_item_data: Option<PlaylistItemData>,
|
||||
pub flex_columns: Vec<MusicColumn>,
|
||||
pub fixed_columns: Vec<MusicColumn>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicThumbnailRenderer {
|
||||
#[serde(alias = "croppedSquareThumbnailRenderer")]
|
||||
pub music_thumbnail_renderer: ThumbnailsWrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PlaylistItemData {
|
||||
pub video_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicContentsRenderer<T> {
|
||||
pub contents: Vec<T>,
|
||||
/*
|
||||
/// Continuation token for fetching recommended items
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub continuations: Vec<MusicContinuation>,
|
||||
*/
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MusicColumn {
|
||||
#[serde(
|
||||
rename = "musicResponsiveListItemFlexColumnRenderer",
|
||||
alias = "musicResponsiveListItemFixedColumnRenderer"
|
||||
)]
|
||||
pub renderer: MusicColumnRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MusicColumnRenderer {
|
||||
pub text: TextComponents,
|
||||
}
|
||||
|
||||
impl From<MusicThumbnailRenderer> for Vec<model::Thumbnail> {
|
||||
fn from(tr: MusicThumbnailRenderer) -> Self {
|
||||
tr.music_thumbnail_renderer.thumbnail.into()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
#MAPPER
|
||||
*/
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MusicListMapper<T> {
|
||||
artists: Option<Vec<ChannelId>>,
|
||||
|
||||
pub items: Vec<T>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl<T> MusicListMapper<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
artists: None,
|
||||
items: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_artists(artists: Vec<ChannelId>) -> Self {
|
||||
Self {
|
||||
artists: Some(artists),
|
||||
items: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_music_item(&mut self, item: MusicItem) -> Option<model::YouTubeMusicItem> {
|
||||
let item = item.music_responsive_list_item_renderer;
|
||||
|
||||
let first_tn = item
|
||||
.thumbnail
|
||||
.music_thumbnail_renderer
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.first();
|
||||
|
||||
let id = some_or_bail!(
|
||||
item.playlist_item_data
|
||||
.map(|d| d.video_id)
|
||||
.or_else(|| first_tn.and_then(|tn| util::video_id_from_thumbnail_url(&tn.url))),
|
||||
None
|
||||
);
|
||||
|
||||
let is_video = !first_tn.map(|tn| tn.height == tn.width).unwrap_or_default();
|
||||
|
||||
let duration = item.fixed_columns.first().and_then(|col| {
|
||||
col.renderer
|
||||
.text
|
||||
.0
|
||||
.first()
|
||||
.and_then(|txt| util::parse_video_length(txt.as_str()))
|
||||
});
|
||||
|
||||
let mut columns = item.flex_columns;
|
||||
|
||||
let album = columns.try_swap_remove(2).and_then(|col| {
|
||||
col.renderer
|
||||
.text
|
||||
.0
|
||||
.into_iter()
|
||||
.find_map(|c| model::AlbumId::try_from(c).ok())
|
||||
});
|
||||
|
||||
let artists_col = columns.try_swap_remove(1);
|
||||
let artists_txt = artists_col
|
||||
.as_ref()
|
||||
.map(|col| col.renderer.text.to_string());
|
||||
let mut artists = artists_col
|
||||
.map(|col| {
|
||||
col.renderer
|
||||
.text
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|c| ChannelId::try_from(c).ok())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(a) = &self.artists {
|
||||
if artists.is_empty() {
|
||||
artists = a.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let title = columns
|
||||
.try_swap_remove(0)
|
||||
.map(|col| col.renderer.text.to_string());
|
||||
|
||||
match (title, duration) {
|
||||
(Some(title), Some(duration)) => {
|
||||
Some(model::YouTubeMusicItem::Track(model::TrackItem {
|
||||
id,
|
||||
title,
|
||||
duration,
|
||||
cover: item.thumbnail.into(),
|
||||
artists,
|
||||
artists_txt,
|
||||
album,
|
||||
view_count: None,
|
||||
is_video,
|
||||
}))
|
||||
}
|
||||
(None, _) => {
|
||||
self.warnings
|
||||
.push(format!("track {}: could not get title", id));
|
||||
None
|
||||
}
|
||||
(_, None) => {
|
||||
self.warnings
|
||||
.push(format!("track {}: could not parse duration", id));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
impl MusicListMapper<model::YouTubeMusicItem> {
|
||||
fn map_item(&mut self, item: MusicItem) {
|
||||
if let Some(mapped) = self.map_music_item(item) {
|
||||
self.items.push(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_response(&mut self, mut res: MapResult<Vec<MusicItem>>) {
|
||||
self.warnings.append(&mut res.warnings);
|
||||
res.c.into_iter().for_each(|item| self.map_item(item));
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
impl MusicListMapper<model::TrackItem> {
|
||||
fn map_item(&mut self, item: MusicItem) {
|
||||
if let Some(model::YouTubeMusicItem::Track(track)) = self.map_music_item(item) {
|
||||
self.items.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_response(&mut self, mut res: MapResult<Vec<MusicItem>>) {
|
||||
self.warnings.append(&mut res.warnings);
|
||||
res.c.into_iter().for_each(|item| self.map_item(item));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,20 +2,27 @@ 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,
|
||||
use crate::serializer::{
|
||||
text::{Text, TextComponents},
|
||||
MapResult, VecLogError,
|
||||
};
|
||||
|
||||
use super::music_item::{MusicContentsRenderer, MusicItem, MusicThumbnailRenderer};
|
||||
use super::{ContentRenderer, ContentsRenderer, MusicContinuation};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PlaylistMusic {
|
||||
pub(crate) struct MusicPlaylist {
|
||||
pub contents: Contents,
|
||||
pub header: Header,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicPlaylistCont {
|
||||
pub continuation_contents: ContinuationContents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Contents {
|
||||
|
|
@ -48,17 +55,12 @@ pub(crate) struct ItemSection {
|
|||
pub(crate) struct MusicShelf {
|
||||
/// Playlist ID (only for playlists)
|
||||
pub playlist_id: Option<String>,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub contents: Vec<PlaylistMusicItem>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub contents: MapResult<Vec<MusicItem>>,
|
||||
/// Continuation token for fetching more (>100) playlist items
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub continuations: Option<Vec<MusicContinuation>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PlaylistMusicItem {
|
||||
pub music_responsive_list_item_renderer: MusicItem,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub continuations: Vec<MusicContinuation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -71,7 +73,7 @@ pub(crate) struct Header {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct HeaderRenderer {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
/// Content type + Channel/Artist + Year.
|
||||
/// Missing on artist_tracks view.
|
||||
|
|
@ -79,17 +81,27 @@ pub(crate) struct HeaderRenderer {
|
|||
/// `"Playlist", " • ", <"Best Music">, " • ", "2022"`
|
||||
///
|
||||
/// `"Album", " • ", <"Helene Fischer">, " • ", "2021"`
|
||||
pub subtitle: Option<Text>,
|
||||
#[serde(default)]
|
||||
pub subtitle: TextComponents,
|
||||
/// Playlist description. May contain hashtags which are
|
||||
/// displayed as search links on the YouTube website.
|
||||
#[serde_as(as = "Option<crate::serializer::text::Text>")]
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
pub description: Option<String>,
|
||||
/// Playlist thumbnail / album cover.
|
||||
/// Missing on artist_tracks view.
|
||||
pub thumbnail: Option<MusicThumbnailRenderer>,
|
||||
#[serde(default)]
|
||||
pub thumbnail: MusicThumbnailRenderer,
|
||||
/// Number of tracks + playtime.
|
||||
/// Missing on artist_tracks view.
|
||||
///
|
||||
/// `"64 songs", " • ", "3 hours, 40 minutes"`
|
||||
pub second_subtitle: Option<Text>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "Text")]
|
||||
pub second_subtitle: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContinuationContents {
|
||||
pub music_playlist_shelf_continuation: MusicShelf,
|
||||
}
|
||||
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
File diff suppressed because it is too large
Load diff
Reference in a new issue