feat: add search item mapping (WIP)

This commit is contained in:
ThetaDev 2022-10-31 08:41:06 +01:00
parent 44da9c7cc5
commit 3ad8f9b178
15 changed files with 14502 additions and 115 deletions

View file

@ -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();
}
}

View file

@ -4,6 +4,7 @@ pub(crate) mod response;
mod channel;
mod music_playlist;
mod music_search;
mod pagination;
mod player;
mod playlist;

View file

@ -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
View 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);
}
}

View file

@ -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 {

View file

@ -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)),
}
}
}

View file

@ -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 {

View 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,
}

View file

@ -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)]

View file

@ -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>,
}

View file

@ -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,
)

View file

@ -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"),
)

View file

@ -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>,
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff