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(&testfiles).await;
|
||||||
music_playlist_cont(&testfiles).await;
|
music_playlist_cont(&testfiles).await;
|
||||||
music_album(&testfiles).await;
|
music_album(&testfiles).await;
|
||||||
|
music_search(&testfiles).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLIENT_TYPES: [ClientType; 5] = [
|
const CLIENT_TYPES: [ClientType; 5] = [
|
||||||
|
|
@ -517,3 +518,17 @@ async fn music_album(testfiles: &Path) {
|
||||||
rp.query().music_album(id).await.unwrap();
|
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 channel;
|
||||||
mod music_playlist;
|
mod music_playlist;
|
||||||
|
mod music_search;
|
||||||
mod pagination;
|
mod pagination;
|
||||||
mod player;
|
mod player;
|
||||||
mod playlist;
|
mod playlist;
|
||||||
|
|
|
||||||
|
|
@ -339,8 +339,6 @@ mod tests {
|
||||||
"deserialization/mapping warnings: {:?}",
|
"deserialization/mapping warnings: {:?}",
|
||||||
map_res.warnings
|
map_res.warnings
|
||||||
);
|
);
|
||||||
insta::assert_ron_snapshot!(format!("map_music_album_{}", name), map_res.c, {
|
insta::assert_ron_snapshot!(format!("map_music_album_{}", name), map_res.c);
|
||||||
".last_update" => "[date]"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 channel;
|
||||||
pub(crate) mod music_item;
|
pub(crate) mod music_item;
|
||||||
pub(crate) mod music_playlist;
|
pub(crate) mod music_playlist;
|
||||||
|
pub(crate) mod music_search;
|
||||||
pub(crate) mod player;
|
pub(crate) mod player;
|
||||||
pub(crate) mod playlist;
|
pub(crate) mod playlist;
|
||||||
pub(crate) mod search;
|
pub(crate) mod search;
|
||||||
|
|
@ -12,6 +13,7 @@ pub(crate) mod video_item;
|
||||||
pub(crate) use channel::Channel;
|
pub(crate) use channel::Channel;
|
||||||
pub(crate) use music_playlist::MusicPlaylist;
|
pub(crate) use music_playlist::MusicPlaylist;
|
||||||
pub(crate) use music_playlist::MusicPlaylistCont;
|
pub(crate) use music_playlist::MusicPlaylistCont;
|
||||||
|
pub(crate) use music_search::MusicSearch;
|
||||||
pub(crate) use player::Player;
|
pub(crate) use player::Player;
|
||||||
pub(crate) use playlist::Playlist;
|
pub(crate) use playlist::Playlist;
|
||||||
pub(crate) use playlist::PlaylistCont;
|
pub(crate) use playlist::PlaylistCont;
|
||||||
|
|
@ -49,6 +51,12 @@ pub(crate) struct ContentsRenderer<T> {
|
||||||
pub contents: Vec<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)]
|
#[derive(Default, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct ThumbnailsWrap {
|
pub(crate) struct ThumbnailsWrap {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,34 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::{serde_as, DefaultOnError};
|
use serde_with::{serde_as, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{self, AlbumItem, AlbumType, ArtistItem, ChannelId, MusicPlaylistItem, TrackItem},
|
model::{self, AlbumItem, AlbumType, ArtistItem, ChannelId, MusicPlaylistItem, TrackItem},
|
||||||
param::Language,
|
param::Language,
|
||||||
serializer::{
|
serializer::{
|
||||||
text::{Text, TextComponents},
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -29,8 +46,43 @@ pub(crate) struct ListMusicItem {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
pub playlist_item_data: Option<PlaylistItemData>,
|
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>,
|
pub flex_columns: Vec<MusicColumn>,
|
||||||
|
/// Track duration (playlist/album tracks)
|
||||||
|
///
|
||||||
|
/// `"3:32"`
|
||||||
|
#[serde(default)]
|
||||||
pub fixed_columns: Vec<MusicColumn>,
|
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]
|
#[serde_as]
|
||||||
|
|
@ -157,67 +209,158 @@ impl MusicListMapper {
|
||||||
fn map_item(&mut self, item: MusicItem) -> Result<(), String> {
|
fn map_item(&mut self, item: MusicItem) -> Result<(), String> {
|
||||||
match item {
|
match item {
|
||||||
MusicItem::MusicResponsiveListItemRenderer(item) => {
|
MusicItem::MusicResponsiveListItemRenderer(item) => {
|
||||||
let first_tn = item
|
let mut columns = item.flex_columns.into_iter();
|
||||||
.thumbnail
|
let title = columns.next().map(|col| col.renderer.text.to_string());
|
||||||
.music_thumbnail_renderer
|
let c2 = columns.next();
|
||||||
.thumbnail
|
let c3 = columns.next();
|
||||||
.thumbnails
|
|
||||||
.first();
|
|
||||||
|
|
||||||
let id = item
|
match item.navigation_endpoint {
|
||||||
.playlist_item_data
|
// Artist / Album / Playlist
|
||||||
.map(|d| d.video_id)
|
Some(ne) => {
|
||||||
.or_else(|| first_tn.and_then(|tn| util::video_id_from_thumbnail_url(&tn.url)))
|
let (page_type, id) = ne
|
||||||
.ok_or_else(|| "no video id".to_owned())?;
|
.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| {
|
let mut subtitle_parts = c2
|
||||||
col.renderer
|
.ok_or_else(|| format!("track {}: could not get subtitle", id))?
|
||||||
.text
|
.renderer
|
||||||
.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
|
|
||||||
.text
|
.text
|
||||||
.0
|
.split(util::DOT_SEPARATOR)
|
||||||
.into_iter()
|
.into_iter();
|
||||||
.filter_map(|c| ChannelId::try_from(c).ok())
|
let subtitle_p1 = subtitle_parts.next();
|
||||||
.collect::<Vec<_>>()
|
let subtitle_p2 = subtitle_parts.next();
|
||||||
})
|
let subtitle_p3 = subtitle_parts.next();
|
||||||
.unwrap_or_default();
|
|
||||||
if let Some(a) = &self.o_artists {
|
match page_type {
|
||||||
if artists.is_empty() && artists_txt.is_none() {
|
PageType::Artist => {
|
||||||
let xa = a.clone();
|
let subscriber_count = subtitle_p2.and_then(|p| {
|
||||||
artists = xa.0;
|
util::parse_large_numstr(&p.to_string(), self.lang)
|
||||||
artists_txt = Some(xa.1);
|
});
|
||||||
|
|
||||||
|
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
|
let id = item
|
||||||
.try_swap_remove(0)
|
.playlist_item_data
|
||||||
.map(|col| col.renderer.text.to_string());
|
.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 {
|
self.tracks.push(TrackItem {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
|
@ -231,8 +374,6 @@ impl MusicListMapper {
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
(None, _) => Err(format!("track {}: could not get title", id)),
|
|
||||||
(_, None) => Err(format!("track {}: could not parse duration", id)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MusicItem::MusicTwoRowItemRenderer(item) => {
|
MusicItem::MusicTwoRowItemRenderer(item) => {
|
||||||
|
|
@ -241,13 +382,13 @@ impl MusicListMapper {
|
||||||
let subtitle_p2 = subtitle_parts.next();
|
let subtitle_p2 = subtitle_parts.next();
|
||||||
let subtitle_p3 = subtitle_parts.next();
|
let subtitle_p3 = subtitle_parts.next();
|
||||||
|
|
||||||
let (page_type, browse_id) = item
|
let (page_type, id) = item
|
||||||
.navigation_endpoint
|
.navigation_endpoint
|
||||||
.music_page()
|
.music_page()
|
||||||
.ok_or_else(|| "could not get navigation endpoint".to_owned())?;
|
.ok_or_else(|| "could not get navigation endpoint".to_owned())?;
|
||||||
|
|
||||||
match page_type {
|
match page_type {
|
||||||
super::url_endpoint::PageType::Album => {
|
PageType::Album => {
|
||||||
let mut year = None;
|
let mut year = None;
|
||||||
let mut album_type = AlbumType::Single;
|
let mut album_type = AlbumType::Single;
|
||||||
|
|
||||||
|
|
@ -277,13 +418,13 @@ impl MusicListMapper {
|
||||||
_ => {
|
_ => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"could not parse subtitle of album {}",
|
"could not parse subtitle of album {}",
|
||||||
browse_id
|
id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.albums.push(AlbumItem {
|
self.albums.push(AlbumItem {
|
||||||
id: browse_id,
|
id,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
cover: item.thumbnail_renderer.into(),
|
cover: item.thumbnail_renderer.into(),
|
||||||
artists,
|
artists,
|
||||||
|
|
@ -293,7 +434,7 @@ impl MusicListMapper {
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
super::url_endpoint::PageType::Playlist => {
|
PageType::Playlist => {
|
||||||
// TODO: make component to string zero-copy if len=1
|
// TODO: make component to string zero-copy if len=1
|
||||||
let from_ytm = subtitle_p2
|
let from_ytm = subtitle_p2
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -304,33 +445,32 @@ impl MusicListMapper {
|
||||||
let channel = subtitle_p2.and_then(|p| {
|
let channel = subtitle_p2.and_then(|p| {
|
||||||
p.0.into_iter().find_map(|c| ChannelId::try_from(c).ok())
|
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 {
|
self.playlists.push(MusicPlaylistItem {
|
||||||
id: browse_id,
|
id,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
thumbnail: item.thumbnail_renderer.into(),
|
thumbnail: item.thumbnail_renderer.into(),
|
||||||
channel,
|
channel,
|
||||||
track_count: subtitle_p3
|
track_count,
|
||||||
.and_then(|p| util::parse_numeric(&p.to_string()).ok()),
|
|
||||||
from_ytm,
|
from_ytm,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
super::url_endpoint::PageType::Artist => {
|
PageType::Artist => {
|
||||||
let subscriber_count = subtitle_p1
|
let subscriber_count = subtitle_p1
|
||||||
.and_then(|p| util::parse_large_numstr(&p.to_string(), self.lang));
|
.and_then(|p| util::parse_large_numstr(&p.to_string(), self.lang));
|
||||||
|
|
||||||
self.artists.push(ArtistItem {
|
self.artists.push(ArtistItem {
|
||||||
id: browse_id,
|
id,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
avatar: item.thumbnail_renderer.into(),
|
avatar: item.thumbnail_renderer.into(),
|
||||||
subscriber_count,
|
subscriber_count,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
super::url_endpoint::PageType::Channel => {
|
PageType::Channel => Err(format!("channel items unsupported. id: {}", id)),
|
||||||
Err(format!("channel items unsupported. id: {}", browse_id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use crate::serializer::{
|
||||||
MapResult, VecLogError,
|
MapResult, VecLogError,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::music_item::{MusicContentsRenderer, MusicItem, MusicThumbnailRenderer};
|
use super::music_item::{MusicContentsRenderer, MusicItem, MusicShelf, MusicThumbnailRenderer};
|
||||||
use super::{ContentRenderer, ContentsRenderer, MusicContinuation};
|
use super::{ContentsRenderer, Tab};
|
||||||
|
|
||||||
/// Response model for YouTube Music playlists and albums
|
/// Response model for YouTube Music playlists and albums
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -27,13 +27,7 @@ pub(crate) struct MusicPlaylistCont {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct Contents {
|
pub(crate) struct Contents {
|
||||||
pub single_column_browse_results_renderer: ContentsRenderer<Tab>,
|
pub single_column_browse_results_renderer: ContentsRenderer<Tab<SectionList>>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct Tab {
|
|
||||||
pub tab_renderer: ContentRenderer<SectionList>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -57,20 +51,6 @@ pub(crate) enum ItemSection {
|
||||||
None,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct Header {
|
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 crate::util::MappingError;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Alert, ContentRenderer, ContentsRenderer, ContinuationEndpoint, ResponseContext, Thumbnails,
|
Alert, ContentsRenderer, ContinuationEndpoint, ResponseContext, Tab, Thumbnails, ThumbnailsWrap,
|
||||||
ThumbnailsWrap,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
|
|
@ -34,13 +33,7 @@ pub(crate) struct PlaylistCont {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct Contents {
|
pub(crate) struct Contents {
|
||||||
pub two_column_browse_results_renderer: ContentsRenderer<Tab>,
|
pub two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList>>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct Tab {
|
|
||||||
pub tab_renderer: ContentRenderer<SectionList>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::{serde_as, VecSkipError};
|
use serde_with::{serde_as, VecSkipError};
|
||||||
|
|
||||||
use super::{video_item::YouTubeListRendererWrap, ContentRenderer, ResponseContext};
|
use super::{video_item::YouTubeListRendererWrap, ResponseContext, Tab};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -29,9 +29,3 @@ pub(crate) struct BrowseResults {
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub tabs: Vec<Tab<YouTubeListRendererWrap>>,
|
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
|
/// Album variants
|
||||||
pub variants: Vec<AlbumItem>,
|
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