feat: add track details, radios

This commit is contained in:
ThetaDev 2022-11-10 23:19:11 +01:00
parent 556575f5ff
commit e4046aef00
22 changed files with 19960 additions and 30 deletions

View file

@ -24,6 +24,8 @@ inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
- [X] **Artist**
- [X] **Search**
- [ ] **Search suggestions**
- [ ] **Radio**
- [ ] **Track details**
- [ ] **Moods**
- [ ] **Charts**
- [ ] **New**

View file

@ -49,7 +49,9 @@ pub async fn download_testfiles(project_root: &Path) {
music_search_playlists(&testfiles).await;
music_search_cont(&testfiles).await;
music_artist(&testfiles).await;
music_details(&testfiles).await;
music_radio(&testfiles).await;
music_radio_cont(&testfiles).await;
}
const CLIENT_TYPES: [ClientType; 5] = [
@ -700,8 +702,8 @@ async fn music_artist(testfiles: &Path) {
}
}
async fn music_radio(testfiles: &Path) {
for (name, id) in [("mv", "RDAMVMZeerrnuLi5E"), ("track", "RDAMVM7nigXQS1Xb0")] {
async fn music_details(testfiles: &Path) {
for (name, id) in [("mv", "ZeerrnuLi5E"), ("track", "7nigXQS1Xb0")] {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_details");
json_path.push(format!("details_{}.json", name));
@ -709,7 +711,36 @@ async fn music_radio(testfiles: &Path) {
continue;
}
let rp = rp_testfile(&json_path);
rp.query().music_details(id).await.unwrap();
}
}
async fn music_radio(testfiles: &Path) {
for (name, id) in [("mv", "RDAMVMZeerrnuLi5E"), ("track", "RDAMVM7nigXQS1Xb0")] {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_details");
json_path.push(format!("radio_{}.json", name));
if json_path.exists() {
continue;
}
let rp = rp_testfile(&json_path);
rp.query().music_radio(id).await.unwrap();
}
}
async fn music_radio_cont(testfiles: &Path) {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_details");
json_path.push("radio_cont.json");
if json_path.exists() {
return;
}
let rp = RustyPipe::new();
let res = rp.query().music_radio("RDAMVM7nigXQS1Xb0").await.unwrap();
let rp = rp_testfile(&json_path);
res.next(&rp.query()).await.unwrap().unwrap();
}

View file

@ -1,9 +0,0 @@
Track radio: RDAMVM + video id
Example: RDAMVMZeerrnuLi5E
Artist radio: RDEMieiteXw81tMLBdKv8qkChg
ID has to be extracted from artist page
Playlist/album radio: RDAMPL + playlist id
Genre radio: RDQM1xqCV6EdPUw

View file

@ -70,3 +70,9 @@ Single: MPREb_bHfHGoy7vuv
EP: MPREb_u1I69lSAe5v
Audiobook: MPREb_gaoNzsQHedo
Show: MPREb_cwzk8EUwypZ
# Radios
Track radio (Autoplay): RDAMVM + video id, example: RDAMVMZeerrnuLi5E
Artist radio: RDEMieiteXw81tMLBdKv8qkChg (ID from artist page)
Playlist/album radio: RDAMPL + playlist id
Genre radio: RDQM1xqCV6EdPUw

View file

@ -1,18 +1,22 @@
use std::borrow::Cow;
use serde::Serialize;
use crate::{
error::{Error, ExtractionError},
model::MusicDetails,
model::{Paginator, TrackDetails, TrackItem},
param::Language,
serializer::MapResult,
};
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
use super::{
response::{self, music_item::map_queue_item},
ClientType, MapResponse, RustyPipeQuery, YTContext,
};
#[derive(Debug, Serialize)]
struct QMusicDetails<'a> {
context: YTContext<'a>,
// YouTube video ID
video_id: &'a str,
enable_persistent_playlist_panel: bool,
is_audio_only: bool,
@ -30,7 +34,27 @@ struct QRadio<'a> {
}
impl RustyPipeQuery {
pub async fn music_radio(&self, radio_id: &str) -> Result<MusicDetails, Error> {
pub async fn music_details(&self, video_id: &str) -> Result<TrackDetails, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QMusicDetails {
context,
video_id,
enable_persistent_playlist_panel: true,
is_audio_only: true,
tuner_setting_value: "AUTOMIX_SETTING_NORMAL",
};
self.execute_request::<response::MusicDetails, _, _>(
ClientType::DesktopMusic,
"music_details",
video_id,
"next",
&request_body,
)
.await
}
pub async fn music_radio(&self, radio_id: &str) -> Result<Paginator<TrackItem>, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QRadio {
context,
@ -50,20 +74,194 @@ impl RustyPipeQuery {
)
.await
}
pub async fn music_radio_track(&self, video_id: &str) -> Result<Paginator<TrackItem>, Error> {
self.music_radio(&format!("RDAMVM{}", video_id)).await
}
pub async fn music_radio_playlist(
&self,
playlist_id: &str,
) -> Result<Paginator<TrackItem>, Error> {
self.music_radio(&format!("RDAMPL{}", playlist_id)).await
}
}
impl MapResponse<MusicDetails> for response::MusicDetails {
impl MapResponse<TrackDetails> for response::MusicDetails {
fn map_response(
self,
id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<MusicDetails>, ExtractionError> {
dbg!(&self);
) -> Result<MapResult<TrackDetails>, ExtractionError> {
let tabs = self
.contents
.single_column_music_watch_next_results_renderer
.tabbed_renderer
.watch_next_tabbed_results_renderer
.tabs;
let mut content = None;
let mut lyrics_id = None;
let mut related_id = None;
for t in tabs {
match (t.tab_renderer.content, t.tab_renderer.endpoint) {
(Some(tc), _) => {
content = Some(tc.music_queue_renderer.content.playlist_panel_renderer);
}
(_, Some(endpoint)) => {
match endpoint
.browse_endpoint
.browse_endpoint_context_supported_configs
.browse_endpoint_context_music_config
.page_type
{
response::music_details::TabType::Lyrics => {
lyrics_id = Some(endpoint.browse_endpoint.browse_id);
}
response::music_details::TabType::Related => {
related_id = Some(endpoint.browse_endpoint.browse_id);
}
}
}
(None, None) => {}
}
}
let content = content.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?;
let track_item = content
.contents
.c
.into_iter()
.find_map(|item| match item {
response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(track) => {
Some(track)
}
response::music_item::PlaylistPanelVideo::None => None,
})
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no video item")))?;
let track = map_queue_item(track_item, lang);
if track.id != id {
return Err(ExtractionError::WrongResult(format!(
"got wrong video id {}, expected {}",
track.id, id
)));
}
Ok(MapResult {
c: MusicDetails {},
warnings: Vec::new(),
c: TrackDetails {
track,
lyrics_id,
related_id,
},
warnings: content.contents.warnings,
})
}
}
impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
fn map_response(
self,
_id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
let tabs = self
.contents
.single_column_music_watch_next_results_renderer
.tabbed_renderer
.watch_next_tabbed_results_renderer
.tabs;
let content = tabs
.into_iter()
.find_map(|t| t.tab_renderer.content)
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
.music_queue_renderer
.content
.playlist_panel_renderer;
let tracks = content
.contents
.c
.into_iter()
.filter_map(|item| match item {
response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) => {
Some(map_queue_item(item, lang))
}
response::music_item::PlaylistPanelVideo::None => None,
})
.collect::<Vec<_>>();
let ctoken = content
.continuations
.into_iter()
.next()
.map(|c| c.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new_ext(
None,
tracks,
ctoken,
None,
crate::param::ContinuationEndpoint::MusicNext,
),
warnings: content.contents.warnings,
})
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader, path::Path};
use rstest::rstest;
use super::*;
use crate::{model, param::Language};
#[rstest]
#[case::mv("mv", "ZeerrnuLi5E")]
#[case::track("track", "7nigXQS1Xb0")]
fn map_music_details(#[case] name: &str, #[case] id: &str) {
let filename = format!("testfiles/music_details/details_{}.json", name);
let json_path = Path::new(&filename);
let json_file = File::open(json_path).unwrap();
let details: response::MusicDetails =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<model::TrackDetails> =
details.map_response(id, Language::En, None).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_music_details_{}", name), map_res.c);
}
#[rstest]
#[case::mv("mv", "RDAMVMZeerrnuLi5E")]
#[case::track("track", "RDAMVM7nigXQS1Xb0")]
fn map_music_radio(#[case] name: &str, #[case] id: &str) {
let filename = format!("testfiles/music_details/radio_{}.json", name);
let json_path = Path::new(&filename);
let json_file = File::open(json_path).unwrap();
let radio: response::MusicDetails =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<TrackItem>> =
radio.map_response(id, Language::En, None).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_music_radio_{}", name), map_res.c);
}
}

View file

@ -6,7 +6,7 @@ use crate::param::ContinuationEndpoint;
use crate::serializer::MapResult;
use crate::util::TryRemove;
use super::response::music_item::MusicListMapper;
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
use super::{response, ClientType, MapResponse, QContinuation, RustyPipeQuery};
impl RustyPipeQuery {
@ -152,6 +152,15 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
}
}
}
response::music_item::ContinuationContents::PlaylistPanelContinuation(mut panel) => {
continuations.append(&mut panel.continuations);
mapper.add_warnings(&mut panel.contents.warnings);
panel.contents.c.into_iter().for_each(|item| {
if let PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) = item {
mapper.add_item(MusicItem::Track(map_queue_item(item, lang)))
}
});
}
}
let map_res = mapper.items();
@ -355,6 +364,7 @@ mod tests {
#[rstest]
#[case("playlist_tracks", "music_playlist/playlist_cont")]
#[case("search_tracks", "music_search/tracks_cont")]
#[case("radio_tracks", "music_details/radio_cont")]
fn map_continuation_tracks(#[case] name: &str, #[case] path: &str) {
let filename = format!("testfiles/{}.json", path);
let json_path = Path::new(&filename);

View file

@ -213,6 +213,7 @@ pub(crate) struct RichGridContinuation {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicContinuationData {
#[serde(alias = "nextRadioContinuationData")]
pub next_continuation_data: MusicContinuationDataInner,
}

View file

@ -1,6 +1,93 @@
use serde::Deserialize;
use super::{music_item::PlaylistPanelRenderer, ContentRenderer};
/// Response model for YouTube Music track details
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicDetails {}
pub(crate) struct MusicDetails {
pub contents: Contents,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Contents {
pub single_column_music_watch_next_results_renderer: WatchNextResultsRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WatchNextResultsRenderer {
pub tabbed_renderer: TabbedRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabbedRenderer {
pub watch_next_tabbed_results_renderer: TabbedRendererInner,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabbedRendererInner {
pub tabs: Vec<Tab>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Tab {
pub tab_renderer: TabRenderer,
}
/// Watch next tab
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabRenderer {
pub content: Option<TabContent>,
pub endpoint: Option<TabEndpoint>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabEndpoint {
pub browse_endpoint: TabBrowseEndpoint,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabBrowseEndpoint {
pub browse_id: String,
pub browse_endpoint_context_supported_configs: TabBrowseEndpointSupportedConfigs,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabBrowseEndpointSupportedConfigs {
pub browse_endpoint_context_music_config: TabBrowseEndpointMusicConfig,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabBrowseEndpointMusicConfig {
pub page_type: TabType,
}
#[derive(Debug, Deserialize)]
pub(crate) enum TabType {
#[serde(rename = "MUSIC_PAGE_TYPE_TRACK_LYRICS")]
Lyrics,
#[serde(rename = "MUSIC_PAGE_TYPE_TRACK_RELATED")]
Related,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TabContent {
pub music_queue_renderer: ContentRenderer<PlaylistPanel>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistPanel {
pub playlist_panel_renderer: PlaylistPanelRenderer,
}

View file

@ -17,7 +17,7 @@ use crate::{
use super::{
url_endpoint::{BrowseEndpointWrap, NavigationEndpoint, PageType},
ContentsRenderer, MusicContinuationData, ThumbnailsWrap,
ContentsRenderer, MusicContinuationData, Thumbnails, ThumbnailsWrap,
};
#[serde_as]
@ -176,6 +176,48 @@ pub(crate) struct CoverMusicItem {
pub navigation_endpoint: NavigationEndpoint,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistPanelRenderer {
#[serde_as(as = "VecLogError<_>")]
pub contents: MapResult<Vec<PlaylistPanelVideo>>,
/// Continuation token for fetching more radio items
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub continuations: Vec<MusicContinuationData>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum PlaylistPanelVideo {
PlaylistPanelVideoRenderer(QueueMusicItem),
#[serde(other, deserialize_with = "ignore_any")]
None,
}
/// Music item from a playback queue (`playlistPanelVideoRenderer`)
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct QueueMusicItem {
pub video_id: String,
#[serde_as(as = "Text")]
pub title: String,
#[serde_as(as = "Option<Text>")]
pub length_text: Option<String>,
/// Artist + Album + Year (for tracks)
/// `<"IVE">, " • ", <"LOVE DIVE (LOVE DIVE)">, " • ", "2022"`
///
/// Artist + view count + like count (for videos)
/// `<"aespa">, " • ", "250M views", " • ", "3.6M likes"`
#[serde(default)]
pub long_byline_text: TextComponents,
#[serde(default)]
pub thumbnail: Thumbnails,
pub menu: Option<MusicItemMenu>,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicThumbnailRenderer {
@ -236,10 +278,12 @@ pub(crate) struct MusicContinuation {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::enum_variant_names)]
pub(crate) enum ContinuationContents {
#[serde(alias = "musicPlaylistShelfContinuation")]
MusicShelfContinuation(MusicShelf),
SectionListContinuation(ContentsRenderer<ItemSection>),
PlaylistPanelContinuation(PlaylistPanelRenderer),
}
#[derive(Debug, Deserialize)]
@ -712,6 +756,14 @@ impl MusicListMapper {
etype
}
pub fn add_item(&mut self, item: MusicItem) {
self.items.push(item);
}
pub fn add_warnings(&mut self, warnings: &mut Vec<String>) {
self.warnings.append(warnings);
}
pub fn items(self) -> MapResult<Vec<MusicItem>> {
MapResult {
c: self.items,
@ -783,7 +835,7 @@ pub(crate) fn map_artists(artists_p: Option<TextComponents>) -> (Vec<ArtistId>,
(artists, by_va)
}
fn map_artist_id(
pub(crate) fn map_artist_id(
menu: Option<MusicItemMenu>,
fallback_artist: Option<&ArtistId>,
) -> Option<String> {
@ -816,6 +868,49 @@ pub(crate) fn map_album_type(txt: &str, lang: Language) -> AlbumType {
.unwrap_or_default()
}
pub(crate) fn map_queue_item(item: QueueMusicItem, lang: Language) -> TrackItem {
let mut subtitle_parts = item.long_byline_text.split(util::DOT_SEPARATOR).into_iter();
let is_video = !item
.thumbnail
.thumbnails
.first()
.map(|tn| tn.height == tn.width)
.unwrap_or_default();
let artist_p = subtitle_parts.next();
let (artists, _) = map_artists(artist_p);
let artist_id = map_artist_id(item.menu, artists.first());
let subtitle_p2 = subtitle_parts.next();
let (album, view_count) = if is_video {
(
None,
subtitle_p2.and_then(|p| util::parse_large_numstr(p.first_str(), lang)),
)
} else {
(
subtitle_p2.and_then(|p| p.0.into_iter().find_map(|c| AlbumId::try_from(c).ok())),
None,
)
};
TrackItem {
id: item.video_id,
title: item.title,
duration: item
.length_text
.and_then(|txt| util::parse_video_length(&txt)),
cover: item.thumbnail.into(),
artists,
artist_id,
album,
view_count,
is_video,
track_nr: None,
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path};

View file

@ -0,0 +1,41 @@
---
source: src/client/music_details.rs
expression: map_res.c
---
TrackDetails(
track: TrackItem(
id: "ZeerrnuLi5E",
title: "Black Mamba",
duration: Some(230),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/ZeerrnuLi5E/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3maNxpYzTFmXZBd8s1w1iE6rTBDaw",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/ZeerrnuLi5E/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k5q17nduJ8-t3h9_obEVMVi8Cz3A",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/ZeerrnuLi5E/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k7CsaxHObhW1JXPtGyKE1fgSGZ3Q",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
name: "aespa",
),
],
artist_id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
album: None,
view_count: Some(235000000),
is_video: true,
track_nr: None,
),
lyrics_id: Some("MPLYt_wrKjTn9hmry"),
related_id: Some("MPTRt_wrKjTn9hmry"),
)

View file

@ -0,0 +1,59 @@
---
source: src/client/music_details.rs
expression: map_res.c
---
TrackDetails(
track: TrackItem(
id: "7nigXQS1Xb0",
title: "INVU",
duration: Some(205),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/2a1Lr46_ibhVNX5tZK_PnsmKFpB1ptZ9eUqtlcCXTRSxAcOLC7HpAO0pqyFJTttUPHAiYpqkTH251DIQ9A=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/2a1Lr46_ibhVNX5tZK_PnsmKFpB1ptZ9eUqtlcCXTRSxAcOLC7HpAO0pqyFJTttUPHAiYpqkTH251DIQ9A=w120-h120-l90-rj",
width: 120,
height: 120,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/2a1Lr46_ibhVNX5tZK_PnsmKFpB1ptZ9eUqtlcCXTRSxAcOLC7HpAO0pqyFJTttUPHAiYpqkTH251DIQ9A=w180-h180-l90-rj",
width: 180,
height: 180,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/2a1Lr46_ibhVNX5tZK_PnsmKFpB1ptZ9eUqtlcCXTRSxAcOLC7HpAO0pqyFJTttUPHAiYpqkTH251DIQ9A=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/2a1Lr46_ibhVNX5tZK_PnsmKFpB1ptZ9eUqtlcCXTRSxAcOLC7HpAO0pqyFJTttUPHAiYpqkTH251DIQ9A=w302-h302-l90-rj",
width: 302,
height: 302,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/2a1Lr46_ibhVNX5tZK_PnsmKFpB1ptZ9eUqtlcCXTRSxAcOLC7HpAO0pqyFJTttUPHAiYpqkTH251DIQ9A=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
artists: [
ArtistId(
id: Some("UCwzCuKxyMY_sT7hr1E8G1XA"),
name: "TAEYEON",
),
],
artist_id: Some("UCwzCuKxyMY_sT7hr1E8G1XA"),
album: Some(AlbumId(
id: "MPREb_4xbv14CiQJm",
name: "INVU - The 3rd Album",
)),
view_count: None,
is_video: false,
track_nr: None,
),
lyrics_id: Some("MPLYt_4xbv14CiQJm-1"),
related_id: Some("MPTRt_4xbv14CiQJm-1"),
)

View file

@ -0,0 +1,826 @@
---
source: src/client/music_details.rs
expression: map_res.c
---
Paginator(
count: None,
items: [
TrackItem(
id: "4TWR90KJl84",
title: "Next Level",
duration: Some(236),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/4TWR90KJl84/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kl3LTK647n1QMNk2ltojkKT5jR8w",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/4TWR90KJl84/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lCHWpapuNMHxDRnGHl_AKqq73fAw",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/4TWR90KJl84/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k49HRWAedtI0Zqb7Noov7jBviZig",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
name: "aespa",
),
],
artist_id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
album: None,
view_count: Some(250000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "Y8JFxS1HlDo",
title: "LOVE DIVE",
duration: Some(179),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/Y8JFxS1HlDo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k7dfJvms48b2vkzgD8IgO7NeY6cQ",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/Y8JFxS1HlDo/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3llrmra1TaoqopbxBevNFRK_6Xc2w",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/Y8JFxS1HlDo/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3moJU9Sl3QbDvSvlGR2Q2cngtnKMw",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UC_4Y1QqJr60C5Z7-eQWy-mw"),
name: "IVE",
),
],
artist_id: Some("UC_4Y1QqJr60C5Z7-eQWy-mw"),
album: None,
view_count: Some(168000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "CM4CkVFmTds",
title: "I CAN\'T STOP ME",
duration: Some(221),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/CM4CkVFmTds/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n_4nSKMrgw65E7qu7SXopvURCqLg",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/CM4CkVFmTds/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mw6J7Z0DXh2ashrL5DBTZm5Z5sXA",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/CM4CkVFmTds/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3laWy4cMXts0_azK9y7-nvHE-TTzQ",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCAq0pFGa2w9SjxOq0ZxKVIw"),
name: "TWICE",
),
],
artist_id: Some("UCAq0pFGa2w9SjxOq0ZxKVIw"),
album: None,
view_count: Some(464000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "_ysomCGaZLw",
title: "In the Morning",
duration: Some(185),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/_ysomCGaZLw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mmHZHNUSSMjNtqYD5P3vpl3fhnTA",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/_ysomCGaZLw/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kpPew9xvDjRS-Psi-SqcKDDVwbCw",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/_ysomCGaZLw/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k8Q_n6ukQ9LlhJzZ6gHskmdFFmhg",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCTP45_DE3fMLujU8sZ-MBzw"),
name: "ITZY",
),
],
artist_id: Some("UCTP45_DE3fMLujU8sZ-MBzw"),
album: None,
view_count: Some(230000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "gQlMMD8auMs",
title: "Pink Venom",
duration: Some(194),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/gQlMMD8auMs/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nEM0b-vFFexT2C8d4yzP8hQi60Sg",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/gQlMMD8auMs/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3khUMi18G93F7jAInIz62E5CIBUFw",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/gQlMMD8auMs/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mkn0qyMCzW43mTrIGr6lana1WZpg",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCkbbMCA40i18i7UdjayMPAg"),
name: "BLACKPINK",
),
],
artist_id: Some("UCkbbMCA40i18i7UdjayMPAg"),
album: None,
view_count: Some(422000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "uR8Mrt1IpXg",
title: "Psycho",
duration: Some(216),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/uR8Mrt1IpXg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhufeImZ0Df0rKCh6-W4M5GF9tGg",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/uR8Mrt1IpXg/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3neHndOeWLSL1Sb73WcnsA7Iiq0mg",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/uR8Mrt1IpXg/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mgmwQ-4E42UvQGZvyQP86E3eKUWw",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCHmZYTfdTyVKQEJicLiXEOg"),
name: "Red Velvet",
),
],
artist_id: Some("UCHmZYTfdTyVKQEJicLiXEOg"),
album: None,
view_count: Some(349000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "PkKnp4SdE-w",
title: "Hot Sauce",
duration: Some(212),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/PkKnp4SdE-w/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kWfgwfdbEnHIDeILZPWhTgwuGDRw",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/PkKnp4SdE-w/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mvnlVkFKrInqbVQbZu_ttrFbih4g",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/PkKnp4SdE-w/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kBZU4LKqDi5yxDZpP3dUeiPzZWXw",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCuKdaTsJ9Jv94hVV_I9aRxQ"),
name: "NCT DREAM",
),
],
artist_id: Some("UCuKdaTsJ9Jv94hVV_I9aRxQ"),
album: None,
view_count: Some(167000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "4vbDFu0PUew",
title: "FEARLESS OFFICIAL M/V",
duration: Some(183),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/4vbDFu0PUew/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3khlrmZ55Elav20m6uPsZObHLhb1Q",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/4vbDFu0PUew/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lYxFh0M0OcTQEKuGinVKYcZYNGhg",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/4vbDFu0PUew/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kvF9SdDGxAVrh5AUiDmF1jW31bzg",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UC-clMkTZa7k-FxmNgMjoCgQ"),
name: "LE SSERAFIM",
),
],
artist_id: Some("UC-clMkTZa7k-FxmNgMjoCgQ"),
album: None,
view_count: Some(124000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "A5H8zBb3iao",
title: "90\'s Love",
duration: Some(227),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/A5H8zBb3iao/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k_nifrnpSLq1wQ8WX1XEx2azAmJw",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/A5H8zBb3iao/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3ly97BC743uywuuUbBd27U6QgyYXw",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/A5H8zBb3iao/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kJoTPcNu84VWy0DE4qX83EmK6qXQ",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCEf_Bc-KVd7onSeifS3py9g"),
name: "SMTOWN",
),
],
artist_id: Some("UCEf_Bc-KVd7onSeifS3py9g"),
album: None,
view_count: Some(127000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "_xJUCsyMQes",
title: "Best Friend (feat. Doja Cat)",
duration: Some(202),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/_xJUCsyMQes/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lR1nuc9rfKYua1azmFgfgI0NI_DA",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/_xJUCsyMQes/sddefault.jpg?sqp=-oaymwEWCKoDEPABIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lUfitpkawiQB5Eh2qeIRKmck_H5Q",
width: 426,
height: 240,
),
],
artists: [
ArtistId(
id: Some("UCqTaQGqjAI6fYkr84KZgZEg"),
name: "Saweetie",
),
],
artist_id: Some("UCqTaQGqjAI6fYkr84KZgZEg"),
album: None,
view_count: Some(239000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "n0j5NPptyM0",
title: "WA DA DA",
duration: Some(198),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/n0j5NPptyM0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mPDQ3gbOoo_rjjKAzA6RL4atuimw",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/n0j5NPptyM0/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3l7mg1I-bMPbXmQuFuTTQZcExRhLQ",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/n0j5NPptyM0/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3m3bKSefnosNWvGi7vR_1_ezSDbnw",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCAKvDuIX3m1AUdPpDSqV_3w"),
name: "Kep1er",
),
],
artist_id: Some("UCAKvDuIX3m1AUdPpDSqV_3w"),
album: None,
view_count: Some(140000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "3GWscde8rM8",
title: "O.O",
duration: Some(214),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/3GWscde8rM8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3njiYPlQmSjbSJxDZ2cazfxOFEw9Q",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/3GWscde8rM8/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lrAIY30SBN9UvKQ8CCLz5HQw2rZw",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/3GWscde8rM8/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3ngOrZhqin3AJB9WWNRVnbH5eoT5Q",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UC_Cx288SDUD9liYn7CiJLAA"),
name: "NMIXX",
),
],
artist_id: Some("UC_Cx288SDUD9liYn7CiJLAA"),
album: None,
view_count: Some(90000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "uBY1AoiF5Vo",
title: "Step Back",
duration: Some(231),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/uBY1AoiF5Vo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3ldz-mtiOTGWMsnjD7IqX9Q2SDDpA",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/uBY1AoiF5Vo/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lu0lV6GzYKqCbUm8E-DPD715gTGQ",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/uBY1AoiF5Vo/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3n8F8MoVoc0RWYssIJ591eVxJrAgQ",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCDDpqmryjNunitS05bv7-8w"),
name: "GOT the beat",
),
],
artist_id: Some("UCDDpqmryjNunitS05bv7-8w"),
album: None,
view_count: Some(137000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "WPdWvnAAurg",
title: "Savage",
duration: Some(259),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/WPdWvnAAurg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mTnrnrCkW0aHO0t5nRP1ukYRu6vg",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/WPdWvnAAurg/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lzqbOcWJEoxbxHT6mLxbSDCx3kPA",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/WPdWvnAAurg/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3nyqKPkwPLJVpPofie4QX3y807Txw",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
name: "aespa",
),
],
artist_id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
album: None,
view_count: Some(220000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "tyrVtwE8Gv0",
title: "Make A Wish (Birthday Song)",
duration: Some(249),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/tyrVtwE8Gv0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEIhBp-IAoTTCiDYJLIj4vtl8Hpw",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/tyrVtwE8Gv0/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mLBdpBoRAGP1SKLd_T2SOzM_Gn-g",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/tyrVtwE8Gv0/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lFYENgMq_4Ql9KLEShyeKm7mV2mQ",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCwPKPUAWE8ah0lkOcvNh8_Q"),
name: "NCT U",
),
],
artist_id: Some("UCwPKPUAWE8ah0lkOcvNh8_Q"),
album: None,
view_count: Some(258000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "Jh4QFaPmdss",
title: "TOMBOY",
duration: Some(198),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/Jh4QFaPmdss/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nL0vQ4DGp4rNES4wtIQXf6MMcX4A",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/Jh4QFaPmdss/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kTDFBdA22mhhPDMAxJPoFkm9bsLA",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/Jh4QFaPmdss/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3nDO6v2YyPEsuP9TlHOCXq0b8kq2Q",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCWT2ZfW7d8YI-HinHEVhyCA"),
name: "(G)I-DLE",
),
],
artist_id: Some("UCWT2ZfW7d8YI-HinHEVhyCA"),
album: None,
view_count: Some(181000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "2OvyA2__Eas",
title: "英雄; Kick It",
duration: Some(239),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/2OvyA2__Eas/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n8rUV8L6DfEtliJa6uRI007X4ryg",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/2OvyA2__Eas/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kbaxVY4Hkp0RwwvQtIf8V3kpBl0w",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/2OvyA2__Eas/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3nNHdrJrMIksXZ5x2_nabxLC1STXA",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCjqYTQjO-JG-8vLlt6-4iyQ"),
name: "NCT 127",
),
],
artist_id: Some("UCjqYTQjO-JG-8vLlt6-4iyQ"),
album: None,
view_count: Some(165000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "dYRITmpFbJ4",
title: "Girls",
duration: Some(269),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/dYRITmpFbJ4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mt44xFH24DkaQqASPBttEMuL02aQ",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/dYRITmpFbJ4/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k1I7uOUx9-Rs_MiUFD2YWrbmAbJg",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/dYRITmpFbJ4/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kQC3YdpaKYJ5xLF1ryXjTN9wJ_3Q",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
name: "aespa",
),
],
artist_id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
album: None,
view_count: Some(108000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "POe9SOEKotk",
title: "Shut Down",
duration: Some(181),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/POe9SOEKotk/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m512hPlVaRZGGDe7lyzi4uYVVm2Q",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/POe9SOEKotk/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kbAXOLxYByimodFUXOfH2mRh45lA",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/POe9SOEKotk/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mP1fmBP5TsNQ8Hkwi_oK9AKmGYNg",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCkbbMCA40i18i7UdjayMPAg"),
name: "BLACKPINK",
),
],
artist_id: Some("UCkbbMCA40i18i7UdjayMPAg"),
album: None,
view_count: Some(222000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "pSudEWBAYRE",
title: "Love Shot",
duration: Some(210),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/pSudEWBAYRE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lAEYvImiSXeADO3bExIVXqZZ7GKQ",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/pSudEWBAYRE/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lWkqnBi3qqf4yWDXzR4qDUcuR7ow",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/pSudEWBAYRE/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3kCW9v4BsQjGfWRYYdO1xh6DMJwmg",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCEUX9tUYqTFfPQdAgVNsKTA"),
name: "EXO",
),
],
artist_id: Some("UCEUX9tUYqTFfPQdAgVNsKTA"),
album: None,
view_count: Some(540000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "nnVjsos40qk",
title: "환상동화 (Secret Story of the Swan)",
duration: Some(202),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/nnVjsos40qk/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kn_BEjT7jYkFddBxe6yv0igyo-0Q",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/nnVjsos40qk/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3nY9IGsviFkfgMsqPBhH2rEAGsGmQ",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/nnVjsos40qk/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lJEM3KYpj5POzRL7MQQBnbRMJIYA",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCG81UKNsFg9Perf0uPQOsQw"),
name: "IZ*ONE",
),
],
artist_id: Some("UCG81UKNsFg9Perf0uPQOsQw"),
album: None,
view_count: Some(90000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "H69tJmsgd9I",
title: "Dreams Come True",
duration: Some(221),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/H69tJmsgd9I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mWiXkBoELY5U1XWBMe2bEn1OFdgQ",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/H69tJmsgd9I/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3me8GohbnE0UckrhjJg5WtTVFgmfg",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/H69tJmsgd9I/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lKXzVOcOskWpZFhI60ZcbEEyEbiw",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
name: "aespa",
),
],
artist_id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
album: None,
view_count: Some(90000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "0IBSemQmno8",
title: "ZOO",
duration: Some(189),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/0IBSemQmno8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nq0HomQUvUAgv20Rb5KTkOOjYy-A",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/0IBSemQmno8/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mHDReqIeTQ82otCPBovy0ye0LNSQ",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/0IBSemQmno8/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3koM0EYu_ZhdOjoKBDhhoQgTrpAUA",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: None,
name: "Taeyong, JENO, YANGYANG, 지젤 (GISELLE), and HENDERY",
),
],
artist_id: Some("UCDdCbqagfKo_euzzCV9G2EQ"),
album: None,
view_count: Some(71000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "MjCZfZfucEc",
title: "LOCO",
duration: Some(233),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/MjCZfZfucEc/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lvN8V98wicGg5vG2F2zon-foZzIA",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/MjCZfZfucEc/sddefault.jpg?sqp=-oaymwEWCKoDEPABIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mKJIqURQYCCY_G1XDJnDiyqRZ4kQ",
width: 426,
height: 240,
),
],
artists: [
ArtistId(
id: Some("UCTP45_DE3fMLujU8sZ-MBzw"),
name: "ITZY",
),
],
artist_id: Some("UCTP45_DE3fMLujU8sZ-MBzw"),
album: None,
view_count: Some(208000000),
is_video: true,
track_nr: None,
),
TrackItem(
id: "tg2uF3R_Ozo",
title: "DUMB DUMB",
duration: Some(179),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/tg2uF3R_Ozo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mYPwfsMuBxT19qgv_XmSk2H79jvg",
width: 400,
height: 225,
),
Thumbnail(
url: "https://i.ytimg.com/vi/tg2uF3R_Ozo/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3lqGADl8uyCCDtehV_LAgMphtc57g",
width: 800,
height: 450,
),
Thumbnail(
url: "https://i.ytimg.com/vi/tg2uF3R_Ozo/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3nHkXP82A1Qe5nY_OQL55o5vtkIOQ",
width: 853,
height: 480,
),
],
artists: [
ArtistId(
id: Some("UCDnYJA3OXXhRKYPe3jzLGeQ"),
name: "SOMI",
),
],
artist_id: Some("UCDnYJA3OXXhRKYPe3jzLGeQ"),
album: None,
view_count: Some(140000000),
is_video: true,
track_nr: None,
),
],
ctoken: Some("CBkSSBILdGcydUYzUl9Pem8iEVJEQU1WTVplZXJybnVMaTVFMg53QUVCOGdFQ2VBRSUzRDgY0AEB-gEQQzcxNUY2RDFGQjIwNEQwQRgKggEVUFQ6RWd0MFp6SjFSak5TWDA5NmJ3"),
endpoint: music_next,
)

File diff suppressed because it is too large Load diff

View file

@ -1234,4 +1234,8 @@ pub struct MusicSearchFiltered<T> {
/// Music track details
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct MusicDetails {}
pub struct TrackDetails {
pub track: TrackItem,
pub lyrics_id: Option<String>,
pub related_id: Option<String>,
}

View file

@ -17,6 +17,7 @@ pub enum ContinuationEndpoint {
Next,
MusicBrowse,
MusicSearch,
MusicNext,
}
impl ContinuationEndpoint {
@ -24,14 +25,16 @@ impl ContinuationEndpoint {
match self {
ContinuationEndpoint::Browse | ContinuationEndpoint::MusicBrowse => "browse",
ContinuationEndpoint::Search | ContinuationEndpoint::MusicSearch => "search",
ContinuationEndpoint::Next => "next",
ContinuationEndpoint::Next | ContinuationEndpoint::MusicNext => "next",
}
}
pub(crate) fn is_music(self) -> bool {
matches!(
self,
ContinuationEndpoint::MusicBrowse | ContinuationEndpoint::MusicSearch
ContinuationEndpoint::MusicBrowse
| ContinuationEndpoint::MusicSearch
| ContinuationEndpoint::MusicNext
)
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
---
source: tests/youtube.rs
expression: track
---
TrackDetails(
track: TrackItem(
id: "ZeerrnuLi5E",
title: "Black Mamba",
duration: Some(230),
cover: "[cover]",
artists: [
ArtistId(
id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
name: "aespa",
),
],
artist_id: Some("UCEdZAdnnKqbaHOlv8nM6OtA"),
album: None,
view_count: Some(235000000),
is_video: true,
track_nr: None,
),
lyrics_id: Some("MPLYt_wrKjTn9hmry"),
related_id: Some("MPTRt_wrKjTn9hmry"),
)

View file

@ -0,0 +1,28 @@
---
source: tests/youtube.rs
expression: track
---
TrackDetails(
track: TrackItem(
id: "7nigXQS1Xb0",
title: "INVU",
duration: Some(205),
cover: "[cover]",
artists: [
ArtistId(
id: Some("UCwzCuKxyMY_sT7hr1E8G1XA"),
name: "TAEYEON",
),
],
artist_id: Some("UCwzCuKxyMY_sT7hr1E8G1XA"),
album: Some(AlbumId(
id: "MPREb_4xbv14CiQJm",
name: "INVU - The 3rd Album",
)),
view_count: None,
is_video: false,
track_nr: None,
),
lyrics_id: Some("MPLYt_4xbv14CiQJm-1"),
related_id: Some("MPTRt_4xbv14CiQJm-1"),
)

View file

@ -1509,9 +1509,8 @@ async fn music_search(#[case] typo: bool) {
assert_eq!(res.corrected_query, None);
}
let track = &res.tracks[0];
dbg!(&track);
assert_eq!(track.id, "ZeerrnuLi5E");
let track = res.tracks.iter().find(|t| t.id == "ZeerrnuLi5E").unwrap();
assert_eq!(track.title, "Black Mamba");
assert_eq!(track.duration.unwrap(), 230);
assert!(!track.cover.is_empty(), "got no cover");
@ -1755,6 +1754,39 @@ async fn music_search_genre_radio() {
rp.query().music_search("pop radio").await.unwrap();
}
#[rstest]
#[case::mv("mv", "ZeerrnuLi5E")]
#[case::track("track", "7nigXQS1Xb0")]
#[tokio::test]
async fn music_details(#[case] name: &str, #[case] id: &str) {
let rp = RustyPipe::builder().strict().build();
let track = rp.query().music_details(id).await.unwrap();
assert!(!track.track.cover.is_empty(), "got no cover");
insta::assert_ron_snapshot!(format!("music_details_{}", name), track,
{".track.cover" => "[cover]"}
);
}
#[tokio::test]
async fn music_radio_track() {
let rp = RustyPipe::builder().strict().build();
let tracks = rp.query().music_radio_track("ZeerrnuLi5E").await.unwrap();
assert_next(tracks, &rp.query(), 20, 3).await;
}
#[tokio::test]
async fn music_radio_playlist() {
let rp = RustyPipe::builder().strict().build();
let tracks = rp
.query()
.music_radio_playlist("PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")
.await
.unwrap();
assert_next(tracks, &rp.query(), 20, 1).await;
}
//#TESTUTIL
/// Assert equality within 10% margin