feat: add music charts
This commit is contained in:
parent
e063c04821
commit
f20ea693a6
19 changed files with 128651 additions and 42 deletions
|
|
@ -4,6 +4,7 @@ pub(crate) mod response;
|
|||
|
||||
mod channel;
|
||||
mod music_artist;
|
||||
mod music_charts;
|
||||
mod music_details;
|
||||
mod music_new;
|
||||
mod music_playlist;
|
||||
|
|
|
|||
|
|
@ -192,9 +192,9 @@ fn map_artist_page(
|
|||
|
||||
mapper.map_response(shelf.contents);
|
||||
}
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer { header, contents } => {
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
let mut extendable_albums = false;
|
||||
if let Some(h) = header {
|
||||
if let Some(h) = shelf.header {
|
||||
if let Some(button) = h
|
||||
.music_carousel_shelf_basic_header_renderer
|
||||
.more_content_button
|
||||
|
|
@ -221,7 +221,7 @@ fn map_artist_page(
|
|||
}
|
||||
|
||||
if !skip_extendables || !extendable_albums {
|
||||
mapper.map_response(contents);
|
||||
mapper.map_response(shelf.contents);
|
||||
}
|
||||
}
|
||||
response::music_item::ItemSection::None => {}
|
||||
|
|
|
|||
171
src/client/music_charts.rs
Normal file
171
src/client/music_charts.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
error::{Error, ExtractionError},
|
||||
model::{MusicCharts, TrackItem},
|
||||
serializer::MapResult,
|
||||
};
|
||||
|
||||
use super::{
|
||||
response::{self, music_item::MusicListMapper, url_endpoint::MusicPageType},
|
||||
ClientType, MapResponse, RustyPipeQuery, YTContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QCharts<'a> {
|
||||
context: YTContext<'a>,
|
||||
browse_id: &'a str,
|
||||
params: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
form_data: Option<FormData<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FormData<'a> {
|
||||
pub selected_values: [&'a str; 1],
|
||||
}
|
||||
|
||||
impl RustyPipeQuery {
|
||||
pub async fn music_charts(&self, country: Option<&str>) -> Result<MusicCharts, Error> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QCharts {
|
||||
context,
|
||||
browse_id: "FEmusic_charts",
|
||||
params: "sgYPRkVtdXNpY19leHBsb3Jl",
|
||||
form_data: country.map(|c| FormData {
|
||||
selected_values: [c],
|
||||
}),
|
||||
};
|
||||
|
||||
self.execute_request::<response::MusicCharts, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_charts",
|
||||
"",
|
||||
"browse",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<MusicCharts> for response::MusicCharts {
|
||||
fn map_response(
|
||||
self,
|
||||
_id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<crate::serializer::MapResult<MusicCharts>, crate::error::ExtractionError> {
|
||||
let countries = self
|
||||
.framework_updates
|
||||
.map(|fwu| {
|
||||
fwu.entity_batch_update
|
||||
.mutations
|
||||
.into_iter()
|
||||
.map(|x| x.payload.music_form_boolean_choice.opaque_token)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut top_playlist_id = None;
|
||||
let mut trending_playlist_id = None;
|
||||
|
||||
let mut mapper_top = MusicListMapper::new(lang);
|
||||
let mut mapper_trending = MusicListMapper::new(lang);
|
||||
let mut mapper_other = MusicListMapper::new(lang);
|
||||
|
||||
self.contents
|
||||
.single_column_browse_results_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.for_each(|s| match s {
|
||||
response::music_charts::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
match shelf.header.and_then(|h| {
|
||||
h.music_carousel_shelf_basic_header_renderer
|
||||
.more_content_button
|
||||
.and_then(|btn| btn.button_renderer.navigation_endpoint.music_page())
|
||||
}) {
|
||||
Some((MusicPageType::Playlist, id)) => {
|
||||
// Top music videos (first shelf with associated playlist)
|
||||
if top_playlist_id.is_none() {
|
||||
mapper_top.map_response(shelf.contents);
|
||||
top_playlist_id = Some(id);
|
||||
}
|
||||
// Trending (second shelf with associated playlist)
|
||||
else if trending_playlist_id.is_none() {
|
||||
mapper_trending.map_response(shelf.contents);
|
||||
trending_playlist_id = Some(id);
|
||||
}
|
||||
}
|
||||
// Other sections (artists, playlists)
|
||||
_ => {
|
||||
mapper_other.map_response(shelf.contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
response::music_charts::ItemSection::None => {}
|
||||
});
|
||||
|
||||
let mapped_top = mapper_top.conv_items::<TrackItem>();
|
||||
let mut mapped_trending = mapper_trending.conv_items::<TrackItem>();
|
||||
let mut mapped_other = mapper_other.group_items();
|
||||
|
||||
let mut warnings = mapped_top.warnings;
|
||||
warnings.append(&mut mapped_trending.warnings);
|
||||
warnings.append(&mut mapped_other.warnings);
|
||||
|
||||
Ok(MapResult {
|
||||
c: MusicCharts {
|
||||
top_tracks: mapped_top.c,
|
||||
trending_tracks: mapped_trending.c,
|
||||
artists: mapped_other.c.artists,
|
||||
playlists: mapped_other.c.playlists,
|
||||
top_playlist_id,
|
||||
trending_playlist_id,
|
||||
available_countries: countries,
|
||||
},
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::param::Language;
|
||||
|
||||
#[rstest]
|
||||
#[case::default("default")]
|
||||
#[case::us("US")]
|
||||
#[case::unavailable("unavailable")]
|
||||
fn map_music_charts(#[case] name: &str) {
|
||||
let filename = format!("testfiles/music_charts/charts_{}.json", name);
|
||||
let json_path = Path::new(&filename);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let charts: response::MusicCharts =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicCharts> = charts.map_response("", Language::En, None).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
insta::assert_ron_snapshot!(format!("map_music_charts_{}", name), map_res.c);
|
||||
}
|
||||
}
|
||||
|
|
@ -315,22 +315,22 @@ impl MapResponse<MusicRelated> for response::MusicRelated {
|
|||
.iter()
|
||||
.find_map(|section| match section {
|
||||
response::music_item::ItemSection::MusicShelfRenderer(_) => None,
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer {
|
||||
header, ..
|
||||
} => header.as_ref().and_then(|h| {
|
||||
h.music_carousel_shelf_basic_header_renderer
|
||||
.title
|
||||
.0
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
let artist = ArtistId::from(c.clone());
|
||||
if artist.id.is_some() {
|
||||
Some(artist)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
shelf.header.as_ref().and_then(|h| {
|
||||
h.music_carousel_shelf_basic_header_renderer
|
||||
.title
|
||||
.0
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
let artist = ArtistId::from(c.clone());
|
||||
if artist.id.is_some() {
|
||||
Some(artist)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
response::music_item::ItemSection::None => None,
|
||||
});
|
||||
|
||||
|
|
@ -341,20 +341,18 @@ impl MapResponse<MusicRelated> for response::MusicRelated {
|
|||
};
|
||||
|
||||
let mut sections = self.contents.section_list_renderer.contents.into_iter();
|
||||
if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer {
|
||||
contents,
|
||||
..
|
||||
}) = sections.next()
|
||||
if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf)) =
|
||||
sections.next()
|
||||
{
|
||||
mapper_tracks.map_response(contents);
|
||||
mapper_tracks.map_response(shelf.contents);
|
||||
}
|
||||
|
||||
sections.for_each(|section| match section {
|
||||
response::music_item::ItemSection::MusicShelfRenderer(shelf) => {
|
||||
mapper.map_response(shelf.contents);
|
||||
}
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer { contents, .. } => {
|
||||
mapper.map_response(contents);
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
mapper.map_response(shelf.contents);
|
||||
}
|
||||
response::music_item::ItemSection::None => {}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -264,9 +264,9 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
|
|||
for section in sections {
|
||||
match section {
|
||||
response::music_item::ItemSection::MusicShelfRenderer(sh) => shelf = Some(sh),
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer {
|
||||
contents, ..
|
||||
} => album_variants = Some(contents),
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(sh) => {
|
||||
album_variants = Some(sh.contents)
|
||||
}
|
||||
response::music_item::ItemSection::None => (),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,11 +143,8 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
|||
mapper.map_response(shelf.contents);
|
||||
continuations.append(&mut shelf.continuations);
|
||||
}
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer {
|
||||
contents,
|
||||
..
|
||||
} => {
|
||||
mapper.map_response(contents);
|
||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||
mapper.map_response(shelf.contents);
|
||||
}
|
||||
response::music_item::ItemSection::None => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub(crate) mod channel;
|
||||
pub(crate) mod music_artist;
|
||||
pub(crate) mod music_charts;
|
||||
pub(crate) mod music_details;
|
||||
pub(crate) mod music_item;
|
||||
pub(crate) mod music_new;
|
||||
|
|
@ -16,6 +17,7 @@ pub(crate) mod video_item;
|
|||
pub(crate) use channel::Channel;
|
||||
pub(crate) use music_artist::MusicArtist;
|
||||
pub(crate) use music_artist::MusicArtistAlbums;
|
||||
pub(crate) use music_charts::MusicCharts;
|
||||
pub(crate) use music_details::MusicDetails;
|
||||
pub(crate) use music_details::MusicLyrics;
|
||||
pub(crate) use music_details::MusicRelated;
|
||||
|
|
|
|||
60
src/client/response/music_charts.rs
Normal file
60
src/client/response/music_charts.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{rust::deserialize_ignore_any, serde_as, VecSkipError};
|
||||
|
||||
use crate::param::Country;
|
||||
|
||||
use super::{music_item::MusicCarouselShelf, ContentsRenderer, SectionList, Tab};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicCharts {
|
||||
pub contents: Contents,
|
||||
pub framework_updates: Option<FrameworkUpdates>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Contents {
|
||||
pub single_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<ItemSection>>>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum ItemSection {
|
||||
MusicCarouselShelfRenderer(Box<MusicCarouselShelf>),
|
||||
#[serde(other, deserialize_with = "deserialize_ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FrameworkUpdates {
|
||||
pub entity_batch_update: EntityBatchUpdate,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EntityBatchUpdate {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub mutations: Vec<CountryOptionMutation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CountryOptionMutation {
|
||||
pub payload: CountryOptionPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CountryOptionPayload {
|
||||
pub music_form_boolean_choice: CountryOption,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CountryOption {
|
||||
pub opaque_token: Country,
|
||||
}
|
||||
|
|
@ -25,11 +25,7 @@ use super::{
|
|||
pub(crate) enum ItemSection {
|
||||
#[serde(alias = "musicPlaylistShelfRenderer")]
|
||||
MusicShelfRenderer(MusicShelf),
|
||||
MusicCarouselShelfRenderer {
|
||||
header: Option<MusicCarouselShelfHeader>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
contents: MapResult<Vec<MusicResponseItem>>,
|
||||
},
|
||||
MusicCarouselShelfRenderer(MusicCarouselShelf),
|
||||
#[serde(other, deserialize_with = "deserialize_ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
|
@ -52,6 +48,15 @@ pub(crate) struct MusicShelf {
|
|||
pub bottom_endpoint: Option<BrowseEndpointWrap>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicCarouselShelf {
|
||||
pub header: Option<MusicCarouselShelfHeader>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub contents: MapResult<Vec<MusicResponseItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum MusicResponseItem {
|
||||
|
|
|
|||
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