Compare commits

...

7 commits

Author SHA1 Message Date
73fa0295bf fix tests 2022-11-21 22:15:28 +01:00
cb38d5a248 feat: add music_related 2022-11-11 23:41:11 +01:00
c80e302d72 fix: use generic SectionListRenderer 2022-11-11 00:21:01 +01:00
2cd74b1da8 feat: add lyrics 2022-11-11 00:14:58 +01:00
dae8c1e775 fix: used borrowed str for QBrowse 2022-11-10 23:26:05 +01:00
e4046aef00 feat: add track details, radios 2022-11-10 23:19:11 +01:00
556575f5ff WIP: add music_details 2022-11-10 15:53:55 +01:00
37 changed files with 72053 additions and 97 deletions

View file

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

View file

@ -49,6 +49,11 @@ 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_lyrics(&testfiles).await;
music_related(&testfiles).await;
music_radio(&testfiles).await;
music_radio_cont(&testfiles).await;
}
const CLIENT_TYPES: [ClientType; 5] = [
@ -698,3 +703,82 @@ async fn music_artist(testfiles: &Path) {
rp.query().music_artist(id, true).await.unwrap();
}
}
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));
if json_path.exists() {
continue;
}
let rp = rp_testfile(&json_path);
rp.query().music_details(id).await.unwrap();
}
}
async fn music_lyrics(testfiles: &Path) {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_details");
json_path.push("lyrics.json");
if json_path.exists() {
return;
}
let rp = RustyPipe::new();
let res = rp.query().music_details("n4tK7LYFxI0").await.unwrap();
let rp = rp_testfile(&json_path);
rp.query()
.music_lyrics(&res.lyrics_id.unwrap())
.await
.unwrap();
}
async fn music_related(testfiles: &Path) {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_details");
json_path.push("related.json");
if json_path.exists() {
return;
}
let rp = RustyPipe::new();
let res = rp.query().music_details("ZeerrnuLi5E").await.unwrap();
let rp = rp_testfile(&json_path);
rp.query()
.music_related(&res.related_id.unwrap())
.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

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

@ -4,6 +4,7 @@ pub(crate) mod response;
mod channel;
mod music_artist;
mod music_details;
mod music_playlist;
mod music_search;
mod pagination;
@ -135,7 +136,7 @@ struct ThirdParty<'a> {
#[serde(rename_all = "camelCase")]
struct QBrowse<'a> {
context: YTContext<'a>,
browse_id: String,
browse_id: &'a str,
}
#[derive(Debug, Serialize)]
@ -989,6 +990,14 @@ impl RustyPipeQuery {
}
}
/// Get a YouTube Music visitor data cookie, which is necessary for certain requests
async fn get_ytm_visitor_data(&self) -> Result<String, Error> {
match &self.opts.visitor_data {
Some(vd) => Ok(vd.to_owned()),
None => self.client.get_ytm_visitor_data().await,
}
}
/// Execute a request to the YouTube API, then deobfuscate and map the response.
///
/// Creates a report in case of failure for easy debugging.

View file

@ -32,17 +32,13 @@ impl RustyPipeQuery {
all_albums: bool,
) -> Result<MusicArtist, Error> {
if all_albums {
let visitor_data = match &self.opts.visitor_data {
Some(vd) => vd.to_owned(),
None => self.client.get_ytm_visitor_data().await?,
};
let visitor_data = self.get_ytm_visitor_data().await?;
let context = self
.get_context(ClientType::DesktopMusic, true, Some(&visitor_data))
.await;
let request_body = QBrowse {
context,
browse_id: artist_id.to_owned(),
browse_id: artist_id,
};
let (mut artist, album_page_params) = self
@ -78,7 +74,7 @@ impl RustyPipeQuery {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: artist_id.to_owned(),
browse_id: artist_id,
};
self.execute_request::<response::MusicArtist, _, _>(
@ -197,25 +193,26 @@ fn map_artist_page(
response::music_item::ItemSection::MusicCarouselShelfRenderer { header, contents } => {
let mut extendable_albums = false;
if let Some(h) = header {
let ep = h
if let Some(button) = h
.music_carousel_shelf_basic_header_renderer
.more_content_button
.button_renderer
.navigation_endpoint;
if let Some(bep) = ep.browse_endpoint {
if let Some(cfg) = bep.browse_endpoint_context_supported_configs {
match cfg.browse_endpoint_context_music_config.page_type {
PageType::Playlist => {
if videos_playlist_id.is_none() {
videos_playlist_id = Some(bep.browse_id);
{
if let Some(bep) =
button.button_renderer.navigation_endpoint.browse_endpoint
{
if let Some(cfg) = bep.browse_endpoint_context_supported_configs {
match cfg.browse_endpoint_context_music_config.page_type {
PageType::Playlist => {
if videos_playlist_id.is_none() {
videos_playlist_id = Some(bep.browse_id);
}
}
PageType::Artist => {
album_page_params.push(bep.params);
extendable_albums = true;
}
_ => {}
}
PageType::Artist => {
album_page_params.push(bep.params);
extendable_albums = true;
}
_ => {}
}
}
}

441
src/client/music_details.rs Normal file
View file

@ -0,0 +1,441 @@
use std::borrow::Cow;
use serde::Serialize;
use crate::{
error::{Error, ExtractionError},
model::{ArtistId, Lyrics, MusicRelated, Paginator, TrackDetails, TrackItem},
param::Language,
serializer::MapResult,
};
use super::{
response::{
self,
music_item::{map_queue_item, MusicListMapper},
},
ClientType, MapResponse, QBrowse, RustyPipeQuery, YTContext,
};
#[derive(Debug, Serialize)]
struct QMusicDetails<'a> {
context: YTContext<'a>,
video_id: &'a str,
enable_persistent_playlist_panel: bool,
is_audio_only: bool,
tuner_setting_value: &'a str,
}
#[derive(Debug, Serialize)]
struct QRadio<'a> {
context: YTContext<'a>,
playlist_id: &'a str,
params: &'a str,
enable_persistent_playlist_panel: bool,
is_audio_only: bool,
tuner_setting_value: &'a str,
}
impl RustyPipeQuery {
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_lyrics(&self, lyrics_id: &str) -> Result<Lyrics, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: lyrics_id,
};
self.execute_request::<response::MusicLyrics, _, _>(
ClientType::DesktopMusic,
"music_lyrics",
lyrics_id,
"browse",
&request_body,
)
.await
}
pub async fn music_related(&self, related_id: &str) -> Result<MusicRelated, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: related_id,
};
self.execute_request::<response::MusicRelated, _, _>(
ClientType::DesktopMusic,
"music_related",
related_id,
"browse",
&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,
playlist_id: radio_id,
params: "wAEB8gECeAE%3D",
enable_persistent_playlist_panel: true,
is_audio_only: true,
tuner_setting_value: "AUTOMIX_SETTING_NORMAL",
};
self.execute_request::<response::MusicDetails, _, _>(
ClientType::DesktopMusic,
"music_radio",
radio_id,
"next",
&request_body,
)
.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<TrackDetails> for response::MusicDetails {
fn map_response(
self,
id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> 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: 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,
})
}
}
impl MapResponse<Lyrics> for response::MusicLyrics {
fn map_response(
self,
_id: &str,
_lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Lyrics>, ExtractionError> {
let lyrics = self
.contents
.section_list_renderer
.contents
.into_iter()
.find_map(|item| item.music_description_shelf_renderer)
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?;
Ok(MapResult {
c: Lyrics {
body: lyrics.description,
footer: lyrics.footer,
},
warnings: Vec::new(),
})
}
}
impl MapResponse<MusicRelated> for response::MusicRelated {
fn map_response(
self,
_id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<MusicRelated>, ExtractionError> {
// Find artist
let artist_id = self
.contents
.section_list_renderer
.contents
.iter()
.find_map(|section| match section {
response::music_item::ItemSection::MusicShelfRenderer(_) => None,
response::music_item::ItemSection::MusicCarouselShelfRenderer {
header, ..
} => header.as_ref().and_then(|h| {
h.music_carousel_shelf_basic_header_renderer
.title
.0
.iter()
.find_map(|c| {
let artist = ArtistId::from(c.clone());
if artist.id.is_some() {
Some(artist)
} else {
None
}
})
}),
response::music_item::ItemSection::None => None,
});
let mut mapper_tracks = MusicListMapper::new(lang);
let mut mapper = match artist_id {
Some(artist_id) => MusicListMapper::with_artist(lang, artist_id),
None => MusicListMapper::new(lang),
};
let mut sections = self.contents.section_list_renderer.contents.into_iter();
if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer {
contents,
..
}) = sections.next()
{
mapper_tracks.map_response(contents);
}
sections.for_each(|section| match section {
response::music_item::ItemSection::MusicShelfRenderer(shelf) => {
mapper.map_response(shelf.contents);
}
response::music_item::ItemSection::MusicCarouselShelfRenderer { contents, .. } => {
mapper.map_response(contents);
}
response::music_item::ItemSection::None => {}
});
let mapped_tracks = mapper_tracks.conv_items();
let mut mapped = mapper.group_items();
let mut warnings = mapped_tracks.warnings;
warnings.append(&mut mapped.warnings);
Ok(MapResult {
c: MusicRelated {
tracks: mapped_tracks.c,
other_versions: mapped.c.tracks,
albums: mapped.c.albums,
artists: mapped.c.artists,
playlists: mapped.c.playlists,
},
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);
}
#[test]
fn map_lyrics() {
let json_path = Path::new("testfiles/music_details/lyrics.json");
let json_file = File::open(json_path).unwrap();
let lyrics: response::MusicLyrics =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Lyrics> = lyrics.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_lyrics"), map_res.c);
}
#[test]
fn map_related() {
let json_path = Path::new("testfiles/music_details/related.json");
let json_file = File::open(json_path).unwrap();
let lyrics: response::MusicRelated =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicRelated> = lyrics.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_related"), map_res.c);
}
}

View file

@ -20,7 +20,7 @@ impl RustyPipeQuery {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: "VL".to_owned() + playlist_id,
browse_id: &format!("VL{}", playlist_id),
};
self.execute_request::<response::MusicPlaylist, _, _>(
@ -37,7 +37,7 @@ impl RustyPipeQuery {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: album_id.to_owned(),
browse_id: album_id,
};
self.execute_request::<response::MusicPlaylist, _, _>(

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

@ -18,7 +18,7 @@ impl RustyPipeQuery {
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QBrowse {
context,
browse_id: "VL".to_owned() + playlist_id,
browse_id: &format!("VL{}", playlist_id),
};
self.execute_request::<response::Playlist, _, _>(

View file

@ -58,7 +58,7 @@ pub(crate) struct TabRenderer {
pub(crate) struct TabContent {
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub section_list_renderer: Option<SectionListRenderer>,
pub section_list_renderer: Option<ContentsRenderer<ItemSectionRendererWrap>>,
/// Seems to be currently A/B tested, as of 11.10.2022
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
@ -83,12 +83,6 @@ pub(crate) struct ChannelTabWebCommandMetadata {
pub url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SectionListRenderer {
pub contents: Vec<ItemSectionRendererWrap>,
}
/// Seems to be currently A/B tested, as of 11.10.2022
#[serde_as]
#[derive(Debug, Deserialize)]

View file

@ -1,5 +1,6 @@
pub(crate) mod channel;
pub(crate) mod music_artist;
pub(crate) mod music_details;
pub(crate) mod music_item;
pub(crate) mod music_playlist;
pub(crate) mod music_search;
@ -14,6 +15,9 @@ pub(crate) mod video_item;
pub(crate) use channel::Channel;
pub(crate) use music_artist::MusicArtist;
pub(crate) use music_artist::MusicArtistAlbums;
pub(crate) use music_details::MusicDetails;
pub(crate) use music_details::MusicLyrics;
pub(crate) use music_details::MusicRelated;
pub(crate) use music_item::MusicContinuation;
pub(crate) use music_playlist::MusicPlaylist;
pub(crate) use music_search::MusicSearch;
@ -60,6 +64,12 @@ pub(crate) struct Tab<T> {
pub tab_renderer: ContentRenderer<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SectionList<T> {
pub section_list_renderer: ContentsRenderer<T>,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ThumbnailsWrap {
@ -211,6 +221,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

@ -5,7 +5,7 @@ use crate::serializer::{text::Text, MapResult, VecLogError};
use super::{
music_item::{ItemSection, MusicResponseItem, MusicThumbnailRenderer},
ContentsRenderer, Tab,
ContentsRenderer, SectionList, Tab,
};
/// Response model for YouTube Music artists
@ -22,12 +22,6 @@ pub(crate) struct Contents<T> {
pub single_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<T>>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SectionList<T> {
pub section_list_renderer: ContentsRenderer<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Header {

View file

@ -0,0 +1,127 @@
use serde::Deserialize;
use serde_with::serde_as;
use crate::serializer::text::Text;
use super::{
music_item::{ItemSection, PlaylistPanelRenderer},
ContentRenderer, SectionList,
};
/// Response model for YouTube Music track details
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
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,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicLyrics {
pub contents: SectionList<LyricsContents>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LyricsContents {
pub music_description_shelf_renderer: Option<LyricsRenderer>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LyricsRenderer {
#[serde_as(as = "Text")]
pub description: String,
#[serde_as(as = "Text")]
pub footer: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicRelated {
pub contents: SectionList<ItemSection>,
}

View file

@ -17,7 +17,7 @@ use crate::{
use super::{
url_endpoint::{BrowseEndpointWrap, NavigationEndpoint, PageType},
ContentsRenderer, MusicContinuationData, ThumbnailsWrap,
ContentsRenderer, MusicContinuationData, Thumbnails, ThumbnailsWrap,
};
#[serde_as]
@ -27,8 +27,6 @@ pub(crate) enum ItemSection {
#[serde(alias = "musicPlaylistShelfRenderer")]
MusicShelfRenderer(MusicShelf),
MusicCarouselShelfRenderer {
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
header: Option<MusicCarouselShelfHeader>,
#[serde_as(as = "VecLogError<_>")]
contents: MapResult<Vec<MusicResponseItem>>,
@ -136,13 +134,15 @@ pub(crate) struct ListMusicItem {
pub navigation_endpoint: Option<NavigationEndpoint>,
#[serde(default)]
pub flex_column_display_style: FlexColumnDisplayStyle,
#[serde(default)]
pub item_height: ItemHeight,
/// Album track number
#[serde_as(as = "Option<Text>")]
pub index: Option<String>,
pub menu: Option<MusicItemMenu>,
}
#[derive(Default, Debug, Deserialize)]
#[derive(Default, Debug, Copy, Clone, Deserialize)]
pub(crate) enum FlexColumnDisplayStyle {
#[serde(rename = "MUSIC_RESPONSIVE_LIST_ITEM_FLEX_COLUMN_DISPLAY_STYLE_TWO_LINE_STACK")]
TwoLines,
@ -151,6 +151,15 @@ pub(crate) enum FlexColumnDisplayStyle {
Default,
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Deserialize)]
pub(crate) enum ItemHeight {
#[serde(rename = "MUSIC_RESPONSIVE_LIST_ITEM_HEIGHT_MEDIUM_COMPACT")]
Compact,
#[default]
#[serde(other)]
Default,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -176,6 +185,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 +287,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)]
@ -251,7 +304,9 @@ pub(crate) struct MusicCarouselShelfHeader {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicCarouselShelfHeaderRenderer {
pub more_content_button: MoreContentButton,
pub more_content_button: Option<MoreContentButton>,
#[serde(default)]
pub title: TextComponents,
}
#[derive(Debug, Deserialize)]
@ -479,27 +534,42 @@ impl MusicListMapper {
{
// Search result
FlexColumnDisplayStyle::TwoLines => {
let mut subtitle_parts = c2
.ok_or_else(|| format!("track {}: could not get subtitle", id))?
.renderer
.text
.split(util::DOT_SEPARATOR)
.into_iter();
// Is it a podcast episode?
if subtitle_parts.len() <= 3 && c3.is_some() {
(subtitle_parts.rev().next(), None, None)
} else {
// Skip first part (track type)
if subtitle_parts.len() > 3 {
subtitle_parts.next();
}
// Is this a related track?
if !is_video && item.item_height == ItemHeight::Compact {
(
subtitle_parts.next(),
subtitle_parts.next(),
subtitle_parts.next(),
c2.map(TextComponents::from),
c3.map(TextComponents::from),
None,
)
} else {
let mut subtitle_parts = c2
.ok_or_else(|| {
format!("track {}: could not get subtitle", id)
})?
.renderer
.text
.split(util::DOT_SEPARATOR)
.into_iter();
// Is this a related video?
if item.item_height == ItemHeight::Compact {
(subtitle_parts.next(), subtitle_parts.next(), None)
}
// Is it a podcast episode?
else if subtitle_parts.len() <= 3 && c3.is_some() {
(subtitle_parts.rev().next(), None, None)
} else {
// Skip first part (track type)
if subtitle_parts.len() > 3 {
subtitle_parts.next();
}
(
subtitle_parts.next(),
subtitle_parts.next(),
subtitle_parts.next(),
)
}
}
}
// Playlist item
@ -712,6 +782,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 +861,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 +894,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

@ -3,7 +3,7 @@ use serde_with::{serde_as, VecSkipError};
use crate::serializer::{ignore_any, text::Text};
use super::{music_item::MusicShelf, ContentsRenderer, Tab};
use super::{music_item::MusicShelf, ContentsRenderer, SectionList, Tab};
/// Response model for YouTube Music search
#[derive(Debug, Deserialize)]
@ -15,13 +15,7 @@ pub(crate) struct MusicSearch {
#[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>,
pub tabbed_search_results_renderer: ContentsRenderer<Tab<SectionList<ItemSection>>>,
}
#[allow(clippy::enum_variant_names)]

View file

@ -6,7 +6,8 @@ use crate::serializer::{ignore_any, MapResult, VecLogError};
use crate::util::MappingError;
use super::{
Alert, ContentsRenderer, ContinuationEndpoint, ResponseContext, Tab, Thumbnails, ThumbnailsWrap,
Alert, ContentsRenderer, ContinuationEndpoint, ResponseContext, SectionList, Tab, Thumbnails,
ThumbnailsWrap,
};
#[serde_as]
@ -33,13 +34,7 @@ pub(crate) struct PlaylistCont {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Contents {
pub two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SectionList {
pub section_list_renderer: ContentsRenderer<ItemSection>,
pub two_column_browse_results_renderer: ContentsRenderer<Tab<SectionList<ItemSection>>>,
}
#[derive(Debug, Deserialize)]

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,8 @@
---
source: src/client/music_details.rs
expression: map_res.c
---
Lyrics(
body: "Eyes, in the sky, gazing far into the night\nI raise my hand to the fire, but it\'s no use\n\'Cause you can\'t stop it from shining through\nIt\'s true\nBaby let the light shine through\nIf you believe it\'s true\nBaby won\'t you let the light shine through\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nWon\'t you let the light shine through\n\nEyes, in the sky, gazing far into the night\nI raise my hand to the fire, but it\'s no use\n\'Cause you can\'t stop it from shining through\nIt\'s true\nBaby let the light shine through\nIf you believe it\'s true\nBaby won\'t you let the light shine through\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you",
footer: "Source: Musixmatch",
)

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

@ -15,7 +15,7 @@ impl RustyPipeQuery {
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QBrowse {
context,
browse_id: "FEwhat_to_watch".to_owned(),
browse_id: "FEwhat_to_watch",
};
self.execute_request::<response::Startpage, _, _>(
@ -32,7 +32,7 @@ impl RustyPipeQuery {
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QBrowse {
context,
browse_id: "FEtrending".to_owned(),
browse_id: "FEtrending",
};
self.execute_request::<response::Trending, _, _>(

View file

@ -1181,6 +1181,7 @@ pub struct MusicArtist {
/// YouTube Music search result
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct MusicSearchResult {
/// Found tracks
pub tracks: Vec<TrackItem>,
@ -1219,6 +1220,7 @@ pub enum MusicEntityType {
/// Filtered YouTube Music search result
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct MusicSearchFiltered<T> {
pub items: Paginator<T>,
/// Corrected search query
@ -1228,3 +1230,38 @@ pub struct MusicSearchFiltered<T> {
/// search results page.
pub corrected_query: Option<String>,
}
/// Music track details
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct TrackDetails {
pub track: TrackItem,
pub lyrics_id: Option<String>,
pub related_id: Option<String>,
}
/// Song lyrics
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct Lyrics {
/// Lyrics text
pub body: String,
/// Footer (contains lyrics source)
pub footer: String,
}
/// YouTube Music related entities
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct MusicRelated {
/// Related tracks
pub tracks: Vec<TrackItem>,
/// Other versions of the same track
pub other_versions: Vec<TrackItem>,
/// Related albums
pub albums: Vec<AlbumItem>,
/// Related artists
pub artists: Vec<ArtistItem>,
/// Related playlists
pub playlists: Vec<MusicPlaylistItem>,
}

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

View file

@ -0,0 +1,103 @@
{
"contents": {
"sectionListRenderer": {
"contents": [
{
"musicDescriptionShelfRenderer": {
"description": {
"runs": [
{
"text": "Eyes, in the sky, gazing far into the night\nI raise my hand to the fire, but it's no use\n'Cause you can't stop it from shining through\nIt's true\nBaby let the light shine through\nIf you believe it's true\nBaby won't you let the light shine through\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nWon't you let the light shine through\n\nEyes, in the sky, gazing far into the night\nI raise my hand to the fire, but it's no use\n'Cause you can't stop it from shining through\nIt's true\nBaby let the light shine through\nIf you believe it's true\nBaby won't you let the light shine through\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you"
}
]
},
"footer": {
"runs": [
{
"text": "Source: Musixmatch"
}
]
},
"maxCollapsedLines": 0,
"maxExpandedLines": 0,
"onShowCommands": [
{
"clickTrackingParams": "CAIQ2fQEGAAiEwjpyODp3aT7AhUe0REIHV-EAKs=",
"logLyricEventCommand": {
"serializedLyricInfo": "Eg10X3E4eGptbTdDbEpMGAIiCDE4NTc2OTU0"
}
}
],
"trackingParams": "CAIQ2fQEGAAiEwjpyODp3aT7AhUe0REIHV-EAKs="
}
}
],
"trackingParams": "CAEQui8iEwjpyODp3aT7AhUe0REIHV-EAKs="
}
},
"responseContext": {
"serviceTrackingParams": [
{
"params": [
{
"key": "has_unlimited_entitlement",
"value": "False"
},
{
"key": "browse_id",
"value": "MPLYt_q8xjmm7ClJL"
},
{
"key": "logged_in",
"value": "0"
},
{
"key": "e",
"value": "1714259,23804281,23848211,23882503,23918597,23934970,23940248,23946420,23966208,23983296,23998056,24001373,24002022,24002025,24004644,24007246,24034168,24036948,24077241,24078244,24080738,24108447,24120819,24135310,24135692,24140247,24147965,24161116,24162920,24164186,24169501,24181174,24185614,24187043,24187377,24191629,24197450,24199724,24200839,24209350,24211178,24216167,24217535,24219713,24228638,24230619,24241378,24248091,24253729,24255165,24255543,24255545,24260783,24262346,24263796,24267564,24267570,24268142,24269410,24278596,24279196,24279852,24281671,24283556,24286005,24286017,24287327,24288043,24290971,24292955,24293803,24295708,24299747,24390374,24390675,24391018,24391537,24392450,24392500,24394549,24396819,24397910,24398998,24399052,24399916,24401557,24402891,24403118,24404641,24406605,24407199,24407665,24409401,24410273,24413557,24413558,24415139,24590921,39322504,39322574"
}
],
"service": "GFEEDBACK"
},
{
"params": [
{
"key": "c",
"value": "WEB_REMIX"
},
{
"key": "cver",
"value": "1.20221107.01.00"
},
{
"key": "yt_li",
"value": "0"
},
{
"key": "GetBrowseTrackLyricsPage_rid",
"value": "0x8d12ba6a166bab0c"
}
],
"service": "CSI"
},
{
"params": [
{
"key": "client.version",
"value": "1.20000101"
},
{
"key": "client.name",
"value": "WEB_REMIX"
},
{
"key": "client.fexp",
"value": "24217535,24295708,24590921,24401557,24410273,24120819,24278596,24001373,24290971,24077241,24292955,23848211,24036948,24255545,24286005,24219713,39322504,24279196,24002022,24080738,24413557,23918597,24404641,24209350,39322574,24390675,23940248,24169501,24391018,24002025,24262346,24392500,24396819,24255165,24241378,24279852,24281671,24162920,1714259,24181174,24200839,24034168,24108447,24402891,24216167,24197450,24253729,23998056,24283556,24007246,24403118,24255543,24406605,24161116,24293803,24004644,24230619,24399052,24191629,24211178,24407199,24135310,23882503,24260783,24263796,24391537,24392450,23983296,24415139,24267564,24187043,24409401,24286017,24287327,24399916,23934970,24228638,24398998,24185614,24268142,24394549,24187377,24140247,24164186,24135692,24269410,24288043,24407665,24199724,24390374,24397910,23966208,24267570,24078244,24248091,24147965,24413558,23946420,23804281,24299747"
}
],
"service": "ECATCHER"
}
],
"visitorData": "Cgs3R19icmhVUkNYOCikibabBg%3D%3D"
},
"trackingParams": "CAAQhGciEwjpyODp3aT7AhUe0REIHV-EAKs="
}

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
---
source: tests/youtube.rs
assertion_line: 1770
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: "[view_count]",
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: "[view_count]",
is_video: false,
track_nr: None,
),
lyrics_id: Some("MPLYt_4xbv14CiQJm-1"),
related_id: Some("MPTRt_4xbv14CiQJm-1"),
)

View file

@ -0,0 +1,8 @@
---
source: tests/youtube.rs
expression: lyrics
---
Lyrics(
body: "Eyes, in the sky, gazing far into the night\nI raise my hand to the fire, but it\'s no use\n\'Cause you can\'t stop it from shining through\nIt\'s true\nBaby let the light shine through\nIf you believe it\'s true\nBaby won\'t you let the light shine through\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nWon\'t you let the light shine through\n\nEyes, in the sky, gazing far into the night\nI raise my hand to the fire, but it\'s no use\n\'Cause you can\'t stop it from shining through\nIt\'s true\nBaby let the light shine through\nIf you believe it\'s true\nBaby won\'t you let the light shine through\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you\nFor you",
footer: "Source: Musixmatch",
)

View file

@ -1,6 +1,8 @@
use std::collections::HashSet;
use std::fmt::Display;
use fancy_regex::Regex;
use once_cell::sync::Lazy;
use rstest::rstest;
use time::macros::date;
use time::OffsetDateTime;
@ -858,7 +860,7 @@ async fn channel_shorts() {
.contains("Hi, I\u{2019}m Tina, aka Doobydobap"));
assert_eq!(
channel.vanity_url.as_ref().unwrap(),
"https://www.youtube.com/c/Doobydobap"
"https://www.youtube.com/@Doobydobap"
);
assert!(!channel.banner.is_empty(), "got no banners");
assert!(!channel.mobile_banner.is_empty(), "got no mobile banners");
@ -964,7 +966,7 @@ fn assert_channel_eevblog<T>(channel: &Channel<T>) {
assert!(!channel.tags.is_empty(), "got no tags");
assert_eq!(
channel.vanity_url.as_ref().unwrap(),
"https://www.youtube.com/c/EevblogDave"
"https://www.youtube.com/@EEVblog"
);
assert!(!channel.banner.is_empty(), "got no banners");
assert!(!channel.mobile_banner.is_empty(), "got no mobile banners");
@ -1509,9 +1511,14 @@ 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 (i, track) = &res
.tracks
.iter()
.enumerate()
.find(|(_, a)| a.id == "ZeerrnuLi5E")
.unwrap();
assert!(*i < 3);
assert_eq!(track.title, "Black Mamba");
assert_eq!(track.duration.unwrap(), 230);
assert!(!track.cover.is_empty(), "got no cover");
@ -1534,7 +1541,15 @@ async fn music_search_tracks() {
let rp = RustyPipe::builder().strict().build();
let res = rp.query().music_search_tracks("black mamba").await.unwrap();
let track = &res.items.items[0];
let (i, track) = &res
.items
.items
.iter()
.enumerate()
.find(|(_, a)| a.id == "BL-aIpCLWnU")
.unwrap();
assert!(*i < 3);
assert_eq!(track.title, "Black Mamba");
assert!(!track.cover.is_empty(), "got no cover");
assert!(!track.is_video);
@ -1548,7 +1563,6 @@ async fn music_search_tracks() {
);
assert_eq!(track_artist.name, "aespa");
assert_eq!(track.id, "BL-aIpCLWnU");
assert_eq!(track.duration.unwrap(), 175);
let album = track.album.as_ref().unwrap();
@ -1563,7 +1577,15 @@ async fn music_search_videos() {
let rp = RustyPipe::builder().strict().build();
let res = rp.query().music_search_videos("black mamba").await.unwrap();
let track = &res.items.items[0];
let (i, track) = &res
.items
.items
.iter()
.enumerate()
.find(|(_, a)| a.id == "ZeerrnuLi5E")
.unwrap();
assert!(*i < 3);
assert_eq!(track.title, "Black Mamba");
assert!(!track.cover.is_empty(), "got no cover");
assert!(track.is_video);
@ -1577,7 +1599,6 @@ async fn music_search_videos() {
);
assert_eq!(track_artist.name, "aespa");
assert_eq!(track.id, "ZeerrnuLi5E");
assert_eq!(track.duration.unwrap(), 230);
assert_eq!(track.album, None);
assert_gte(track.view_count.unwrap(), 230_000_000, "views");
@ -1648,9 +1669,15 @@ async fn music_search_albums(
let rp = RustyPipe::builder().strict().build();
let res = rp.query().music_search_albums(query).await.unwrap();
let album = &res.items.items[0];
let (i, album) = &res
.items
.items
.iter()
.enumerate()
.find(|(_, a)| a.id == id)
.unwrap();
assert!(*i < 3);
assert_eq!(album.name, name);
assert_eq!(album.id, id);
assert_eq!(album.artists.len(), 1);
let album_artist = &album.artists[0];
@ -1755,6 +1782,164 @@ 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");
if name == "mv" {
assert_gte(track.track.view_count.unwrap(), 235_000_000, "view count");
} else {
assert!(track.track.view_count.is_none());
}
insta::assert_ron_snapshot!(format!("music_details_{}", name), track,
{
".track.cover" => "[cover]",
".track.view_count" => "[view_count]"
}
);
}
#[tokio::test]
async fn music_lyrics() {
let rp = RustyPipe::builder().strict().build();
let track = rp.query().music_details("n4tK7LYFxI0").await.unwrap();
let lyrics = rp
.query()
.music_lyrics(&track.lyrics_id.unwrap())
.await
.unwrap();
insta::assert_ron_snapshot!(lyrics);
}
#[rstest]
#[case::a("7nigXQS1Xb0", true)]
#[case::b("4t3SUDZCBaQ", false)]
#[tokio::test]
async fn music_related(#[case] id: &str, #[case] full: bool) {
let rp = RustyPipe::builder().strict().build();
let track = rp.query().music_details(id).await.unwrap();
let related = rp
.query()
.music_related(&track.related_id.unwrap())
.await
.unwrap();
let n_tracks = related.tracks.len();
let mut track_artists = 0;
let mut track_artist_ids = 0;
let mut n_tracks_ytm = 0;
let mut track_albums = 0;
for track in related.tracks {
assert_video_id(&track.id);
assert!(!track.title.is_empty());
assert!(!track.cover.is_empty(), "got no cover");
if let Some(artist_id) = track.artist_id {
assert_channel_id(&artist_id);
track_artist_ids += 1;
}
let artist = track.artists.first().unwrap();
assert!(!artist.name.is_empty());
if let Some(artist_id) = &artist.id {
assert_channel_id(artist_id);
track_artists += 1;
}
if track.is_video {
assert!(track.album.is_none());
assert_gte(track.view_count.unwrap(), 10_000, "views")
} else {
n_tracks_ytm += 1;
assert!(track.view_count.is_none());
if let Some(album) = track.album {
assert_album_id(&album.id);
assert!(!album.name.is_empty());
track_albums += 1;
}
}
}
assert_gte(n_tracks, 20, "tracks");
assert_gte(n_tracks_ytm, 10, "tracks_ytm");
assert_gte(track_artists, n_tracks - 3, "track_artists");
assert_gte(track_artist_ids, n_tracks - 3, "track_artists");
assert_gte(track_albums, n_tracks_ytm - 3, "track_artists");
if full {
assert_gte(related.albums.len(), 10, "albums");
for album in related.albums {
assert_album_id(&album.id);
assert!(!album.name.is_empty());
assert!(!album.cover.is_empty(), "got no cover");
let artist = album.artists.first().unwrap();
assert_channel_id(&artist.id.as_ref().unwrap());
assert!(!artist.name.is_empty());
}
assert_gte(related.artists.len(), 10, "artists");
for artist in related.artists {
assert_channel_id(&artist.id);
assert!(!artist.name.is_empty());
assert!(!artist.avatar.is_empty(), "got no avatar");
assert_gte(artist.subscriber_count.unwrap(), 5000, "subscribers")
}
assert_gte(related.playlists.len(), 10, "playlists");
for playlist in related.playlists {
assert_playlist_id(&playlist.id);
assert!(!playlist.name.is_empty());
assert!(
!playlist.thumbnail.is_empty(),
"pl: {}, got no playlist thumbnail",
playlist.id
);
if !playlist.from_ytm {
assert!(
playlist.channel.is_some(),
"pl: {}, got no channel",
playlist.id
);
let channel = playlist.channel.unwrap();
assert_channel_id(&channel.id);
assert!(!channel.name.is_empty());
assert_gte(playlist.track_count.unwrap(), 2, "tracks");
} else {
assert!(playlist.channel.is_none());
}
}
}
}
#[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(), 10, 1).await;
}
//#TESTUTIL
/// Assert equality within 10% margin
@ -1792,3 +1977,46 @@ async fn assert_next<T: FromYtItem>(
);
}
}
fn assert_video_id(id: &str) {
static VIDEO_ID_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_-]{11}$").unwrap());
assert!(
VIDEO_ID_REGEX.is_match(id).unwrap_or_default(),
"invalid video id: `{}`",
id
);
}
fn assert_channel_id(id: &str) {
static CHANNEL_ID_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^UC[A-Za-z0-9_-]{22}$").unwrap());
assert!(
CHANNEL_ID_REGEX.is_match(id).unwrap_or_default(),
"invalid channel id: `{}`",
id
);
}
fn assert_album_id(id: &str) {
static ALBUM_ID_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^MPREb_[A-Za-z0-9_-]{11}$").unwrap());
assert!(
ALBUM_ID_REGEX.is_match(id).unwrap_or_default(),
"invalid album id: `{}`",
id
);
}
fn assert_playlist_id(id: &str) {
static PLAYLIST_ID_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(?:PL|RD|OLAK)[A-Za-z0-9_-]{30,}$").unwrap());
assert!(
PLAYLIST_ID_REGEX.is_match(id).unwrap_or_default(),
"invalid album id: `{}`",
id
);
}