fix: fetch artist albums continuation
This commit is contained in:
parent
bef2fc3df6
commit
93dbd0d20c
17 changed files with 50309 additions and 38906 deletions
|
|
@ -38,6 +38,7 @@ use crate::{
|
||||||
cache::{CacheStorage, FileStorage, DEFAULT_CACHE_FILE},
|
cache::{CacheStorage, FileStorage, DEFAULT_CACHE_FILE},
|
||||||
deobfuscate::DeobfData,
|
deobfuscate::DeobfData,
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
|
model::ArtistId,
|
||||||
param::{Country, Language},
|
param::{Country, Language},
|
||||||
report::{FileReporter, Level, Report, Reporter, RustyPipeInfo, DEFAULT_REPORT_DIR},
|
report::{FileReporter, Level, Report, Reporter, RustyPipeInfo, DEFAULT_REPORT_DIR},
|
||||||
serializer::MapResult,
|
serializer::MapResult,
|
||||||
|
|
@ -1415,7 +1416,7 @@ impl RustyPipeQuery {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::debug!("mapped response");
|
tracing::trace!("mapped response");
|
||||||
Ok(RequestResult { res, status, body })
|
Ok(RequestResult { res, status, body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1468,10 +1469,9 @@ impl RustyPipeQuery {
|
||||||
/// - `method`: HTTP method
|
/// - `method`: HTTP method
|
||||||
/// - `endpoint`: YouTube API endpoint (`https://www.youtube.com/youtubei/v1/<XYZ>?key=...`)
|
/// - `endpoint`: YouTube API endpoint (`https://www.youtube.com/youtubei/v1/<XYZ>?key=...`)
|
||||||
/// - `body`: Serializable request body to be sent in json format
|
/// - `body`: Serializable request body to be sent in json format
|
||||||
/// - `visitor_data`: YouTube visitor data cookie
|
/// - `ctx_src`: Context source (additional parameters for fetching and mapping, used to build the MapRespCtx)
|
||||||
/// - `deobf`: Deobfuscator (is passed to the mapper to deobfuscate stream URLs).
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn execute_request_deobf<
|
async fn execute_request_ctx<
|
||||||
R: DeserializeOwned + MapResponse<M> + Debug,
|
R: DeserializeOwned + MapResponse<M> + Debug,
|
||||||
M,
|
M,
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
|
|
@ -1482,25 +1482,25 @@ impl RustyPipeQuery {
|
||||||
id: &str,
|
id: &str,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
body: &B,
|
body: &B,
|
||||||
visitor_data: Option<&str>,
|
ctx_src: MapRespCtxSource<'_>,
|
||||||
deobf: Option<&DeobfData>,
|
|
||||||
) -> Result<M, Error> {
|
) -> Result<M, Error> {
|
||||||
tracing::debug!("getting {}({})", operation, id);
|
tracing::debug!("getting {}({})", operation, id);
|
||||||
|
|
||||||
let request = self
|
|
||||||
.request_builder(ctype, endpoint, visitor_data)
|
|
||||||
.await
|
|
||||||
.json(body)
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let ctx = MapRespCtx {
|
let ctx = MapRespCtx {
|
||||||
id,
|
id,
|
||||||
lang: self.opts.lang,
|
lang: self.opts.lang,
|
||||||
deobf,
|
deobf: ctx_src.deobf,
|
||||||
visitor_data: visitor_data.or(self.opts.visitor_data.as_deref()),
|
visitor_data: ctx_src.visitor_data.or(self.opts.visitor_data.as_deref()),
|
||||||
client_type: ctype,
|
client_type: ctype,
|
||||||
|
artist: ctx_src.artist,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let request = self
|
||||||
|
.request_builder(ctype, endpoint, ctx.visitor_data)
|
||||||
|
.await
|
||||||
|
.json(body)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
let req_res = self.yt_request::<R, M>(&request, &ctx).await?;
|
let req_res = self.yt_request::<R, M>(&request, &ctx).await?;
|
||||||
|
|
||||||
// Uncomment to debug response text
|
// Uncomment to debug response text
|
||||||
|
|
@ -1533,7 +1533,7 @@ impl RustyPipeQuery {
|
||||||
operation: &format!("{operation}({id})"),
|
operation: &format!("{operation}({id})"),
|
||||||
error,
|
error,
|
||||||
msgs,
|
msgs,
|
||||||
deobf_data: deobf.cloned(),
|
deobf_data: ctx.deobf.cloned(),
|
||||||
http_request: crate::report::HTTPRequest {
|
http_request: crate::report::HTTPRequest {
|
||||||
url: request.url().as_str(),
|
url: request.url().as_str(),
|
||||||
method: request.method().as_str(),
|
method: request.method().as_str(),
|
||||||
|
|
@ -1587,10 +1587,18 @@ impl RustyPipeQuery {
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
body: &B,
|
body: &B,
|
||||||
) -> Result<M, Error> {
|
) -> Result<M, Error> {
|
||||||
self.execute_request_deobf::<R, M, B>(ctype, operation, id, endpoint, body, None, None)
|
self.execute_request_ctx::<R, M, B>(
|
||||||
.await
|
ctype,
|
||||||
|
operation,
|
||||||
|
id,
|
||||||
|
endpoint,
|
||||||
|
body,
|
||||||
|
MapRespCtxSource::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
/// Execute a request to the YouTube API, then map the response.
|
/// Execute a request to the YouTube API, then map the response.
|
||||||
///
|
///
|
||||||
/// Creates a report in case of failure for easy debugging.
|
/// Creates a report in case of failure for easy debugging.
|
||||||
|
|
@ -1629,6 +1637,7 @@ impl RustyPipeQuery {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
/// Execute a request to the YouTube API and return the response string
|
/// Execute a request to the YouTube API and return the response string
|
||||||
///
|
///
|
||||||
|
|
@ -1664,6 +1673,14 @@ struct MapRespCtx<'a> {
|
||||||
deobf: Option<&'a DeobfData>,
|
deobf: Option<&'a DeobfData>,
|
||||||
visitor_data: Option<&'a str>,
|
visitor_data: Option<&'a str>,
|
||||||
client_type: ClientType,
|
client_type: ClientType,
|
||||||
|
artist: Option<ArtistId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MapRespCtxSource<'a> {
|
||||||
|
visitor_data: Option<&'a str>,
|
||||||
|
deobf: Option<&'a DeobfData>,
|
||||||
|
artist: Option<ArtistId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> MapRespCtx<'a> {
|
impl<'a> MapRespCtx<'a> {
|
||||||
|
|
@ -1676,6 +1693,16 @@ impl<'a> MapRespCtx<'a> {
|
||||||
deobf: None,
|
deobf: None,
|
||||||
visitor_data: None,
|
visitor_data: None,
|
||||||
client_type: ClientType::Desktop,
|
client_type: ClientType::Desktop,
|
||||||
|
artist: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> MapRespCtxSource<'a> {
|
||||||
|
fn visitor_data(visitor_data: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
visitor_data: Some(visitor_data),
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,19 @@ use regex::Regex;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
client::response::url_endpoint::NavigationEndpoint,
|
client::{response::url_endpoint::NavigationEndpoint, MapRespCtxSource, QContinuation},
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
model::{AlbumItem, ArtistId, MusicArtist},
|
model::{
|
||||||
|
paginator::Paginator, traits::FromYtItem, AlbumItem, ArtistId, MusicArtist, MusicItem,
|
||||||
|
},
|
||||||
|
param::{AlbumFilter, AlbumOrder},
|
||||||
serializer::MapResult,
|
serializer::MapResult,
|
||||||
util,
|
util::{self, ProtoBuilder},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
response::{self, music_item::MusicListMapper, url_endpoint::PageType},
|
response::{self, music_item::MusicListMapper, url_endpoint::PageType},
|
||||||
ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery,
|
ClientType, MapRespCtx, MapResponse, QBrowse, QBrowseParams, RustyPipeQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl RustyPipeQuery {
|
impl RustyPipeQuery {
|
||||||
|
|
@ -56,7 +59,9 @@ impl RustyPipeQuery {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if can_fetch_more {
|
if can_fetch_more {
|
||||||
artist.albums = self.music_artist_albums(artist_id).await?;
|
artist.albums = self
|
||||||
|
.music_artist_albums(artist_id, None, Some(AlbumOrder::Recency))
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(artist)
|
Ok(artist)
|
||||||
|
|
@ -73,21 +78,62 @@ impl RustyPipeQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a list of all albums of a YouTube Music artist
|
/// Get a list of all albums of a YouTube Music artist
|
||||||
pub async fn music_artist_albums(&self, artist_id: &str) -> Result<Vec<AlbumItem>, Error> {
|
pub async fn music_artist_albums(
|
||||||
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
|
&self,
|
||||||
let request_body = QBrowse {
|
artist_id: &str,
|
||||||
context,
|
filter: Option<AlbumFilter>,
|
||||||
|
order: Option<AlbumOrder>,
|
||||||
|
) -> Result<Vec<AlbumItem>, Error> {
|
||||||
|
let visitor_data = self.get_visitor_data().await?;
|
||||||
|
let context = self
|
||||||
|
.get_context(ClientType::DesktopMusic, true, Some(&visitor_data))
|
||||||
|
.await;
|
||||||
|
let request_body = QBrowseParams {
|
||||||
|
context: context.clone(),
|
||||||
browse_id: &format!("{}{}", util::ARTIST_DISCOGRAPHY_PREFIX, artist_id),
|
browse_id: &format!("{}{}", util::ARTIST_DISCOGRAPHY_PREFIX, artist_id),
|
||||||
|
params: &albums_param(filter, order),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request::<response::MusicArtistAlbums, _, _>(
|
let first_page = self
|
||||||
ClientType::DesktopMusic,
|
.execute_request_ctx::<response::MusicArtistAlbums, _, _>(
|
||||||
"music_artist_albums",
|
ClientType::DesktopMusic,
|
||||||
artist_id,
|
"music_artist_albums",
|
||||||
"browse",
|
artist_id,
|
||||||
&request_body,
|
"browse",
|
||||||
)
|
&request_body,
|
||||||
.await
|
MapRespCtxSource::visitor_data(&visitor_data),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut albums = first_page.albums;
|
||||||
|
let mut ctoken = first_page.ctoken;
|
||||||
|
|
||||||
|
while let Some(tkn) = &ctoken {
|
||||||
|
let request_body = QContinuation {
|
||||||
|
context: context.clone(),
|
||||||
|
continuation: tkn,
|
||||||
|
};
|
||||||
|
let resp: Paginator<MusicItem> = self
|
||||||
|
.execute_request_ctx::<response::MusicContinuation, Paginator<MusicItem>, _>(
|
||||||
|
ClientType::DesktopMusic,
|
||||||
|
"music_artist_albums_cont",
|
||||||
|
artist_id,
|
||||||
|
"browse",
|
||||||
|
&request_body,
|
||||||
|
MapRespCtxSource {
|
||||||
|
artist: Some(first_page.artist.clone()),
|
||||||
|
visitor_data: Some(&visitor_data),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if resp.items.is_empty() {
|
||||||
|
tracing::warn!("artist albums [{artist_id}] empty continuation");
|
||||||
|
}
|
||||||
|
ctoken = resp.ctoken;
|
||||||
|
albums.extend(resp.items.into_iter().filter_map(AlbumItem::from_ytm_item));
|
||||||
|
}
|
||||||
|
Ok(albums)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,11 +326,18 @@ fn map_artist_page(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MapResponse<Vec<AlbumItem>> for response::MusicArtistAlbums {
|
#[derive(Debug)]
|
||||||
|
struct FirstAlbumPage {
|
||||||
|
albums: Vec<AlbumItem>,
|
||||||
|
ctoken: Option<String>,
|
||||||
|
artist: ArtistId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MapResponse<FirstAlbumPage> for response::MusicArtistAlbums {
|
||||||
fn map_response(
|
fn map_response(
|
||||||
self,
|
self,
|
||||||
ctx: &MapRespCtx<'_>,
|
ctx: &MapRespCtx<'_>,
|
||||||
) -> Result<MapResult<Vec<AlbumItem>>, ExtractionError> {
|
) -> Result<MapResult<FirstAlbumPage>, ExtractionError> {
|
||||||
// dbg!(&self);
|
// dbg!(&self);
|
||||||
|
|
||||||
let Some(header) = self.header else {
|
let Some(header) = self.header else {
|
||||||
|
|
@ -306,27 +359,55 @@ impl MapResponse<Vec<AlbumItem>> for response::MusicArtistAlbums {
|
||||||
.section_list_renderer
|
.section_list_renderer
|
||||||
.contents;
|
.contents;
|
||||||
|
|
||||||
let mut mapper = MusicListMapper::with_artist(
|
let artist_id = ArtistId {
|
||||||
ctx.lang,
|
id: Some(ctx.id.to_owned()),
|
||||||
ArtistId {
|
name: header.music_header_renderer.title,
|
||||||
id: Some(ctx.id.to_owned()),
|
};
|
||||||
name: header.music_header_renderer.title,
|
let mut mapper = MusicListMapper::with_artist(ctx.lang, artist_id.clone());
|
||||||
},
|
let mut ctoken = None;
|
||||||
);
|
|
||||||
|
|
||||||
for grid in grids {
|
for grid in grids {
|
||||||
mapper.map_response(grid.grid_renderer.items);
|
mapper.map_response(grid.grid_renderer.items);
|
||||||
|
if ctoken.is_none() {
|
||||||
|
ctoken = grid
|
||||||
|
.grid_renderer
|
||||||
|
.continuations
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|g| g.next_continuation_data.continuation);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mapped = mapper.group_items();
|
let mapped = mapper.group_items();
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: mapped.c.albums,
|
c: FirstAlbumPage {
|
||||||
|
albums: mapped.c.albums,
|
||||||
|
ctoken,
|
||||||
|
artist: artist_id,
|
||||||
|
},
|
||||||
warnings: mapped.warnings,
|
warnings: mapped.warnings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn albums_param(filter: Option<AlbumFilter>, order: Option<AlbumOrder>) -> String {
|
||||||
|
let mut pb_filter = ProtoBuilder::new();
|
||||||
|
if let Some(filter) = filter {
|
||||||
|
pb_filter.varint(1, filter as u64);
|
||||||
|
}
|
||||||
|
if let Some(order) = order {
|
||||||
|
pb_filter.varint(2, order as u64);
|
||||||
|
}
|
||||||
|
pb_filter.bytes(3, &[1, 2]);
|
||||||
|
|
||||||
|
let mut pb_48 = ProtoBuilder::new();
|
||||||
|
pb_48.embedded(15, pb_filter);
|
||||||
|
|
||||||
|
let mut pb_3 = ProtoBuilder::new();
|
||||||
|
pb_3.embedded(48, pb_48);
|
||||||
|
pb_3.to_base64()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{fs::File, io::BufReader};
|
use std::{fs::File, io::BufReader};
|
||||||
|
|
@ -366,11 +447,12 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(can_fetch_more, album_page_path.is_some());
|
assert_eq!(can_fetch_more, album_page_path.is_some());
|
||||||
|
|
||||||
|
// Album overview
|
||||||
if let Some(album_page_path) = album_page_path {
|
if let Some(album_page_path) = album_page_path {
|
||||||
let json_file = File::open(album_page_path).unwrap();
|
let json_file = File::open(album_page_path).unwrap();
|
||||||
let resp: response::MusicArtistAlbums =
|
let resp: response::MusicArtistAlbums =
|
||||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||||
let mut map_res: MapResult<Vec<AlbumItem>> =
|
let map_res: MapResult<FirstAlbumPage> =
|
||||||
resp.map_response(&MapRespCtx::test(id)).unwrap();
|
resp.map_response(&MapRespCtx::test(id)).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -378,7 +460,29 @@ mod tests {
|
||||||
"deserialization/mapping warnings: {:?}",
|
"deserialization/mapping warnings: {:?}",
|
||||||
map_res.warnings
|
map_res.warnings
|
||||||
);
|
);
|
||||||
artist.albums.append(&mut map_res.c);
|
artist.albums = map_res.c.albums;
|
||||||
|
|
||||||
|
// Album overview continuation
|
||||||
|
for i in 2..10 {
|
||||||
|
let cont_path =
|
||||||
|
path!(*TESTFILES / "music_artist" / format!("artist_{name}_{i}.json"));
|
||||||
|
if !cont_path.is_file() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let json_file = File::open(cont_path).unwrap();
|
||||||
|
let resp: response::MusicContinuation =
|
||||||
|
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||||
|
let map_res: MapResult<Paginator<MusicItem>> =
|
||||||
|
resp.map_response(&MapRespCtx::test(id)).unwrap();
|
||||||
|
assert!(!map_res.c.items.is_empty());
|
||||||
|
artist.albums.extend(
|
||||||
|
map_res
|
||||||
|
.c
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(AlbumItem::from_ytm_item),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
insta::assert_ron_snapshot!(format!("map_music_artist_{name}"), artist);
|
insta::assert_ron_snapshot!(format!("map_music_artist_{name}"), artist);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use super::{
|
||||||
self,
|
self,
|
||||||
music_item::{map_queue_item, MusicListMapper},
|
music_item::{map_queue_item, MusicListMapper},
|
||||||
},
|
},
|
||||||
ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery, YTContext,
|
ClientType, MapRespCtx, MapRespCtxSource, MapResponse, QBrowse, RustyPipeQuery, YTContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -134,13 +134,13 @@ impl RustyPipeQuery {
|
||||||
tuner_setting_value: "AUTOMIX_SETTING_NORMAL",
|
tuner_setting_value: "AUTOMIX_SETTING_NORMAL",
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request_vdata::<response::MusicDetails, _, _>(
|
self.execute_request_ctx::<response::MusicDetails, _, _>(
|
||||||
ClientType::DesktopMusic,
|
ClientType::DesktopMusic,
|
||||||
"music_radio",
|
"music_radio",
|
||||||
radio_id,
|
radio_id,
|
||||||
"next",
|
"next",
|
||||||
&request_body,
|
&request_body,
|
||||||
Some(&visitor_data),
|
MapRespCtxSource::visitor_data(&visitor_data),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ use super::{
|
||||||
self,
|
self,
|
||||||
music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper},
|
music_item::{map_album_type, map_artist_id, map_artists, MusicListMapper},
|
||||||
},
|
},
|
||||||
ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery,
|
ClientType, MapRespCtx, MapRespCtxSource, MapResponse, QBrowse, RustyPipeQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl RustyPipeQuery {
|
impl RustyPipeQuery {
|
||||||
|
|
@ -42,13 +42,16 @@ impl RustyPipeQuery {
|
||||||
browse_id: &format!("VL{playlist_id}"),
|
browse_id: &format!("VL{playlist_id}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request_vdata::<response::MusicPlaylist, _, _>(
|
self.execute_request_ctx::<response::MusicPlaylist, _, _>(
|
||||||
ClientType::DesktopMusic,
|
ClientType::DesktopMusic,
|
||||||
"music_playlist",
|
"music_playlist",
|
||||||
playlist_id,
|
playlist_id,
|
||||||
"browse",
|
"browse",
|
||||||
&request_body,
|
&request_body,
|
||||||
visitor_data.as_deref(),
|
MapRespCtxSource {
|
||||||
|
visitor_data: visitor_data.as_deref(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ use crate::model::{
|
||||||
use crate::serializer::MapResult;
|
use crate::serializer::MapResult;
|
||||||
|
|
||||||
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
|
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
|
||||||
use super::{response, ClientType, MapRespCtx, MapResponse, QContinuation, RustyPipeQuery};
|
use super::{
|
||||||
|
response, ClientType, MapRespCtx, MapRespCtxSource, MapResponse, QContinuation, RustyPipeQuery,
|
||||||
|
};
|
||||||
|
|
||||||
impl RustyPipeQuery {
|
impl RustyPipeQuery {
|
||||||
/// Get more YouTube items from the given continuation token and endpoint
|
/// Get more YouTube items from the given continuation token and endpoint
|
||||||
|
|
@ -37,13 +39,13 @@ impl RustyPipeQuery {
|
||||||
};
|
};
|
||||||
|
|
||||||
let p = self
|
let p = self
|
||||||
.execute_request_vdata::<response::MusicContinuation, Paginator<MusicItem>, _>(
|
.execute_request_ctx::<response::MusicContinuation, Paginator<MusicItem>, _>(
|
||||||
ClientType::DesktopMusic,
|
ClientType::DesktopMusic,
|
||||||
"music_continuation",
|
"music_continuation",
|
||||||
ctoken,
|
ctoken,
|
||||||
endpoint.as_str(),
|
endpoint.as_str(),
|
||||||
&request_body,
|
&request_body,
|
||||||
Some(&visitor_data),
|
MapRespCtxSource::visitor_data(&visitor_data),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -138,7 +140,11 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
||||||
self,
|
self,
|
||||||
ctx: &MapRespCtx<'_>,
|
ctx: &MapRespCtx<'_>,
|
||||||
) -> Result<MapResult<Paginator<MusicItem>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<MusicItem>>, ExtractionError> {
|
||||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
let mut mapper = if let Some(artist) = &ctx.artist {
|
||||||
|
MusicListMapper::with_artist(ctx.lang, artist.clone())
|
||||||
|
} else {
|
||||||
|
MusicListMapper::new(ctx.lang)
|
||||||
|
};
|
||||||
let mut continuations = Vec::new();
|
let mut continuations = Vec::new();
|
||||||
|
|
||||||
match self.continuation_contents {
|
match self.continuation_contents {
|
||||||
|
|
@ -156,7 +162,11 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
||||||
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
|
||||||
mapper.map_response(shelf.contents);
|
mapper.map_response(shelf.contents);
|
||||||
}
|
}
|
||||||
_ => {}
|
response::music_item::ItemSection::GridRenderer(mut grid) => {
|
||||||
|
mapper.map_response(grid.items);
|
||||||
|
continuations.append(&mut grid.continuations);
|
||||||
|
}
|
||||||
|
response::music_item::ItemSection::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +183,10 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Some(response::music_item::ContinuationContents::GridContinuation(mut grid)) => {
|
||||||
|
mapper.map_response(grid.items);
|
||||||
|
continuations.append(&mut grid.continuations);
|
||||||
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,6 +271,19 @@ impl<T: FromYtItem> Paginator<T> {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extend the items of the paginator until the paginator is exhausted.
|
||||||
|
pub async fn extend_all<Q: AsRef<RustyPipeQuery>>(&mut self, query: Q) -> Result<(), Error> {
|
||||||
|
let query = query.as_ref();
|
||||||
|
loop {
|
||||||
|
match self.extend(query).await {
|
||||||
|
Ok(false) => break,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Paginator<Comment> {
|
impl Paginator<Comment> {
|
||||||
|
|
@ -334,6 +361,22 @@ macro_rules! paginator {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extend the items of the paginator until the paginator is exhausted.
|
||||||
|
pub async fn extend_all<Q: AsRef<RustyPipeQuery>>(
|
||||||
|
&mut self,
|
||||||
|
query: Q,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let query = query.as_ref();
|
||||||
|
loop {
|
||||||
|
match self.extend(query).await {
|
||||||
|
Ok(false) => break,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ use super::{
|
||||||
self,
|
self,
|
||||||
player::{self, Format},
|
player::{self, Format},
|
||||||
},
|
},
|
||||||
ClientType, MapRespCtx, MapResponse, MapResult, RustyPipeQuery, YTContext,
|
ClientType, MapRespCtx, MapRespCtxSource, MapResponse, MapResult, RustyPipeQuery, YTContext,
|
||||||
DEFAULT_PLAYER_CLIENT_ORDER,
|
DEFAULT_PLAYER_CLIENT_ORDER,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -162,14 +162,16 @@ impl RustyPipeQuery {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request_deobf::<response::Player, _, _>(
|
self.execute_request_ctx::<response::Player, _, _>(
|
||||||
client_type,
|
client_type,
|
||||||
"player",
|
"player",
|
||||||
video_id,
|
video_id,
|
||||||
"player",
|
"player",
|
||||||
&request_body,
|
&request_body,
|
||||||
None,
|
MapRespCtxSource {
|
||||||
Some(&deobf),
|
deobf: Some(&deobf),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -763,6 +765,7 @@ mod tests {
|
||||||
deobf: Some(&DEOBF_DATA),
|
deobf: Some(&DEOBF_DATA),
|
||||||
visitor_data: None,
|
visitor_data: None,
|
||||||
client_type,
|
client_type,
|
||||||
|
artist: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ use crate::{
|
||||||
util::{self, dictionary, timeago, TryRemove},
|
util::{self, dictionary, timeago, TryRemove},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{response, ClientType, MapRespCtx, MapResponse, MapResult, QBrowse, RustyPipeQuery};
|
use super::{
|
||||||
|
response, ClientType, MapRespCtx, MapRespCtxSource, MapResponse, MapResult, QBrowse,
|
||||||
|
RustyPipeQuery,
|
||||||
|
};
|
||||||
|
|
||||||
impl RustyPipeQuery {
|
impl RustyPipeQuery {
|
||||||
/// Get a YouTube playlist
|
/// Get a YouTube playlist
|
||||||
|
|
@ -34,13 +37,16 @@ impl RustyPipeQuery {
|
||||||
browse_id: &format!("VL{playlist_id}"),
|
browse_id: &format!("VL{playlist_id}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.execute_request_vdata::<response::Playlist, _, _>(
|
self.execute_request_ctx::<response::Playlist, _, _>(
|
||||||
ClientType::Desktop,
|
ClientType::Desktop,
|
||||||
"playlist",
|
"playlist",
|
||||||
playlist_id,
|
playlist_id,
|
||||||
"browse",
|
"browse",
|
||||||
&request_body,
|
&request_body,
|
||||||
visitor_data.as_deref(),
|
MapRespCtxSource {
|
||||||
|
visitor_data: visitor_data.as_deref(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@ pub(crate) enum ContinuationContents {
|
||||||
MusicShelfContinuation(MusicShelf),
|
MusicShelfContinuation(MusicShelf),
|
||||||
SectionListContinuation(ContentsRenderer<ItemSection>),
|
SectionListContinuation(ContentsRenderer<ItemSection>),
|
||||||
PlaylistPanelContinuation(PlaylistPanelRenderer),
|
PlaylistPanelContinuation(PlaylistPanelRenderer),
|
||||||
|
GridContinuation(GridRenderer),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -381,11 +382,15 @@ pub(crate) struct Grid {
|
||||||
pub grid_renderer: GridRenderer,
|
pub grid_renderer: GridRenderer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct GridRenderer {
|
pub(crate) struct GridRenderer {
|
||||||
pub items: MapResult<Vec<MusicResponseItem>>,
|
pub items: MapResult<Vec<MusicResponseItem>>,
|
||||||
pub header: Option<GridHeader>,
|
pub header: Option<GridHeader>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
pub continuations: Vec<MusicContinuationData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -23,9 +23,10 @@ pub enum ChannelVideoTab {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort order for channel videos
|
/// Sort order for channel videos
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ChannelOrder {
|
pub enum ChannelOrder {
|
||||||
/// Order videos with the latest upload date first (default)
|
/// Order videos with the latest upload date first (default)
|
||||||
|
#[default]
|
||||||
Latest = 1,
|
Latest = 1,
|
||||||
/// Order videos with the highest number of views first
|
/// Order videos with the highest number of views first
|
||||||
Popular = 2,
|
Popular = 2,
|
||||||
|
|
@ -43,3 +44,23 @@ impl ChannelVideoTab {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sort order for YTM artist albums
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AlbumOrder {
|
||||||
|
/// Sort albums by release date
|
||||||
|
Recency = 1,
|
||||||
|
/// Sort albums by popularity
|
||||||
|
Popularity = 2,
|
||||||
|
/// Sort albums by their name
|
||||||
|
Alphabetical = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter for YTM artist albums
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AlbumFilter {
|
||||||
|
/// Only show albums
|
||||||
|
Albums = 1,
|
||||||
|
/// Only show singles
|
||||||
|
Singles = 2,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ impl SearchFilter {
|
||||||
if let Some(sort) = self.sort {
|
if let Some(sort) = self.sort {
|
||||||
pb.varint(1, sort as u64);
|
pb.varint(1, sort as u64);
|
||||||
}
|
}
|
||||||
if !filters.bytes.is_empty() {
|
if !filters.is_empty() {
|
||||||
pb.embedded(2, filters);
|
pb.embedded(2, filters);
|
||||||
}
|
}
|
||||||
if self.verbatim {
|
if self.verbatim {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/// [`ProtoBuilder`] is used to construct protobuf messages using a builder pattern
|
/// [`ProtoBuilder`] is used to construct protobuf messages using a builder pattern
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ProtoBuilder {
|
pub struct ProtoBuilder {
|
||||||
pub bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProtoBuilder {
|
impl ProtoBuilder {
|
||||||
|
|
@ -39,6 +39,11 @@ impl ProtoBuilder {
|
||||||
self._varint(val);
|
self._varint(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the builder contains no data
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.bytes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
/// Write a varint field
|
/// Write a varint field
|
||||||
pub fn varint(&mut self, field: u32, val: u64) {
|
pub fn varint(&mut self, field: u32, val: u64) {
|
||||||
self._field(field, 0);
|
self._field(field, 0);
|
||||||
|
|
@ -52,6 +57,13 @@ impl ProtoBuilder {
|
||||||
self.bytes.extend_from_slice(string.as_bytes());
|
self.bytes.extend_from_slice(string.as_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write a bytes field
|
||||||
|
pub fn bytes(&mut self, field: u32, bytes: &[u8]) {
|
||||||
|
self._field(field, 2);
|
||||||
|
self._varint(bytes.len() as u64);
|
||||||
|
self.bytes.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/// Write an embedded message
|
/// Write an embedded message
|
||||||
///
|
///
|
||||||
/// Requires passing another [`ProtoBuilder`] with the embedded message.
|
/// Requires passing another [`ProtoBuilder`] with the embedded message.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
8442
testfiles/music_artist/artist_default_2.json
Normal file
8442
testfiles/music_artist/artist_default_2.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ use std::fmt::Display;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
|
use rustypipe::param::{AlbumOrder, LANGUAGES};
|
||||||
use time::{macros::date, OffsetDateTime};
|
use time::{macros::date, OffsetDateTime};
|
||||||
|
|
||||||
use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery};
|
use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery};
|
||||||
|
|
@ -1719,7 +1720,11 @@ async fn music_artist(
|
||||||
|
|
||||||
// Fetch albums seperately
|
// Fetch albums seperately
|
||||||
if name != "no_artist" {
|
if name != "no_artist" {
|
||||||
let albums = rp.query().music_artist_albums(id).await.unwrap();
|
let albums = rp
|
||||||
|
.query()
|
||||||
|
.music_artist_albums(id, None, Some(AlbumOrder::Recency))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let albums_expect = artist
|
let albums_expect = artist
|
||||||
.albums
|
.albums
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -1758,7 +1763,7 @@ async fn music_artist_not_found(rp: RustyPipe) {
|
||||||
async fn music_artist_albums_not_found(rp: RustyPipe) {
|
async fn music_artist_albums_not_found(rp: RustyPipe) {
|
||||||
let err = rp
|
let err = rp
|
||||||
.query()
|
.query()
|
||||||
.music_artist_albums("UC7cl4MmM6ZZ2TcFyMk_b4pq")
|
.music_artist_albums("UC7cl4MmM6ZZ2TcFyMk_b4pq", None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
|
|
@ -2636,23 +2641,6 @@ async fn music_genre_not_found(rp: RustyPipe) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
//#AB TESTS
|
|
||||||
|
|
||||||
const VISITOR_DATA_SEARCH_CHANNEL_HANDLES: &str = "CgszYlc1Yk1WZGRCSSjrwOSbBg%3D%3D";
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn ab3_search_channel_handles() {
|
|
||||||
let rp = rp_visitor_data(VISITOR_DATA_SEARCH_CHANNEL_HANDLES);
|
|
||||||
|
|
||||||
rp.query()
|
|
||||||
.search_filter::<YouTubeItem, _>(
|
|
||||||
"test",
|
|
||||||
&SearchFilter::new().item_type(search_filter::ItemType::Channel),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
//#MISCELLANEOUS
|
//#MISCELLANEOUS
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
|
@ -2677,6 +2665,25 @@ async fn invalid_ctoken(#[case] ep: ContinuationEndpoint, rp: RustyPipe) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn isrc_search_languages(rp: RustyPipe) {
|
||||||
|
for lang in LANGUAGES {
|
||||||
|
let tracks = rp
|
||||||
|
.query()
|
||||||
|
.lang(lang)
|
||||||
|
.music_search_tracks("DEUM71602459")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let working = tracks.items.items.iter().any(|t| t.id == "g0iRiJ_ck48");
|
||||||
|
assert_eq!(
|
||||||
|
working,
|
||||||
|
!matches!(lang, Language::En | Language::EnGb | Language::EnIn),
|
||||||
|
"lang: {lang}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//#TESTUTIL
|
//#TESTUTIL
|
||||||
|
|
||||||
/// Get the language setting from the environment variable
|
/// Get the language setting from the environment variable
|
||||||
|
|
@ -2706,6 +2713,7 @@ fn unlocalized(lang: Language) -> bool {
|
||||||
lang == Language::En
|
lang == Language::En
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
/// Get a new RustyPipe instance with pre-set visitor data
|
/// Get a new RustyPipe instance with pre-set visitor data
|
||||||
fn rp_visitor_data(vdata: &str) -> RustyPipe {
|
fn rp_visitor_data(vdata: &str) -> RustyPipe {
|
||||||
RustyPipe::builder()
|
RustyPipe::builder()
|
||||||
|
|
@ -2713,7 +2721,7 @@ fn rp_visitor_data(vdata: &str) -> RustyPipe {
|
||||||
.visitor_data(vdata)
|
.visitor_data(vdata)
|
||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}*/
|
||||||
|
|
||||||
/// Assert equality within 10% margin
|
/// Assert equality within 10% margin
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
|
|
|
||||||
Reference in a new issue