feat: add search item mapping (WIP)
This commit is contained in:
parent
44da9c7cc5
commit
3ad8f9b178
15 changed files with 14502 additions and 115 deletions
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue