feat: add search item mapping (WIP)
This commit is contained in:
parent
44da9c7cc5
commit
3ad8f9b178
15 changed files with 14502 additions and 115 deletions
|
|
@ -39,6 +39,7 @@ pub async fn download_testfiles(project_root: &Path) {
|
|||
music_playlist(&testfiles).await;
|
||||
music_playlist_cont(&testfiles).await;
|
||||
music_album(&testfiles).await;
|
||||
music_search(&testfiles).await;
|
||||
}
|
||||
|
||||
const CLIENT_TYPES: [ClientType; 5] = [
|
||||
|
|
@ -517,3 +518,17 @@ async fn music_album(testfiles: &Path) {
|
|||
rp.query().music_album(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn music_search(testfiles: &Path) {
|
||||
for (name, query) in [("default", "black mamba"), ("typo", "liblingsmensch")] {
|
||||
let mut json_path = testfiles.to_path_buf();
|
||||
json_path.push("music_search");
|
||||
json_path.push(format!("{}.json", name));
|
||||
if json_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query().music_search(query).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub(crate) mod response;
|
|||
|
||||
mod channel;
|
||||
mod music_playlist;
|
||||
mod music_search;
|
||||
mod pagination;
|
||||
mod player;
|
||||
mod playlist;
|
||||
|
|
|
|||
|
|
@ -339,8 +339,6 @@ mod tests {
|
|||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
insta::assert_ron_snapshot!(format!("map_music_album_{}", name), map_res.c, {
|
||||
".last_update" => "[date]"
|
||||
});
|
||||
insta::assert_ron_snapshot!(format!("map_music_album_{}", name), map_res.c);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
src/client/music_search.rs
Normal file
144
src/client/music_search.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
client::response::music_item::MusicListMapper,
|
||||
error::{Error, ExtractionError},
|
||||
model::MusicSearchResult,
|
||||
serializer::MapResult,
|
||||
util::TryRemove,
|
||||
};
|
||||
|
||||
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QSearch<'a> {
|
||||
context: YTContext<'a>,
|
||||
query: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<Params>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
enum Params {
|
||||
#[serde(rename = "EgWKAQIIAWoMEAMQBBAJEA4QChAF")]
|
||||
Tracks,
|
||||
#[serde(rename = "EgWKAQIQAWoMEAMQBBAJEA4QChAF")]
|
||||
Videos,
|
||||
#[serde(rename = "EgWKAQIYAWoMEAMQBBAJEA4QChAF")]
|
||||
Albums,
|
||||
#[serde(rename = "EgWKAQIgAWoMEAMQBBAJEA4QChAF")]
|
||||
Artists,
|
||||
#[serde(rename = "EgeKAQQoADgBagwQAxAEEAkQDhAKEAU%3D")]
|
||||
FeaturedPlaylists,
|
||||
#[serde(rename = "EgeKAQQoAEABagwQAxAEEAkQDhAKEAU%3D")]
|
||||
CommunityPlaylists,
|
||||
}
|
||||
|
||||
impl RustyPipeQuery {
|
||||
pub async fn music_search(&self, query: &str) -> Result<MusicSearchResult, Error> {
|
||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
||||
let request_body = QSearch {
|
||||
context,
|
||||
query,
|
||||
params: None,
|
||||
};
|
||||
|
||||
self.execute_request::<response::MusicSearch, _, _>(
|
||||
ClientType::DesktopMusic,
|
||||
"music_search",
|
||||
query,
|
||||
"search",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<MusicSearchResult> for response::MusicSearch {
|
||||
fn map_response(
|
||||
self,
|
||||
_id: &str,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<MapResult<MusicSearchResult>, crate::error::ExtractionError> {
|
||||
dbg!(&self);
|
||||
|
||||
let mut tabs = self.contents.tabbed_search_results_renderer.contents;
|
||||
let sections = tabs
|
||||
.try_swap_remove(0)
|
||||
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no tab")))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents;
|
||||
|
||||
let mut corrected_query = None;
|
||||
// let mut ctoken = None;
|
||||
let mut mapper = MusicListMapper::new(lang);
|
||||
|
||||
sections.into_iter().for_each(|section| match section {
|
||||
response::music_search::ItemSection::MusicShelfRenderer(shelf) => {
|
||||
mapper.map_response(shelf.contents);
|
||||
// if let Some(cont) = shelf.continuations.try_swap_remove(0) {
|
||||
// ctoken = Some(cont.next_continuation_data.continuation);
|
||||
// }
|
||||
}
|
||||
response::music_search::ItemSection::ItemSectionRenderer { mut contents } => {
|
||||
if let Some(corrected) = contents.try_swap_remove(0) {
|
||||
corrected_query = Some(corrected.showing_results_for_renderer.corrected_query)
|
||||
}
|
||||
}
|
||||
response::music_search::ItemSection::None => {}
|
||||
});
|
||||
|
||||
Ok(MapResult {
|
||||
c: MusicSearchResult {
|
||||
tracks: mapper.tracks,
|
||||
albums: mapper.albums,
|
||||
artists: mapper.artists,
|
||||
playlists: mapper.playlists,
|
||||
corrected_query,
|
||||
},
|
||||
warnings: mapper.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
||||
use crate::{
|
||||
client::{response, MapResponse},
|
||||
model::MusicSearchResult,
|
||||
param::Language,
|
||||
serializer::MapResult,
|
||||
};
|
||||
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::default("default")]
|
||||
#[case::typo("typo")]
|
||||
fn map_music_search(#[case] name: &str) {
|
||||
let filename = format!("testfiles/music_search/{}.json", name);
|
||||
let json_path = Path::new(&filename);
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let search: response::MusicSearch =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<MusicSearchResult> =
|
||||
search.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_search_{}", name), map_res.c);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
pub(crate) mod channel;
|
||||
pub(crate) mod music_item;
|
||||
pub(crate) mod music_playlist;
|
||||
pub(crate) mod music_search;
|
||||
pub(crate) mod player;
|
||||
pub(crate) mod playlist;
|
||||
pub(crate) mod search;
|
||||
|
|
@ -12,6 +13,7 @@ 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 music_search::MusicSearch;
|
||||
pub(crate) use player::Player;
|
||||
pub(crate) use playlist::Playlist;
|
||||
pub(crate) use playlist::PlaylistCont;
|
||||
|
|
@ -49,6 +51,12 @@ pub(crate) struct ContentsRenderer<T> {
|
|||
pub contents: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Tab<T> {
|
||||
pub tab_renderer: ContentRenderer<T>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ThumbnailsWrap {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,34 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, DefaultOnError};
|
||||
use serde_with::{serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::{
|
||||
model::{self, AlbumItem, AlbumType, ArtistItem, ChannelId, MusicPlaylistItem, TrackItem},
|
||||
param::Language,
|
||||
serializer::{
|
||||
text::{Text, TextComponents},
|
||||
MapResult,
|
||||
MapResult, VecLogError,
|
||||
},
|
||||
util::{self, TryRemove},
|
||||
util,
|
||||
};
|
||||
|
||||
use super::{url_endpoint::NavigationEndpoint, ThumbnailsWrap};
|
||||
use super::{
|
||||
url_endpoint::{NavigationEndpoint, PageType},
|
||||
MusicContinuation, ThumbnailsWrap,
|
||||
};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicShelf {
|
||||
/// Playlist ID (only for playlists)
|
||||
pub playlist_id: Option<String>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub contents: MapResult<Vec<MusicItem>>,
|
||||
/// Continuation token for fetching more (>100) playlist items
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub continuations: Vec<MusicContinuation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -29,8 +46,43 @@ pub(crate) struct ListMusicItem {
|
|||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub playlist_item_data: Option<PlaylistItemData>,
|
||||
/// `[<"Das Beste">], [<"Silbermond">], [<"Laut Gedacht (Re-Edition)">]`
|
||||
/// Playlist track (title, artist, album)
|
||||
///
|
||||
/// `[<"Der Himmel reißt auf">]` Album track (title)
|
||||
///
|
||||
/// `[<"Girls">], ["Song", " • ", <"aespa">, " • ", <"Girls - The 2nd Mini Album">, " • ", "4:01"]`
|
||||
/// Search track (title, artist, album, duration)
|
||||
///
|
||||
/// `[<"Black Mamba">], ["Video", " • ", <"aespa">, " • ", "235M views", " • ", "3:50"]`
|
||||
/// Search video (title, artist, view count, duration)
|
||||
///
|
||||
/// `["Next Level"], ["Single", " • ", <"aespa">, " • ", "2021"]`
|
||||
/// Search album (title, type, artist, year)
|
||||
///
|
||||
/// `["Test Shot Starfish"], ["Artist", " • ", "1660 subscribers"]` Search artist
|
||||
///
|
||||
/// `["aespa - All Songs & MV"], ["Playlist", " • ", <"Jerwen">, " • ", "49 songs"]`
|
||||
/// Search playlist (title, creator, track count)
|
||||
pub flex_columns: Vec<MusicColumn>,
|
||||
/// Track duration (playlist/album tracks)
|
||||
///
|
||||
/// `"3:32"`
|
||||
#[serde(default)]
|
||||
pub fixed_columns: Vec<MusicColumn>,
|
||||
/// Content type + ID (for non-track search items)
|
||||
pub navigation_endpoint: Option<NavigationEndpoint>,
|
||||
#[serde(default)]
|
||||
pub flex_column_display_style: FlexColumnDisplayStyle,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Deserialize)]
|
||||
pub(crate) enum FlexColumnDisplayStyle {
|
||||
#[serde(rename = "MUSIC_RESPONSIVE_LIST_ITEM_FLEX_COLUMN_DISPLAY_STYLE_TWO_LINE_STACK")]
|
||||
TwoLines,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Default,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
|
@ -157,67 +209,158 @@ impl MusicListMapper {
|
|||
fn map_item(&mut self, item: MusicItem) -> Result<(), String> {
|
||||
match item {
|
||||
MusicItem::MusicResponsiveListItemRenderer(item) => {
|
||||
let first_tn = item
|
||||
.thumbnail
|
||||
.music_thumbnail_renderer
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.first();
|
||||
let mut columns = item.flex_columns.into_iter();
|
||||
let title = columns.next().map(|col| col.renderer.text.to_string());
|
||||
let c2 = columns.next();
|
||||
let c3 = columns.next();
|
||||
|
||||
let id = item
|
||||
.playlist_item_data
|
||||
.map(|d| d.video_id)
|
||||
.or_else(|| first_tn.and_then(|tn| util::video_id_from_thumbnail_url(&tn.url)))
|
||||
.ok_or_else(|| "no video id".to_owned())?;
|
||||
match item.navigation_endpoint {
|
||||
// Artist / Album / Playlist
|
||||
Some(ne) => {
|
||||
let (page_type, id) = ne
|
||||
.music_page()
|
||||
.ok_or_else(|| "could not get navigation endpoint".to_owned())?;
|
||||
|
||||
let is_video = !first_tn.map(|tn| tn.height == tn.width).unwrap_or_default();
|
||||
let title =
|
||||
title.ok_or_else(|| format!("track {}: could not get title", id))?;
|
||||
|
||||
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 mut artists_txt = artists_col
|
||||
.as_ref()
|
||||
.and_then(|col| col.renderer.text.to_opt_string());
|
||||
let mut artists = artists_col
|
||||
.map(|col| {
|
||||
col.renderer
|
||||
let mut subtitle_parts = c2
|
||||
.ok_or_else(|| format!("track {}: could not get subtitle", id))?
|
||||
.renderer
|
||||
.text
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|c| ChannelId::try_from(c).ok())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(a) = &self.o_artists {
|
||||
if artists.is_empty() && artists_txt.is_none() {
|
||||
let xa = a.clone();
|
||||
artists = xa.0;
|
||||
artists_txt = Some(xa.1);
|
||||
.split(util::DOT_SEPARATOR)
|
||||
.into_iter();
|
||||
let subtitle_p1 = subtitle_parts.next();
|
||||
let subtitle_p2 = subtitle_parts.next();
|
||||
let subtitle_p3 = subtitle_parts.next();
|
||||
|
||||
match page_type {
|
||||
PageType::Artist => {
|
||||
let subscriber_count = subtitle_p2.and_then(|p| {
|
||||
util::parse_large_numstr(&p.to_string(), self.lang)
|
||||
});
|
||||
|
||||
self.artists.push(ArtistItem {
|
||||
id,
|
||||
name: title,
|
||||
avatar: item.thumbnail.into(),
|
||||
subscriber_count,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
PageType::Album => {
|
||||
let album_type = subtitle_p1
|
||||
.map(|st| map_album_type(&st.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let (artists, artists_txt) = map_artists(subtitle_p2);
|
||||
|
||||
let year = subtitle_p3
|
||||
.and_then(|st| util::parse_numeric(&st.to_string()).ok());
|
||||
|
||||
self.albums.push(AlbumItem {
|
||||
id,
|
||||
name: title,
|
||||
cover: item.thumbnail.into(),
|
||||
artists,
|
||||
artists_txt,
|
||||
album_type,
|
||||
year,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
PageType::Playlist => {
|
||||
let from_ytm = subtitle_p2
|
||||
.as_ref()
|
||||
.and_then(|p| {
|
||||
p.0.first().map(|txt| txt.as_str() == util::YT_MUSIC_NAME)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let channel = subtitle_p2.and_then(|p| {
|
||||
p.0.into_iter().find_map(|c| ChannelId::try_from(c).ok())
|
||||
});
|
||||
let track_count = subtitle_p3
|
||||
.and_then(|p| util::parse_numeric(&p.to_string()).ok());
|
||||
|
||||
self.playlists.push(MusicPlaylistItem {
|
||||
id,
|
||||
name: title,
|
||||
thumbnail: item.thumbnail.into(),
|
||||
channel,
|
||||
track_count,
|
||||
from_ytm,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
PageType::Channel => {
|
||||
Err(format!("channel items unsupported. id: {}", id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Track
|
||||
None => {
|
||||
let first_tn = item
|
||||
.thumbnail
|
||||
.music_thumbnail_renderer
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.first();
|
||||
|
||||
let title = columns
|
||||
.try_swap_remove(0)
|
||||
.map(|col| col.renderer.text.to_string());
|
||||
let id = item
|
||||
.playlist_item_data
|
||||
.map(|d| d.video_id)
|
||||
.or_else(|| {
|
||||
first_tn.and_then(|tn| util::video_id_from_thumbnail_url(&tn.url))
|
||||
})
|
||||
.ok_or_else(|| "no video id".to_owned())?;
|
||||
|
||||
let title =
|
||||
title.ok_or_else(|| format!("track {}: could not get title", id))?;
|
||||
|
||||
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()))
|
||||
})
|
||||
.ok_or_else(|| format!("track {}: could not parse duration", id))?;
|
||||
|
||||
let album = c3.and_then(|col| {
|
||||
col.renderer
|
||||
.text
|
||||
.0
|
||||
.into_iter()
|
||||
.find_map(|c| model::AlbumId::try_from(c).ok())
|
||||
});
|
||||
|
||||
let mut artists_txt = c2
|
||||
.as_ref()
|
||||
.and_then(|col| col.renderer.text.to_opt_string());
|
||||
let mut artists = c2
|
||||
.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.o_artists {
|
||||
if artists.is_empty() && artists_txt.is_none() {
|
||||
let xa = a.clone();
|
||||
artists = xa.0;
|
||||
artists_txt = Some(xa.1);
|
||||
}
|
||||
}
|
||||
|
||||
match (title, duration) {
|
||||
(Some(title), Some(duration)) => {
|
||||
self.tracks.push(TrackItem {
|
||||
id,
|
||||
title,
|
||||
|
|
@ -231,8 +374,6 @@ impl MusicListMapper {
|
|||
});
|
||||
Ok(())
|
||||
}
|
||||
(None, _) => Err(format!("track {}: could not get title", id)),
|
||||
(_, None) => Err(format!("track {}: could not parse duration", id)),
|
||||
}
|
||||
}
|
||||
MusicItem::MusicTwoRowItemRenderer(item) => {
|
||||
|
|
@ -241,13 +382,13 @@ impl MusicListMapper {
|
|||
let subtitle_p2 = subtitle_parts.next();
|
||||
let subtitle_p3 = subtitle_parts.next();
|
||||
|
||||
let (page_type, browse_id) = item
|
||||
let (page_type, id) = item
|
||||
.navigation_endpoint
|
||||
.music_page()
|
||||
.ok_or_else(|| "could not get navigation endpoint".to_owned())?;
|
||||
|
||||
match page_type {
|
||||
super::url_endpoint::PageType::Album => {
|
||||
PageType::Album => {
|
||||
let mut year = None;
|
||||
let mut album_type = AlbumType::Single;
|
||||
|
||||
|
|
@ -277,13 +418,13 @@ impl MusicListMapper {
|
|||
_ => {
|
||||
return Err(format!(
|
||||
"could not parse subtitle of album {}",
|
||||
browse_id
|
||||
id
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
self.albums.push(AlbumItem {
|
||||
id: browse_id,
|
||||
id,
|
||||
name: item.title,
|
||||
cover: item.thumbnail_renderer.into(),
|
||||
artists,
|
||||
|
|
@ -293,7 +434,7 @@ impl MusicListMapper {
|
|||
});
|
||||
Ok(())
|
||||
}
|
||||
super::url_endpoint::PageType::Playlist => {
|
||||
PageType::Playlist => {
|
||||
// TODO: make component to string zero-copy if len=1
|
||||
let from_ytm = subtitle_p2
|
||||
.as_ref()
|
||||
|
|
@ -304,33 +445,32 @@ impl MusicListMapper {
|
|||
let channel = subtitle_p2.and_then(|p| {
|
||||
p.0.into_iter().find_map(|c| ChannelId::try_from(c).ok())
|
||||
});
|
||||
let track_count =
|
||||
subtitle_p3.and_then(|p| util::parse_numeric(&p.to_string()).ok());
|
||||
|
||||
self.playlists.push(MusicPlaylistItem {
|
||||
id: browse_id,
|
||||
id,
|
||||
name: item.title,
|
||||
thumbnail: item.thumbnail_renderer.into(),
|
||||
channel,
|
||||
track_count: subtitle_p3
|
||||
.and_then(|p| util::parse_numeric(&p.to_string()).ok()),
|
||||
track_count,
|
||||
from_ytm,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
super::url_endpoint::PageType::Artist => {
|
||||
PageType::Artist => {
|
||||
let subscriber_count = subtitle_p1
|
||||
.and_then(|p| util::parse_large_numstr(&p.to_string(), self.lang));
|
||||
|
||||
self.artists.push(ArtistItem {
|
||||
id: browse_id,
|
||||
id,
|
||||
name: item.title,
|
||||
avatar: item.thumbnail_renderer.into(),
|
||||
subscriber_count,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
super::url_endpoint::PageType::Channel => {
|
||||
Err(format!("channel items unsupported. id: {}", browse_id))
|
||||
}
|
||||
PageType::Channel => Err(format!("channel items unsupported. id: {}", id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ use crate::serializer::{
|
|||
MapResult, VecLogError,
|
||||
};
|
||||
|
||||
use super::music_item::{MusicContentsRenderer, MusicItem, MusicThumbnailRenderer};
|
||||
use super::{ContentRenderer, ContentsRenderer, MusicContinuation};
|
||||
use super::music_item::{MusicContentsRenderer, MusicItem, MusicShelf, MusicThumbnailRenderer};
|
||||
use super::{ContentsRenderer, Tab};
|
||||
|
||||
/// Response model for YouTube Music playlists and albums
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -27,13 +27,7 @@ pub(crate) struct MusicPlaylistCont {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Contents {
|
||||
pub single_column_browse_results_renderer: ContentsRenderer<Tab>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Tab {
|
||||
pub tab_renderer: ContentRenderer<SectionList>,
|
||||
pub single_column_browse_results_renderer: ContentsRenderer<Tab<SectionList>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -57,20 +51,6 @@ pub(crate) enum ItemSection {
|
|||
None,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicShelf {
|
||||
/// Playlist ID (only for playlists)
|
||||
pub playlist_id: Option<String>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub contents: MapResult<Vec<MusicItem>>,
|
||||
/// Continuation token for fetching more (>100) playlist items
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub continuations: Vec<MusicContinuation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Header {
|
||||
|
|
|
|||
52
src/client/response/music_search.rs
Normal file
52
src/client/response/music_search.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, VecSkipError};
|
||||
|
||||
use crate::serializer::{ignore_any, text::Text};
|
||||
|
||||
use super::{music_item::MusicShelf, ContentsRenderer, Tab};
|
||||
|
||||
/// Response model for YouTube Music search
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicSearch {
|
||||
pub contents: Contents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Contents {
|
||||
pub tabbed_search_results_renderer: ContentsRenderer<Tab<SectionList>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SectionList {
|
||||
pub section_list_renderer: ContentsRenderer<ItemSection>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum ItemSection {
|
||||
MusicShelfRenderer(MusicShelf),
|
||||
ItemSectionRenderer {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
contents: Vec<ShowingResultsFor>,
|
||||
},
|
||||
#[serde(other, deserialize_with = "ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ShowingResultsFor {
|
||||
pub showing_results_for_renderer: ShowingResultsForRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ShowingResultsForRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub corrected_query: String,
|
||||
}
|
||||
|
|
@ -6,8 +6,7 @@ use crate::serializer::{ignore_any, MapResult, VecLogError};
|
|||
use crate::util::MappingError;
|
||||
|
||||
use super::{
|
||||
Alert, ContentRenderer, ContentsRenderer, ContinuationEndpoint, ResponseContext, Thumbnails,
|
||||
ThumbnailsWrap,
|
||||
Alert, ContentsRenderer, ContinuationEndpoint, ResponseContext, Tab, Thumbnails, ThumbnailsWrap,
|
||||
};
|
||||
|
||||
#[serde_as]
|
||||
|
|
@ -34,13 +33,7 @@ pub(crate) struct PlaylistCont {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Contents {
|
||||
pub two_column_browse_results_renderer: ContentsRenderer<Tab>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Tab {
|
||||
pub tab_renderer: ContentRenderer<SectionList>,
|
||||
pub two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, VecSkipError};
|
||||
|
||||
use super::{video_item::YouTubeListRendererWrap, ContentRenderer, ResponseContext};
|
||||
use super::{video_item::YouTubeListRendererWrap, ResponseContext, Tab};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -29,9 +29,3 @@ pub(crate) struct BrowseResults {
|
|||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub tabs: Vec<Tab<YouTubeListRendererWrap>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Tab<T> {
|
||||
pub tab_renderer: ContentRenderer<T>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
source: src/client/music_search.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
MusicSearchResult(
|
||||
tracks: [],
|
||||
albums: [],
|
||||
artists: [],
|
||||
playlists: [
|
||||
MusicPlaylistItem(
|
||||
id: "VLPLk76iSbFqNJsu_Gozn9SkEXxQ7t-bpXid",
|
||||
name: "IRMA MIRTILLA Black Mamba",
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/md19pon3B9o/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kR84wE4E_UufGzATfZhAsFWEieaA",
|
||||
width: 400,
|
||||
height: 225,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/md19pon3B9o/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3nxumiGKYWYiiTokZB8M6rwtK5mRw",
|
||||
width: 800,
|
||||
height: 450,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/md19pon3B9o/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mEU1yvpIHQXYgVnCyXx8Rlzilg6Q",
|
||||
width: 853,
|
||||
height: 480,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelId(
|
||||
id: "UCtZaFx5MXZHIh7VTItJK1lQ",
|
||||
name: "Lajos Fülöp",
|
||||
)),
|
||||
track_count: Some(29),
|
||||
from_ytm: false,
|
||||
),
|
||||
MusicPlaylistItem(
|
||||
id: "VLPLIL9Q2jz6euDEJZKHd4QaG4iic944_vKY",
|
||||
name: "Black Mamba",
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/jsvBK6isPIQ0ERSc1xV6PoaYxbYZqCzqr90lHZNEfUcQL2lP0oNzrdimX8KIBchE6X8myc58zwyS=s192",
|
||||
width: 192,
|
||||
height: 192,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/jsvBK6isPIQ0ERSc1xV6PoaYxbYZqCzqr90lHZNEfUcQL2lP0oNzrdimX8KIBchE6X8myc58zwyS=s576",
|
||||
width: 576,
|
||||
height: 576,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/jsvBK6isPIQ0ERSc1xV6PoaYxbYZqCzqr90lHZNEfUcQL2lP0oNzrdimX8KIBchE6X8myc58zwyS=s1200",
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelId(
|
||||
id: "UCwFT0vvkbtbohtzVbwx7WjQ",
|
||||
name: "Toshihiko KOMINAMI",
|
||||
)),
|
||||
track_count: Some(6),
|
||||
from_ytm: false,
|
||||
),
|
||||
MusicPlaylistItem(
|
||||
id: "VLPLinm7-cvTdN7RqadpfNrncUGqkdyKNpn6",
|
||||
name: "Black Mamba",
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/hj6EywHSUD3UEnRQPHaEjHPC1VRi9UcsrkW8zGiOaXhRGlyNikLw6Iv0VnHTSuo2MlVBiQaskqo=s192",
|
||||
width: 192,
|
||||
height: 192,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/hj6EywHSUD3UEnRQPHaEjHPC1VRi9UcsrkW8zGiOaXhRGlyNikLw6Iv0VnHTSuo2MlVBiQaskqo=s576",
|
||||
width: 576,
|
||||
height: 576,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/hj6EywHSUD3UEnRQPHaEjHPC1VRi9UcsrkW8zGiOaXhRGlyNikLw6Iv0VnHTSuo2MlVBiQaskqo=s1200",
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelId(
|
||||
id: "UCEdZAdnnKqbaHOlv8nM6OtA",
|
||||
name: "aespa",
|
||||
)),
|
||||
track_count: Some(39),
|
||||
from_ytm: false,
|
||||
),
|
||||
],
|
||||
corrected_query: None,
|
||||
)
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
source: src/client/music_search.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
MusicSearchResult(
|
||||
tracks: [],
|
||||
albums: [],
|
||||
artists: [
|
||||
ArtistItem(
|
||||
id: "UCIh4j8fXWf2U0ro0qnGU8Mg",
|
||||
name: "Namika",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://lh3.googleusercontent.com/iY5H0k9sMP6hevj7ttwx2WibgxmJ9OMoK9TuVHwUMvdA8ZrrJCdGYT_BG-HhgYcVDihVJMQqSKbOcpk=w60-h60-p-l90-rj",
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://lh3.googleusercontent.com/iY5H0k9sMP6hevj7ttwx2WibgxmJ9OMoK9TuVHwUMvdA8ZrrJCdGYT_BG-HhgYcVDihVJMQqSKbOcpk=w120-h120-p-l90-rj",
|
||||
width: 120,
|
||||
height: 120,
|
||||
),
|
||||
],
|
||||
subscriber_count: Some(737000),
|
||||
),
|
||||
ArtistItem(
|
||||
id: "UCCpID8TTjkkjLCwBybAfHSg",
|
||||
name: "Boris Brejcha",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://lh3.googleusercontent.com/2aw3EVIIy1zbuvkl0txoqPBGUjvkv056NUzc6Qdz5ZdmknsJr28AQig7HTy_q9xqYC4LjVsyffl-9shZ=w60-h60-p-l90-rj",
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://lh3.googleusercontent.com/2aw3EVIIy1zbuvkl0txoqPBGUjvkv056NUzc6Qdz5ZdmknsJr28AQig7HTy_q9xqYC4LjVsyffl-9shZ=w120-h120-p-l90-rj",
|
||||
width: 120,
|
||||
height: 120,
|
||||
),
|
||||
],
|
||||
subscriber_count: Some(988000),
|
||||
),
|
||||
ArtistItem(
|
||||
id: "UCZnutiGgJ2LrrwzDH_ElSDg",
|
||||
name: "Dendemann",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://lh3.googleusercontent.com/G_nI16FH_wiRKj1HAhmltOA-vTifD8UVwsNcJGKu40c6Y2A6Pg2S6o6f5EajkIZguv8JAt1mU9V66dw=w60-h60-p-l90-rj",
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://lh3.googleusercontent.com/G_nI16FH_wiRKj1HAhmltOA-vTifD8UVwsNcJGKu40c6Y2A6Pg2S6o6f5EajkIZguv8JAt1mU9V66dw=w120-h120-p-l90-rj",
|
||||
width: 120,
|
||||
height: 120,
|
||||
),
|
||||
],
|
||||
subscriber_count: Some(22700),
|
||||
),
|
||||
],
|
||||
playlists: [],
|
||||
corrected_query: Some("lieblingsmensch"),
|
||||
)
|
||||
|
|
@ -1023,3 +1023,18 @@ pub struct MusicAlbum {
|
|||
/// Album variants
|
||||
pub variants: Vec<AlbumItem>,
|
||||
}
|
||||
|
||||
/// YouTube Music search result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MusicSearchResult {
|
||||
pub tracks: Vec<TrackItem>,
|
||||
pub albums: Vec<AlbumItem>,
|
||||
pub artists: Vec<ArtistItem>,
|
||||
pub playlists: Vec<MusicPlaylistItem>,
|
||||
/// Corrected search query
|
||||
///
|
||||
/// If the search term containes a typo, YouTube instead searches
|
||||
/// for the corrected search term and displays it on top of the
|
||||
/// search results page.
|
||||
pub corrected_query: Option<String>,
|
||||
}
|
||||
|
|
|
|||
6902
testfiles/music_search/default.json
Normal file
6902
testfiles/music_search/default.json
Normal file
File diff suppressed because it is too large
Load diff
6989
testfiles/music_search/typo.json
Normal file
6989
testfiles/music_search/typo.json
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue